diff --git a/pk3_build/sound/objectives/rustchain/LICENSE b/pk3_build/sound/objectives/rustchain/LICENSE new file mode 100644 index 0000000..378b81c --- /dev/null +++ b/pk3_build/sound/objectives/rustchain/LICENSE @@ -0,0 +1,8 @@ +CC0 1.0 Universal + +The RustChain objective control cue sound design, generated audio files, and +deterministic synthesis source in `tools/audio/generate_rustchain_objective_cues.py` +are dedicated to the public domain under Creative Commons CC0 1.0 Universal. + +No external samples, recordings, speech models, or third-party audio assets are +used. diff --git a/pk3_build/sound/objectives/rustchain/README.md b/pk3_build/sound/objectives/rustchain/README.md new file mode 100644 index 0000000..a710247 --- /dev/null +++ b/pk3_build/sound/objectives/rustchain/README.md @@ -0,0 +1,39 @@ +# RustChain objective control cue set + +Five original short nonverbal cues for RustChain Arena objective, capture, and +reward-route moments: + +| File | Cue | +|---|---| +| `capture_initiated.ogg` | Objective capture started / node handshake | +| `node_contested.ogg` | Validator node contested / control conflict | +| `validator_lock.ogg` | Capture lock or validator control secured | +| `reward_route_open.ogg` | Reward route, payout lane, or bonus gate opened | +| `objective_lost.ogg` | Objective lost / control reverted | + +All files are mono OGG Vorbis at 48 kHz and are generated from deterministic +procedural synthesis. No external samples, speech models, or recordings are +used. + +Suggested virtual paths are relative to `sound/`, for example: + +```c +precache_sound("objectives/rustchain/validator_lock.ogg"); +sound(world, CHAN_AUTO, "objectives/rustchain/validator_lock.ogg", VOL_BASE, ATTEN_NORM); +``` + +## Regeneration + +From the repository root: + +```sh +python3 tools/audio/generate_rustchain_objective_cues.py +``` + +The generator uses Python's standard library for synthesis and ffmpeg only for +OGG Vorbis encoding. + +## License + +The sound design, PCM synthesis code, and generated audio are dedicated to the +public domain under CC0 1.0. See `LICENSE`. diff --git a/pk3_build/sound/objectives/rustchain/capture_initiated.ogg b/pk3_build/sound/objectives/rustchain/capture_initiated.ogg new file mode 100644 index 0000000..a34c042 Binary files /dev/null and b/pk3_build/sound/objectives/rustchain/capture_initiated.ogg differ diff --git a/pk3_build/sound/objectives/rustchain/node_contested.ogg b/pk3_build/sound/objectives/rustchain/node_contested.ogg new file mode 100644 index 0000000..30e65e1 Binary files /dev/null and b/pk3_build/sound/objectives/rustchain/node_contested.ogg differ diff --git a/pk3_build/sound/objectives/rustchain/objective_lost.ogg b/pk3_build/sound/objectives/rustchain/objective_lost.ogg new file mode 100644 index 0000000..3d8739c Binary files /dev/null and b/pk3_build/sound/objectives/rustchain/objective_lost.ogg differ diff --git a/pk3_build/sound/objectives/rustchain/reward_route_open.ogg b/pk3_build/sound/objectives/rustchain/reward_route_open.ogg new file mode 100644 index 0000000..3b72a3b Binary files /dev/null and b/pk3_build/sound/objectives/rustchain/reward_route_open.ogg differ diff --git a/pk3_build/sound/objectives/rustchain/validator_lock.ogg b/pk3_build/sound/objectives/rustchain/validator_lock.ogg new file mode 100644 index 0000000..1793768 Binary files /dev/null and b/pk3_build/sound/objectives/rustchain/validator_lock.ogg differ diff --git a/tests/test_objective_cue_sounds.py b/tests/test_objective_cue_sounds.py new file mode 100644 index 0000000..5352601 --- /dev/null +++ b/tests/test_objective_cue_sounds.py @@ -0,0 +1,63 @@ +import importlib.util +import math +import tempfile +import unittest +import wave +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "tools" / "audio" / "generate_rustchain_objective_cues.py" +OBJECTIVE_DIR = ROOT / "pk3_build" / "sound" / "objectives" / "rustchain" +SPEC = importlib.util.spec_from_file_location("rustchain_objective_generator", MODULE_PATH) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(MODULE) + + +class ObjectiveCuePackageTests(unittest.TestCase): + def test_required_objective_cues_are_present(self): + self.assertEqual( + set(MODULE.OBJECTIVE_CUES), + { + "capture_initiated", + "node_contested", + "validator_lock", + "reward_route_open", + "objective_lost", + }, + ) + + def test_generated_samples_are_finite_audible_and_short(self): + for name, generator in MODULE.OBJECTIVE_CUES.items(): + samples = generator() + self.assertGreater(len(samples), MODULE.SAMPLE_RATE // 2, name) + self.assertLess(len(samples), MODULE.SAMPLE_RATE * 2, name) + self.assertTrue(all(math.isfinite(sample) for sample in samples), name) + self.assertGreater(max(abs(sample) for sample in samples), 0.05, name) + + def test_wav_output_is_mono_48khz_pcm(self): + with tempfile.TemporaryDirectory() as temp_dir: + output = Path(temp_dir) / "objective.wav" + MODULE.write_wav(output, MODULE.validator_lock()) + with wave.open(str(output), "rb") as audio: + self.assertEqual(audio.getnchannels(), 1) + self.assertEqual(audio.getframerate(), 48_000) + self.assertEqual(audio.getsampwidth(), 2) + self.assertGreater(audio.getnframes(), 0) + + def test_metadata_files_are_packaged(self): + readme = (OBJECTIVE_DIR / "README.md").read_text() + self.assertIn("objective control cue", readme) + self.assertIn("CC0 1.0", (OBJECTIVE_DIR / "LICENSE").read_text()) + + def test_packaged_files_are_ogg_vorbis(self): + for name in MODULE.OBJECTIVE_CUES: + payload = (OBJECTIVE_DIR / f"{name}.ogg").read_bytes() + self.assertTrue(payload.startswith(b"OggS"), name) + self.assertIn(b"\x01vorbis", payload[:256], name) + self.assertGreater(len(payload), 1_000, name) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/audio/generate_rustchain_objective_cues.py b/tools/audio/generate_rustchain_objective_cues.py new file mode 100644 index 0000000..69da8df --- /dev/null +++ b/tools/audio/generate_rustchain_objective_cues.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Generate original RustChain Arena objective/control cue sounds. + +The cues are deterministic procedural synthesis: no samples, speech models, +recordings, or third-party audio are used. ffmpeg is used only for OGG Vorbis +encoding. +""" + +from __future__ import annotations + +import argparse +import math +import shutil +import struct +import subprocess +import tempfile +import wave +from pathlib import Path +from typing import Callable + + +SAMPLE_RATE = 48_000 +TARGET_PEAK = 10 ** (-1.2 / 20.0) + + +class Noise: + def __init__(self, seed: int) -> None: + self.state = seed & 0xFFFFFFFF + + def next(self) -> float: + self.state = (1664525 * self.state + 1013904223) & 0xFFFFFFFF + return ((self.state / 0xFFFFFFFF) * 2.0) - 1.0 + + +def blank(seconds: float) -> list[float]: + return [0.0] * round(seconds * SAMPLE_RATE) + + +def add_tone( + samples: list[float], + start: float, + duration: float, + start_hz: float, + end_hz: float, + gain: float, + *, + attack: float = 0.006, + release_power: float = 2.0, + harmonics: tuple[float, ...] = (1.0, 0.25, 0.08), +) -> None: + start_index = round(start * SAMPLE_RATE) + count = min(round(duration * SAMPLE_RATE), len(samples) - start_index) + phases = [0.0] * len(harmonics) + for offset in range(max(0, count)): + progress = offset / max(1, count - 1) + frequency = start_hz + (end_hz - start_hz) * progress + envelope = min(1.0, offset / max(1, round(attack * SAMPLE_RATE))) + envelope *= (1.0 - progress) ** release_power + value = 0.0 + for harmonic_index, harmonic_gain in enumerate(harmonics, start=1): + phases[harmonic_index - 1] += ( + 2.0 * math.pi * frequency * harmonic_index / SAMPLE_RATE + ) + value += harmonic_gain * math.sin(phases[harmonic_index - 1]) + samples[start_index + offset] += gain * envelope * value + + +def add_noise( + samples: list[float], + start: float, + duration: float, + gain: float, + seed: int, + *, + smoothing: float = 0.68, + release_power: float = 2.8, +) -> None: + start_index = round(start * SAMPLE_RATE) + count = min(round(duration * SAMPLE_RATE), len(samples) - start_index) + noise = Noise(seed) + filtered = 0.0 + for offset in range(max(0, count)): + progress = offset / max(1, count - 1) + filtered = smoothing * filtered + (1.0 - smoothing) * noise.next() + envelope = min(1.0, offset / max(1, round(0.002 * SAMPLE_RATE))) + envelope *= (1.0 - progress) ** release_power + samples[start_index + offset] += gain * envelope * filtered + + +def add_tick(samples: list[float], start: float, pitch: float, seed: int, gain: float = 0.34) -> None: + add_noise(samples, start, 0.045, gain * 0.45, seed, smoothing=0.34, release_power=4.0) + add_tone( + samples, + start, + 0.085, + pitch, + pitch * 0.70, + gain, + attack=0.001, + release_power=4.2, + harmonics=(1.0, 0.34, 0.12), + ) + + +def add_chord(samples: list[float], start: float, duration: float, pitches: tuple[float, ...], gain: float) -> None: + per_tone = gain / math.sqrt(len(pitches)) + for pitch in pitches: + add_tone(samples, start, duration, pitch, pitch * 1.005, per_tone, release_power=1.6) + + +def capture_initiated() -> list[float]: + samples = blank(1.12) + for index, pitch in enumerate((392.0, 493.88, 659.25)): + add_tick(samples, 0.06 + index * 0.13, pitch, 0xC410 + index, 0.34) + add_tone(samples, 0.40, 0.48, 196.0, 329.63, 0.28, release_power=1.4) + add_chord(samples, 0.62, 0.34, (261.63, 329.63, 392.0), 0.46) + return samples + + +def node_contested() -> list[float]: + samples = blank(1.34) + for index, start in enumerate((0.05, 0.25, 0.45, 0.65)): + add_noise(samples, start, 0.12, 0.42, 0xC0A0 + index, smoothing=0.28) + add_tone(samples, start, 0.18, 880 - index * 80, 140, 0.36, attack=0.001, release_power=2.5) + add_tone(samples, 0.86, 0.30, 110.0, 82.41, 0.42, release_power=2.2) + return samples + + +def validator_lock() -> list[float]: + samples = blank(1.26) + for index, pitch in enumerate((220.0, 277.18, 329.63, 440.0)): + add_tick(samples, 0.08 + index * 0.12, pitch, 0x10CC + index, 0.30) + add_chord(samples, 0.58, 0.52, (220.0, 329.63, 440.0, 554.37), 0.62) + add_noise(samples, 0.58, 0.10, 0.18, 0x10CF, smoothing=0.45) + return samples + + +def reward_route_open() -> list[float]: + samples = blank(1.46) + for index in range(6): + add_tick(samples, 0.05 + index * 0.085, 520 + index * 70, 0x7EED + index, 0.24) + add_tone(samples, 0.52, 0.70, 174.61, 698.46, 0.34, release_power=1.2) + add_chord(samples, 0.84, 0.44, (261.63, 392.0, 523.25, 659.25), 0.56) + return samples + + +def objective_lost() -> list[float]: + samples = blank(1.38) + add_noise(samples, 0.04, 0.16, 0.48, 0x1057, smoothing=0.32) + for index, start in enumerate((0.14, 0.36, 0.58)): + add_tone(samples, start, 0.22, 620 - index * 120, 150 - index * 22, 0.42, attack=0.002, release_power=2.6) + add_noise(samples, start, 0.08, 0.26, 0x1060 + index, smoothing=0.42) + add_chord(samples, 0.82, 0.40, (98.0, 146.83, 196.0), 0.44) + return samples + + +OBJECTIVE_CUES: dict[str, Callable[[], list[float]]] = { + "capture_initiated": capture_initiated, + "node_contested": node_contested, + "validator_lock": validator_lock, + "reward_route_open": reward_route_open, + "objective_lost": objective_lost, +} + + +def normalize(samples: list[float]) -> list[float]: + peak = max((abs(sample) for sample in samples), default=0.0) + scale = TARGET_PEAK / peak if peak else 1.0 + normalized = [max(-1.0, min(1.0, sample * scale)) for sample in samples] + fade_samples = min(round(0.008 * SAMPLE_RATE), len(normalized) // 2) + for index in range(fade_samples): + fade = index / max(1, fade_samples) + normalized[index] *= fade + normalized[-index - 1] *= fade + return normalized + + +def write_wav(path: Path, samples: list[float]) -> None: + pcm = bytearray() + for sample in normalize(samples): + pcm.extend(struct.pack(" None: + subprocess.run( + [ + ffmpeg, + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + str(wav_path), + "-map_metadata", + "-1", + "-ac", + "1", + "-ar", + str(SAMPLE_RATE), + "-c:a", + "libvorbis", + "-q:a", + "6", + str(ogg_path), + ], + check=True, + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--output", + type=Path, + default=Path("pk3_build/sound/objectives/rustchain"), + help="Destination directory for generated OGG files.", + ) + parser.add_argument("--ffmpeg", default=shutil.which("ffmpeg")) + args = parser.parse_args() + + if not args.ffmpeg: + raise SystemExit("ffmpeg was not found on PATH") + + args.output.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="rustchain-objectives-") as temp_dir: + temp_path = Path(temp_dir) + for name, generator in OBJECTIVE_CUES.items(): + wav_path = temp_path / f"{name}.wav" + ogg_path = args.output / f"{name}.ogg" + write_wav(wav_path, generator()) + encode_ogg(wav_path, ogg_path, args.ffmpeg) + print(f"generated {ogg_path}") + + +if __name__ == "__main__": + main()