Files
Papay-Shooter/tools/build_audio_assets.py
T
2026-08-02 02:20:02 -04:00

656 lines
28 KiB
Python

#!/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("<h", round(value * 32767.0)))
out.writeframes(frames)
def normalize(values: list[float], peak: float) -> 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()