#!/usr/bin/env python """Build Papaya Shooter's mastered audio set. The checked-in source recordings remain untouched. This script creates the runtime masters with deterministic filtering/layering so their peaks, tone and loop seams cannot drift between hand edits. Requirements: Python 3.10+ ffmpeg on PATH """ from __future__ import annotations import math import random import shutil import struct import subprocess import tempfile import urllib.request import wave import hashlib import zipfile from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SOUNDS = ROOT / "assets" / "sounds" RATE = 48_000 FFMPEG = shutil.which("ffmpeg") FFPROBE = shutil.which("ffprobe") MENU_SOURCE = "https://opengameart.org/sites/default/files/pynchon.mp3" MENU_SOURCE_SHA256 = "295bc30016587d909a8b1d494cc7fb13550f5c74807221e9eb0eb9f2769d4e3b" SOURCE_FILES = { # Field-recorded wind, already authored as a loop. CC-BY 3.0: # Jonathan Shaw (InspectorJ), loop edit by AntumDeluge. "wind": ( "https://opengameart.org/sites/default/files/wind-01_0.flac", "4027477c493e26db8e2ea53793a30e376c4665de67c0333d7480777cd85e1639", "wind-01.flac", ), # All sources below are CC0. See assets/sounds/SOURCES.md. "generator": ( "https://opengameart.org/sites/default/files/generator_loop.wav", "64b174cf8ef71e7f71aa03cf29864ab489f7be1f624456cab916043da38911bd", "generator_loop.wav", ), "stomp": ( "https://opengameart.org/sites/default/files/stomp.flac", "c9114c31ab07a371db12bfb22190d3a8e08424813319b67b6040df1f23fa9bba", "stomp.flac", ), "scrapes": ( "https://opengameart.org/sites/default/files/scrapes.zip", "9598070ddd77ed99e922df1d4472a249624adcd945794d1b6eba88ac77f6a014", "scrapes.zip", ), "lightclunk1": ( "https://opengameart.org/sites/default/files/lightclunk1.wav", "d5a2aa8764c02924f6ed6fbaad0c650ab2d57ec57a626cee9e6645f941dbc8fa", "lightclunk1.wav", ), "lightclunk2": ( "https://opengameart.org/sites/default/files/lightclunk2.wav", "987be837a5941ded9a81572c319b9bf8f57d9f89741a5781fc7104adc87d60f5", "lightclunk2.wav", ), "mechanical2": ( "https://opengameart.org/sites/default/files/mechanical2.wav", "27b4b6c3517c90d635295fca48fccd747950f869d680c871662f26038c3665ef", "mechanical2.wav", ), "mechanical_explosion": ( "https://opengameart.org/sites/default/files/mechanical_explosion.wav", "13e9f713bd0d5921b223b01d29d68bb7625a1a1fbf1894af35ebfd5ef72c8632", "mechanical_explosion.wav", ), "air_whoosh": ( "https://opengameart.org/sites/default/files/whoosh2_0.wav", "d3fbff5bb78b289af16b2dfa29083f07ef83b0cbaff4514c48dfda16d082b468", "whoosh2.wav", ), "rocket_engine": ( "https://opengameart.org/sites/default/files/rocket_engine.001.wav", "dc82ef86d6af2278d6592d20372737c32620f1c1957290c79f6643af5381f277", "rocket_engine.wav", ), } def run(args: list[str]) -> None: subprocess.run(args, cwd=ROOT, check=True) def ffmpeg(*args: str) -> None: if not FFMPEG: raise SystemExit("ffmpeg is required to build audio assets") run([FFMPEG, "-hide_banner", "-loglevel", "error", "-y", *args]) def media_duration(path: Path) -> float: if not FFPROBE: raise SystemExit("ffprobe is required to build audio assets") result = subprocess.run([ FFPROBE, "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", str(path), ], check=True, capture_output=True, text=True) return float(result.stdout.strip()) def write_wav(path: Path, channels: list[list[float]]) -> None: count = min(map(len, channels)) path.parent.mkdir(parents=True, exist_ok=True) with wave.open(str(path), "wb") as out: out.setnchannels(len(channels)) out.setsampwidth(2) out.setframerate(RATE) frames = bytearray() for i in range(count): for channel in channels: value = max(-0.999, min(0.999, channel[i])) frames.extend(struct.pack(" list[float]: current = max((abs(v) for v in values), default=1.0) gain = peak / max(current, 1.0e-9) return [v * gain for v in values] def download_sources(temp: Path) -> dict[str, Path]: """Fetch pinned, licensed recordings and fail if any upstream file drifts.""" result: dict[str, Path] = {} for key, (url, expected_sha, filename) in SOURCE_FILES.items(): path = temp / filename urllib.request.urlretrieve(url, path) digest = hashlib.sha256(path.read_bytes()).hexdigest() if digest != expected_sha: raise RuntimeError( f"{key} source checksum changed ({digest}); verify {url}") result[key] = path scrape_dir = temp / "scrapes" with zipfile.ZipFile(result["scrapes"]) as archive: archive.extractall(scrape_dir) for index in range(1, 9): result[f"scrape{index}"] = scrape_dir / f"scrape-{index}.wav" return result def make_loop(source: Path, output: Path, fade: float = 0.18) -> None: """Rotate a processed recording through a tail/head crossfade.""" duration = media_duration(source) if duration < 3.5 + fade: raise RuntimeError(f"{source.name}: source is too short to loop") graph = ( f"[0:a]atrim=start={fade}:end={duration - fade}," "asetpts=PTS-STARTPTS[body];" f"[0:a]atrim=start={duration - fade}:end={duration}," "asetpts=PTS-STARTPTS[tail];" f"[0:a]atrim=start=0:end={fade},asetpts=PTS-STARTPTS[head];" f"[tail][head]acrossfade=d={fade}:c1=tri:c2=tri[seam];" "[body][seam]concat=n=2:v=0:a=1[out]" ) ffmpeg("-i", str(source), "-filter_complex", graph, "-map", "[out]", "-ar", str(RATE), "-c:a", "pcm_s16le", str(output)) def make_recording_sequence( inputs: list[Path], output: Path, filters: str, channels: int = 2) -> None: """Concatenate physical gestures, process them, then close the loop seam.""" base = output.with_name(f"{output.stem}_base.wav") args: list[str] = [] labels: list[str] = [] graph_parts: list[str] = [] for index, source in enumerate(inputs): args.extend(["-i", str(source)]) label = f"s{index}" labels.append(f"[{label}]") graph_parts.append( f"[{index}:a]aresample={RATE}," "aformat=sample_fmts=fltp:channel_layouts=mono," f"afade=t=in:st=0:d=0.015,afade=t=out:st=0.95:d=0.12[{label}]") graph_parts.append( "".join(labels) + f"concat=n={len(inputs)}:v=0:a=1," f"{filters}[processed]") ffmpeg(*args, "-filter_complex", ";".join(graph_parts), "-map", "[processed]", "-ar", str(RATE), "-ac", str(channels), "-c:a", "pcm_s16le", str(base)) make_loop(base, output) def make_source_loop( source: Path, output: Path, filters: str, seconds: float, channels: int) -> None: base = source.parent / f"{output.stem}_base.wav" ffmpeg("-i", str(source), "-af", f"{filters},atrim=duration={seconds}", "-ar", str(RATE), "-ac", str(channels), "-c:a", "pcm_s16le", str(base)) make_loop(base, output, 0.30) def build_movement_loops(temp: Path, sources: dict[str, Path]) -> None: # Use almost the complete source-authored wind loop. The previous 8-second # edit exposed its cadence during sustained movement; forty-eight seconds # makes repetition disappear in normal play. A narrower stereo image and # less low ambience make it read as speed over the body, not weather around # the whole map. make_source_loop( sources["wind"], SOUNDS / "movement_wind.wav", "atrim=start=0," "highpass=f=150,lowpass=f=5200:p=2," "equalizer=f=420:t=q:w=0.8:g=2.0," "equalizer=f=2600:t=q:w=1.0:g=-4.5," "stereotools=mlev=0.82:slev=0.40," "acompressor=threshold=-25dB:ratio=1.8:attack=28:release=220:makeup=1dB," "alimiter=limit=0.20:attack=7:release=140:level=0", 48.8, 2) scrape_sets = { "movement_slide_concrete.wav": ( [sources["scrape2"], sources["scrape6"], sources["scrape4"]], "highpass=f=55,lowpass=f=2200:p=2," "equalizer=f=420:t=q:w=0.9:g=1," "equalizer=f=1650:t=q:w=1.0:g=-3," "acompressor=threshold=-28dB:ratio=2.0:attack=18:release=150:makeup=0dB," "alimiter=limit=0.22:attack=7:release=100:level=0", ), "movement_slide_metal.wav": ( [sources["scrape3"], sources["scrape8"], sources["scrape1"]], "highpass=f=75,lowpass=f=2400:p=2," "equalizer=f=680:t=q:w=1.1:g=1.5," "equalizer=f=1750:t=q:w=1.1:g=-4," "acompressor=threshold=-28dB:ratio=2.0:attack=20:release=150:makeup=0dB," "alimiter=limit=0.21:attack=7:release=105:level=0", ), "movement_slide_wood.wav": ( [sources["scrape5"], sources["scrape2"], sources["scrape7"]], "highpass=f=45,lowpass=f=1800:p=2," "equalizer=f=180:t=q:w=0.9:g=2," "equalizer=f=1350:t=q:w=1.0:g=-2.5," "acompressor=threshold=-29dB:ratio=2.0:attack=20:release=165:makeup=0dB," "alimiter=limit=0.22:attack=7:release=110:level=0", ), "movement_slide_glass.wav": ( [sources["scrape1"], sources["scrape4"], sources["scrape8"]], "highpass=f=95,lowpass=f=2300:p=2," "equalizer=f=1000:t=q:w=1.0:g=1," "equalizer=f=1850:t=q:w=1.1:g=-4," "acompressor=threshold=-29dB:ratio=2.0:attack=20:release=145:makeup=0dB," "alimiter=limit=0.19:attack=7:release=100:level=0", ), "movement_wallrun.wav": ( [sources["scrape7"], sources["scrape5"], sources["scrape2"]], "highpass=f=55,lowpass=f=2000:p=2," "equalizer=f=240:t=q:w=0.9:g=1.5," "equalizer=f=1500:t=q:w=1.0:g=-3," "acompressor=threshold=-29dB:ratio=2.0:attack=20:release=160:makeup=0dB," "alimiter=limit=0.19:attack=7:release=110:level=0", ), } for filename, (recordings, filters) in scrape_sets.items(): make_recording_sequence(recordings, temp / filename, filters) shutil.copyfile(temp / filename, SOUNDS / filename) # Cable reel: discrete ratchet/clutch recordings alternating with short # friction pulls. A continuous boiler/generator bed read as another jet, # which is semantically wrong even after aggressive filtering. make_recording_sequence( [sources["mechanical2"], sources["scrape3"], sources["lightclunk2"], sources["scrape8"]], SOUNDS / "movement_grapple_reel.wav", "highpass=f=80,lowpass=f=2200:p=2," "equalizer=f=260:t=q:w=0.8:g=1.5," "equalizer=f=1700:t=q:w=1.1:g=-3," "volume=6dB," "acompressor=threshold=-28dB:ratio=1.8:attack=18:release=135:makeup=0dB," "alimiter=limit=0.18:attack=7:release=105:level=0") make_source_loop( sources["generator"], SOUNDS / "projectile_rocket_loop.wav", "atrim=start=7,highpass=f=38,lowpass=f=1700:p=2," "equalizer=f=95:t=q:w=0.8:g=3," "acompressor=threshold=-26dB:ratio=2.0:attack=20:release=160:makeup=0dB," "alimiter=limit=0.25:attack=7:release=110:level=0", 5.0, 1) make_source_loop( sources["generator"], SOUNDS / "projectile_swarm_loop.wav", "atrim=start=12,highpass=f=75,lowpass=f=2300:p=2," "equalizer=f=380:t=q:w=0.9:g=1.5," "acompressor=threshold=-27dB:ratio=2.0:attack=18:release=145:makeup=0dB," "alimiter=limit=0.20:attack=7:release=100:level=0", 5.0, 1) def synth_foot_layer(kind: str, variant: int, path: Path) -> None: rng = random.Random(3000 + variant * 101 + sum(map(ord, kind))) duration = 0.24 count = round(duration * RATE) values: list[float] = [] low_state = 0.0 configs = { "concrete": (92.0, 0.065, 0.10, 0.14), "metal": (128.0, 0.050, 0.07, 0.11), "wood": (78.0, 0.085, 0.045, 0.18), "glass": (112.0, 0.055, 0.055, 0.11), } body_hz, body_decay, grit, body_gain = configs[kind] body_hz *= 1.0 + (variant - 1.5) * 0.018 for i in range(count): t = i / RATE noise = rng.uniform(-1.0, 1.0) low_state += 0.075 * (noise - low_state) heel = math.exp(-t / body_decay) toe_t = max(0.0, t - (0.052 + variant * 0.003)) toe = math.exp(-toe_t / 0.030) if t > 0.050 else 0.0 body = math.sin(math.tau * body_hz * t) * heel * body_gain texture = low_state * (heel * 0.7 + toe * 0.3) * grit ring = 0.0 if kind == "metal": ring = (math.sin(math.tau * 510.0 * t) + 0.55 * math.sin(math.tau * 735.0 * t)) \ * math.exp(-t / 0.055) * 0.022 elif kind == "wood": ring = math.sin(math.tau * 142.0 * t) \ * math.exp(-t / 0.070) * 0.035 elif kind == "glass": ring = (math.sin(math.tau * 740.0 * t) + 0.35 * math.sin(math.tau * 1120.0 * t)) \ * math.exp(-t / 0.038) * 0.012 values.append(body + texture + ring) write_wav(path, [normalize(values, 0.28)]) def process_footsteps(temp: Path) -> None: sources = sorted(SOUNDS.glob("footstep*.wav")) # Do not recursively remaster outputs from a previous run. sources = [p for p in sources if not any( token in p.stem for token in ("concrete", "metal", "wood", "glass"))] if len(sources) < 4: raise SystemExit("Expected at least four source footstep recordings") filters = { "concrete": "highpass=f=55,lowpass=f=6800,equalizer=f=180:t=q:w=0.8:g=2", "metal": "highpass=f=65,lowpass=f=8200,equalizer=f=2400:t=q:w=1.2:g=-1", "wood": "highpass=f=45,lowpass=f=4700,equalizer=f=150:t=q:w=0.8:g=3", "glass": "highpass=f=65,lowpass=f=7600,equalizer=f=3200:t=q:w=1.0:g=-2", } for kind, source_filter in filters.items(): for variant in range(4): layer = temp / f"{kind}_{variant}.wav" synth_foot_layer(kind, variant, layer) out = SOUNDS / f"footstep_{kind}_{variant:02d}.wav" graph = ( f"[0:a]{source_filter},volume=0.72[src];" "[1:a]volume=0.55[layer];" "[src][layer]amix=inputs=2:duration=longest:normalize=0," "afade=t=out:st=0.19:d=0.05," "alimiter=limit=0.50:attack=2:release=35:level=0[out]" ) ffmpeg("-i", str(sources[variant % len(sources)]), "-i", str(layer), "-filter_complex", graph, "-map", "[out]", "-ar", str(RATE), "-ac", "1", "-c:a", "pcm_s16le", str(out)) def process_layered(source: Path, output: Path, profile: str) -> None: profiles = { "auto": (12_500, 250, 0.30, "36|72", "0.14|0.07", 0.68), "heavy": (11_500, 220, 0.40, "58|116|210", "0.18|0.10|0.055", 0.70), "shotgun": (10_500, 190, 0.48, "65|135|245", "0.20|0.11|0.06", 0.68), "energy": (10_000, 310, 0.24, "45|95", "0.12|0.06", 0.62), "launcher": (9_000, 175, 0.50, "70|145|260", "0.19|0.10|0.05", 0.68), "melee": (9_500, 350, 0.12, "30|65", "0.08|0.04", 0.58), } lowpass, body_lp, body_gain, delays, decays, limit = profiles[profile] graph = ( "[0:a]aformat=sample_fmts=fltp:channel_layouts=mono,asplit=3[d][b][t];" f"[d]highpass=f=38,lowpass=f={lowpass}," "equalizer=f=3600:t=q:w=1.1:g=-2.2,volume=0.74[dry];" f"[b]highpass=f=32,lowpass=f={body_lp},volume={body_gain}[body];" f"[t]aecho=0.8:0.55:{delays}:{decays}," f"lowpass=f={min(6200, lowpass)},volume=0.24[tail];" "[dry][body][tail]amix=inputs=3:duration=longest:normalize=0," f"alimiter=limit={limit}:attack=3:release=80:level=0[out]" ) ffmpeg("-i", str(source), "-filter_complex", graph, "-map", "[out]", "-ar", str(RATE), "-ac", "1", "-c:a", "pcm_s16le", str(output)) def process_weapons() -> None: groups = { "weapon_ak47": ("ak47_fire", "auto"), "weapon_m4": ("m4_fire", "auto"), "weapon_mp7": ("mp7_fire", "auto"), "weapon_dmr": ("dmr_fire", "heavy"), "weapon_awp": ("awp_fire", "heavy"), "weapon_shotgun": ("shotgun_fire", "shotgun"), "weapon_nailgun": ("nailgun_fire", "energy"), "weapon_plasma": ("plasma_fire", "energy"), "weapon_rocket": ("rocket_fire", "launcher"), "weapon_swarm": ("swarm_fire", "launcher"), "weapon_mortar": ("mortar_fire", "launcher"), "weapon_knife": ("knife_swing", "melee"), } for output_id, (source_id, profile) in groups.items(): sources = sorted(SOUNDS.glob(f"{source_id}*.wav")) if not sources: raise SystemExit(f"No source files found for {source_id}") for variant, source in enumerate(sources[:4]): process_layered( source, SOUNDS / f"{output_id}_{variant:02d}.wav", profile) reload_source = SOUNDS / "shotgun_reload.wav" reload_output = SOUNDS / "weapon_reload_00.wav" # This master did not change in the industrial redesign. Avoid rewriting a # stream an open Godot editor may currently have mapped into a live weapon. if not reload_output.exists(): ffmpeg("-i", str(reload_source), "-af", "highpass=f=55,lowpass=f=9000," "equalizer=f=3900:t=q:w=1.1:g=-3," "acompressor=threshold=-16dB:ratio=2.2:attack=800:release=100:" "makeup=1.5dB," "alimiter=limit=0.56:attack=2:release=60:level=0", "-ar", str(RATE), "-ac", "1", "-c:a", "pcm_s16le", str(reload_output)) def process_explosions(sources: dict[str, Path]) -> None: candidates = sorted(SOUNDS.glob("explosion*.wav")) original_explosions = [p for p in candidates if "deep" not in p.stem] for variant, source in enumerate(original_explosions[:5]): graph = ( "[0:a]aformat=sample_fmts=fltp:channel_layouts=mono,asplit=3[d][b][t];" "[d]highpass=f=26,lowpass=f=6800," "equalizer=f=3400:t=q:w=1:g=-4.5,volume=0.92[dry];" "[b]highpass=f=24,lowpass=f=185,volume=0.90[body];" "[t]aecho=0.8:0.55:95|205|390:0.24|0.13|0.07," "lowpass=f=4200,volume=0.36[tail];" "[1:a]aformat=sample_fmts=fltp:channel_layouts=mono," "highpass=f=30,lowpass=f=3200:p=2," "equalizer=f=1900:t=q:w=1.0:g=-4,volume=0.26[metal];" "[dry][body][tail][metal]amix=inputs=4:duration=longest:normalize=0," "acompressor=threshold=-14dB:ratio=2.0:attack=6:release=260:makeup=3dB," "alimiter=limit=0.86:attack=4:release=190:level=0[out]" ) ffmpeg("-i", str(source), "-i", str(sources["mechanical_explosion"]), "-filter_complex", graph, "-map", "[out]", "-ar", str(RATE), "-ac", "1", "-c:a", "pcm_s16le", str(SOUNDS / f"explosion_deep_{variant:02d}.wav")) def make_layered_oneshot( output: Path, layers: list[tuple[Path, float, int, float, float, bool]], final_filters: str) -> None: """Build one tactile cue from recorded gestures, never broadband synthesis. layer tuple: (source, volume, delay_ms, pitch_ratio, trim_seconds, reverse) """ args: list[str] = [] graph: list[str] = [] labels: list[str] = [] for index, (source, volume, delay_ms, pitch, trim, reverse) in enumerate(layers): args.extend(["-i", str(source)]) chain = ( f"[{index}:a]aresample={RATE}," "aformat=sample_fmts=fltp:channel_layouts=mono," f"atrim=start=0:end={trim},asetpts=PTS-STARTPTS") if reverse: chain += ",areverse" if abs(pitch - 1.0) > 0.001: chain += f",asetrate={RATE * pitch:.2f},aresample={RATE}" effective_duration = trim / pitch fade_out = min(0.10, effective_duration * 0.28) fade_start = max(0.01, effective_duration - fade_out) chain += ( f",volume={volume}," "afade=t=in:st=0:d=0.008," f"afade=t=out:st={fade_start:.3f}:d={fade_out:.3f}," f"adelay={delay_ms}") label = f"l{index}" graph.append(f"{chain}[{label}]") labels.append(f"[{label}]") graph.append( "".join(labels) + f"amix=inputs={len(layers)}:duration=longest:normalize=0," + final_filters + "[out]") ffmpeg(*args, "-filter_complex", ";".join(graph), "-map", "[out]", "-ar", str(RATE), "-ac", "1", "-c:a", "pcm_s16le", str(output)) def process_movement_oneshots(sources: dict[str, Path]) -> None: # Ordinary movement stays physical. Only the impossible dash/double jump # receives a restrained thrust layer: the audio version of the 90/10 rule. events = { "movement_dash": [ (sources["air_whoosh"], 0.56, 0, 0.96, 0.48, False), (sources["rocket_engine"], 0.54, 5, 0.90, 0.44, False), (sources["lightclunk2"], 0.10, 28, 0.74, 0.20, False), ], "movement_jump": [ # Two short shoe contacts: heel/body, then toe release. No whoosh, # reversed scrape, engine, mechanism or other thrust-like source. (SOUNDS / "footstep_concrete_00.wav", 0.72, 0, 1.14, 0.19, False), (SOUNDS / "footstep_concrete_02.wav", 0.32, 18, 0.98, 0.15, False), ], "movement_double_jump": [ (sources["air_whoosh"], 0.52, 0, 0.94, 0.46, False), (sources["rocket_engine"], 0.58, 4, 0.88, 0.46, False), (sources["lightclunk1"], 0.09, 24, 0.72, 0.20, False), ], "movement_vault": [ (sources["scrape5"], 0.22, 0, 0.84, 0.48, False), (sources["air_whoosh"], 0.34, 0, 0.92, 0.38, False), (sources["lightclunk1"], 0.12, 70, 0.76, 0.22, False), ], "movement_land": [ (sources["stomp"], 0.74, 0, 0.84, 0.50, False), (sources["lightclunk1"], 0.15, 18, 0.72, 0.24, False), ], "movement_grapple_launch": [ # Spring release, cable friction and trigger clutch. (sources["mechanical2"], 0.32, 0, 0.88, 0.28, False), (sources["scrape5"], 0.16, 12, 0.86, 0.25, False), (sources["lightclunk1"], 0.28, 0, 0.90, 0.18, False), ], "movement_grapple_latch": [ # Hook impact followed by the pawl closing on the line. (sources["lightclunk2"], 0.56, 0, 0.92, 0.24, False), (sources["stomp"], 0.18, 0, 1.08, 0.18, False), (sources["mechanical2"], 0.24, 34, 1.04, 0.25, False), ], } for output_id, layers in events.items(): is_jet = output_id in {"movement_dash", "movement_double_jump"} is_grapple = output_id.startswith("movement_grapple_") upper_cut = 2100 if is_grapple else (2200 if is_jet else 2800) presence_cut = -6.5 if is_grapple else (-5.5 if is_jet else -3.5) make_layered_oneshot( SOUNDS / f"{output_id}_00.wav", layers, f"highpass=f=42,lowpass=f={upper_cut}:p=2," "equalizer=f=170:t=q:w=0.9:g=2," f"equalizer=f=1500:t=q:w=1.1:g={presence_cut}," "acompressor=threshold=-20dB:ratio=1.7:attack=14:release=125:makeup=0dB," "loudnorm=I=-20:TP=-6:LRA=5," "alimiter=limit=0.48:attack=6:release=100:level=0") impacts = sorted(SOUNDS.glob("bullet_impact*.wav")) for variant, source in enumerate(impacts[:4]): ffmpeg("-i", str(source), "-af", "highpass=f=70,lowpass=f=9500," "equalizer=f=4100:t=q:w=1.2:g=-3," "aecho=0.8:0.5:24|52:0.10|0.04," "alimiter=limit=0.42:attack=2:release=45:level=0", "-ar", str(RATE), "-ac", "1", "-c:a", "pcm_s16le", str(SOUNDS / f"impact_bullet_{variant:02d}.wav")) def envelope(t: float, attack: float, release: float, duration: float) -> float: return min(1.0, t / max(attack, 1.0e-6)) \ * min(1.0, (duration - t) / max(release, 1.0e-6)) def build_ui(sources: dict[str, Path]) -> None: # Interface feedback is a family of relay/lever gestures. Pitch stays below # the old arcade chirp range, and semantic weight comes from mechanism size. cues = { "ui_hover_premium_00.wav": [ (sources["lightclunk2"], 0.34, 0, 0.68, 0.18, False), ], "ui_hover_premium_01.wav": [ (sources["lightclunk1"], 0.30, 0, 0.66, 0.18, False), ], "ui_click_premium_00.wav": [ (sources["lightclunk1"], 0.52, 0, 0.72, 0.26, False), ], "ui_click_premium_01.wav": [ (sources["lightclunk2"], 0.54, 0, 0.70, 0.24, False), ], "ui_confirm_premium_00.wav": [ (sources["lightclunk2"], 0.34, 0, 0.68, 0.20, False), (sources["lightclunk1"], 0.46, 46, 0.70, 0.26, False), ], "ui_kill_premium_00.wav": [ (sources["stomp"], 0.44, 0, 0.78, 0.42, False), (sources["lightclunk2"], 0.38, 62, 0.72, 0.24, False), ], "ui_error_premium_00.wav": [ (sources["mechanical2"], 0.28, 0, 0.66, 0.34, False), (sources["lightclunk1"], 0.38, 42, 0.62, 0.24, False), ], "ui_equip_premium_00.wav": [ (sources["mechanical2"], 0.38, 0, 0.68, 0.42, False), (sources["lightclunk2"], 0.30, 58, 0.66, 0.22, False), ], } for filename, layers in cues.items(): make_layered_oneshot( SOUNDS / filename, layers, "highpass=f=60,lowpass=f=2400:p=2," "equalizer=f=210:t=q:w=0.9:g=1.5," "equalizer=f=1500:t=q:w=1.0:g=-3," "equalizer=f=2200:t=q:w=1.0:g=-4," "acompressor=threshold=-22dB:ratio=1.6:attack=12:release=95:makeup=0dB," "loudnorm=I=-22:TP=-8:LRA=4," "alimiter=limit=0.40:attack=5:release=85:level=0") def build_menu_music(temp: Path) -> None: source = temp / "pynchon.mp3" urllib.request.urlretrieve(MENU_SOURCE, source) digest = hashlib.sha256(source.read_bytes()).hexdigest() if digest != MENU_SOURCE_SHA256: raise RuntimeError( "PYNCHON source checksum changed; verify the CC0 download before rebuilding") mastered = temp / "pynchon_mastered.wav" ffmpeg("-i", str(source), "-af", "highpass=f=28,lowpass=f=15500," "loudnorm=I=-18:TP=-2:LRA=5", "-ar", str(RATE), "-ac", "2", "-c:a", "pcm_s16le", str(mastered)) duration = media_duration(mastered) fade = 0.25 # MP3 encoder delay leaves even a source-authored loop with a boundary # click. Rotate by the crossfade length: output starts at +fade, and its # final crossfade ends at that same musical point. graph = ( f"[0:a]atrim=start={fade}:end={duration - fade},asetpts=PTS-STARTPTS[body];" f"[0:a]atrim=start={duration - fade}:end={duration},asetpts=PTS-STARTPTS[tail];" f"[0:a]atrim=start=0:end={fade},asetpts=PTS-STARTPTS[head];" f"[tail][head]acrossfade=d={fade}:c1=tri:c2=tri[seam];" "[body][seam]concat=n=2:v=0:a=1[out]" ) ffmpeg("-i", str(mastered), "-filter_complex", graph, "-map", "[out]", "-ar", str(RATE), "-ac", "2", "-c:a", "libvorbis", "-q:a", "5", str(SOUNDS / "music_menu_premium.ogg")) def main() -> None: SOUNDS.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="papaya-audio-") as raw_temp: temp = Path(raw_temp) sources = download_sources(temp) build_movement_loops(temp, sources) process_footsteps(temp) process_weapons() process_explosions(sources) process_movement_oneshots(sources) build_ui(sources) build_menu_music(temp) print("Built premium audio assets in", SOUNDS) if __name__ == "__main__": main()