656 lines
28 KiB
Python
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()
|