Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shweetandClaude Opus 5.5 committed 2026-11-08 01:30:33 -05:00
commit 2007fb7edc
200 files changed
+27710

No files matched your search

+366
View File
@@ -0,0 +1,366 @@
# Builds the game's sound effects (resources/sfx), cut from the Sound Ideas Series 4000 and Guns libraries and the
# Universal Studios one (or synthesized, see tools/sfx_suites.py), then squeezed the way an old console would have to:
# resampled to a low rate with no filtering, and stored as SNES-style BRR (4-bit ADPCM, 16 samples in 9 bytes) before
# being played back with no interpolation. Each sound is one library track's sound (or a few, layered) starting at a
# given time, trimmed where it dies away or runs too long, faded, optionally pitched, and driven towards the loudness
# of the prototype's synthesized version (kept in tools/sfx_loudness.json). A few sounds were kept as they were made
# (from the synthesized set or the first library demo); those files are left alone. SOURCES.md lists where each came
# from and its size as BRR. tools/sfx_review.py auditions them.
#
# python tools/sfx_build.py [sound...]
import json
import math
import pathlib
import subprocess
import sys
import tempfile
import wave
import numpy as np
import sfx_suites
ROOT = pathlib.Path(__file__).resolve().parent.parent
SFX = ROOT / "resources" / "sfx"
LOUDNESS = json.loads((ROOT / "tools" / "sfx_loudness.json").read_text())
SOUNDS = pathlib.Path("/home/anon/sda/Media/Audio/Sounds")
LIBRARIES = [
"SoundIdeasSeries4000",
"Sound Ideas - Guns Sound Effects Library (24 bit 48 khz)",
"Universal Studios Sound Effects Library (OGG)",
]
RATE = 44100
OUT_RATE = 22050
SAMPLE_RATE = 11025
BRR_BLOCK = 16
BRR_BYTES = 9
BRR_SHIFT_MAX = 12
FRAME = 0.002
GAP = 0.05
THRESHOLD = 0.12
TAIL = 0.02
FADE = 0.012
TRUNCATED_FADE = 0.35
LOUDNESS_WINDOW = 0.05
PEAK = 0.95
DRIVE = 2
# Each game sound: the library track's id ("4001_75_1", "GUNS03_78_2" or "US03-17-1"), the second its sound starts at
# (the start of the track's sound nearest it, or exactly there with "x"; none means its first sound), the longest it
# may run (it also stops where the next sound in the track starts, unless "j" joins them), and optionally a speed
# ("sp", which pitches it too), a gain ("g"), a sample rate ("r") and a longer fade ("fd", the share of it faded out).
# A list layers several, each with an "at" offset in seconds; a layer can be synthesized instead ({"synth": recipe,
# "a": {arguments}}, from tools/sfx_suites.py). {"keep": set} marks a sound whose file is kept as it was made.
PICKS = {
"beam": {"f": "4001_88_7", "s": 1.09, "n": 0.45},
"blast": {"synth": "pew", "a": {"start": 1200, "end": 150, "seconds": 0.18, "duty": 0.125, "octave": 0.5}},
"boomerang": {"f": "4001_75_4", "s": 1.004, "n": 0.3},
"boomerang_heavy": {"f": "4001_75_8", "s": 1.331, "n": 0.5},
"bounce": {"f": "4003_59_1", "s": 0.54, "n": 0.2},
"bow": {"f": "US02-44-1", "s": 0.17, "n": 0.25},
"bow_heavy": {"f": "US02-44-1", "s": 0.17, "n": 0.4, "sp": 0.8},
"break": {"f": "US02-96-1", "s": 0.59, "n": 0.45},
"cast": {"synth": "ember", "a": {}},
"chat": {"synth": "beeps", "a": {"frequency": 1400, "count": 2, "length": 0.035, "gap": 0.055, "duty": 0.25}},
"charge": {"synth": "warp", "a": {"start": 200, "end": 700, "seconds": 0.8, "wobble": 14, "depth": 0.3}},
"crawl": {"keep": "synth"},
"dash": {"f": "4003_47_4", "s": 0.17, "n": 0.25},
"death": [{"f": "US03-34-1", "s": 0.17, "n": 0.45}, {"f": "4001_30_4", "s": 0.8, "n": 0.4, "at": 0.05, "g": 0.6}],
"detonate": {"keep": "synth"},
"detonate_empty": {"f": "GUNS03_24_3", "s": 0.092, "n": 0.08, "r": 16000},
"duck": {"keep": "synth"},
"echo": {"synth": "shimmer", "a": {"frequency": 2000, "rate": 30, "duty": 0.25}},
"equip": {"f": "GUNS03_39_1", "s": 0.032, "n": 0.3},
"explosion": {"f": "4003_97_2", "s": 2.209, "n": 0.9, "r": 8000},
"fall": {"f": "4001_78_3", "s": 1.433, "n": 0.8},
"finish": {"keep": "demo1"},
"fireball": {"keep": "demo1"},
"flail": [{"f": "4003_47_4", "s": 0.17, "n": 0.3}, {"f": "4003_84", "s": 15.46, "n": 0.3, "j": True, "g": 0.6}],
"flail_heavy": [{"f": "4003_47_1", "s": 0.395, "n": 0.45, "sp": 0.7}, {"f": "4003_84", "s": 15.46, "n": 0.45, "j": True, "g": 0.7}],
"flail_land": {"n": 0.3, "f": "US03-37-2", "s": 0.782},
"flame": {"f": "US03-17-2", "s": 0.68, "n": 0.15, "sp": 1.4, "g": 0.6},
"grapple": [{"f": "US04-42-1", "s": 0.16, "n": 0.12}, {"f": "4003_84", "s": 15.46, "n": 0.25, "j": True, "sp": 1.3, "at": 0.03, "g": 0.7}],
"grapple_heavy": [{"f": "4003_47_1", "s": 0.395, "n": 0.4, "sp": 0.8}, {"f": "4003_84", "s": 11.94, "n": 0.35, "j": True, "g": 0.8}],
"impact_arrow": {"f": "4003_63_3", "s": 1.527, "n": 0.2},
"impact_blaster": {"f": "GUNS03_65_8", "s": 0.016, "n": 0.12},
"impact_boomerang": {"f": "4001_03_4", "s": 0.778, "n": 0.18},
"impact_bullet": {"f": "GUNS03_64_1", "s": 0.036, "n": 0.08, "r": 16000},
"impact_mine": {"f": "4003_62_5", "s": 1.269, "n": 0.15},
"jump": {"keep": "synth"},
"land": {"keep": "synth"},
"latch": {"f": "US02-21-3", "s": 0.16, "n": 0.25},
"level_down": {"synth": "arp", "a": {"notes": [784, 622, 523, 392], "seconds": 0.35, "duty": 0.25}},
"level_up": {"synth": "arp", "a": {"notes": [392, 523, 659, 784, 1047], "seconds": 0.35, "duty": 0.25}},
"mine": [{"f": "4003_47_3", "s": 0.18, "n": 0.15}, {"synth": "beeps", "a": {}, "at": 0.12}],
"wisp": {"f": "US02-45-5", "s": 0.18, "n": 0.25},
"poke": {"f": "GUNS02_23_6", "s": 0.098, "n": 0.15},
"poke_heavy": {"f": "GUNS02_24_1", "s": 0.14, "n": 0.35},
"pound": {"synth": "thud", "a": {"start": 120, "end": 35, "seconds": 0.5, "body": 0.8}, "r": 8000},
"punch": {"f": "4003_47_1", "s": 0.395, "n": 0.1},
"ready": {"f": "US03-02-1", "s": 0.16, "n": 0.4},
"ricochet": {"f": "GUNS03_77_14", "s": 9.393, "n": 0.3, "r": 16000},
"rocket": {"f": "GUNS02_99_1", "s": 0.02, "n": 0.45},
"rocket_heavy": {"f": "GUNS02_49_5", "s": 19.681, "n": 0.8, "r": 8000},
"roll": {"keep": "synth"},
"select": {"f": "4003_64_4", "s": 1.648, "n": 0.08, "g": 0.7, "r": 16000},
"shotgun": [{"f": "GUNS02_18_2", "s": 0.01, "n": 0.45, "fd": 0.7}, {"f": "GUNS03_57_6", "s": 0.06, "n": 0.4, "at": 0.32, "g": 0.6, "j": True}],
"shotgun_heavy": [{"f": "GUNS02_19_2", "s": 0.012, "n": 0.5, "fd": 0.6}, {"f": "GUNS03_57_2", "s": 0.06, "n": 0.9, "sp": 0.8, "at": 0.42, "g": 0.6, "j": True, "fd": 0.25}],
"slam": {"f": "US03-39-2", "s": 0.16, "n": 0.6, "sp": 0.7},
"slash": {"f": "4003_47_2", "s": 0.792, "n": 0.2},
"slash_heavy": {"f": "4003_47_2", "s": 0.792, "n": 0.35, "sp": 0.75},
"step": {"f": "4003_46_2", "s": 0.796, "n": 0.08, "g": 0.6, "r": 16000},
"swap": {"keep": "demo1"},
"swing": {"f": "4003_47_1", "s": 0.395, "n": 0.3},
"swing_heavy": {"f": "4003_47_1", "s": 0.395, "n": 0.5, "sp": 0.7},
"teleport": {"f": "US03-68-1", "s": 0.8, "n": 0.3},
"teleport_warp": {"f": "US03-68-3", "s": 0.32, "n": 0.38, "sp": 3},
"unduck": {"keep": "synth"},
"unequip": {"f": "GUNS03_55_1", "s": 0.489, "n": 0.26},
"uppercut": {"synth": "whoosh", "a": {"seconds": 0.5, "low": 0.02, "high": 0.8, "roar": 0.8, "roarStart": 120, "roarEnd": 480}},
"uzi": {"keep": "demo1"},
"uzi_heavy": {"f": "4002_86_3", "s": 1.826, "n": 0.3},
"whip": {"f": "4003_57_4", "s": 2.045, "n": 0.25},
"whip_heavy": {"keep": "demo1 (its whip)"},
"zap": {"f": "US03-12-1", "s": 0.48, "n": 0.2, "j": True},
# Hits, by type, light then heavy
"hit_blunt": {"f": "4003_49_1", "s": 0.407, "n": 0.15},
"hit_blunt_heavy": {"f": "4003_48_6", "s": 0.836, "n": 0.3},
"hit_slash": {"f": "4003_56_4", "s": 0.868, "n": 0.18},
"hit_slash_heavy": {"f": "GUNS02_23_8", "s": 0.14, "n": 0.3},
"hit_pierce": {"f": "GUNS03_64_1", "s": 0.036, "n": 0.12},
"hit_pierce_heavy": {"f": "GUNS02_24_3", "s": 0.122, "n": 0.25},
"hit_lash": {"f": "4003_52_5", "s": 1.213, "n": 0.1},
"hit_lash_heavy": {"f": "4003_52_6", "s": 1.161, "n": 0.2},
"hit_bullet": {"f": "GUNS03_67_7", "s": 0.329, "n": 0.06, "r": 16000},
"hit_bullet_heavy": {"f": "GUNS03_65_12", "s": 0.147, "n": 0.3, "x": True},
"hit_energy": {"f": "4002_27_5", "s": 0.806, "n": 0.14},
"hit_energy_heavy": {"f": "4001_88_8", "s": 0.832, "n": 0.3},
"hit_fire": {"f": "4003_94_4", "s": 3.14, "n": 0.3},
"hit_fire_heavy": {"f": "GUNS02_46_1", "s": 0.85, "n": 0.47, "r": 8000},
"hit_explosive": {"f": "4003_92_4", "s": 2.438, "n": 0.3},
"hit_explosive_heavy": {"f": "4003_97_5", "s": 1.209, "n": 0.5, "r": 8000},
"hit_magic": [{"f": "US03-17-1", "s": 0.17, "n": 0.15, "sp": 1.3, "r": 16000}, {"synth": "sparkle", "a": {}, "g": 0.7}],
"hit_magic_heavy": {"n": 0.3, "r": 16000, "f": "4001_88_9", "s": 1.026},
}
# Any sounds still being chosen take the first of their candidates.
PICKS.update({name: suite[0][1] for name, suite in sfx_suites.SUITES.items()})
def decode(path):
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(path), "-ac", "1", "-ar", str(RATE), "-f", "f32le", "-"],
capture_output=True, check=True).stdout
return np.frombuffer(raw, dtype=np.float32).astype(np.float64)
TRACKS = {}
def track_find(track):
if not TRACKS:
for library in LIBRARIES:
for path in (SOUNDS / library).rglob("*"):
if path.suffix.lower() in (".wav", ".ogg"):
TRACKS[path.stem] = path
matches = [path for stem, path in TRACKS.items() if stem.endswith(" " + track) or stem.startswith(track + " ")]
assert len(matches) == 1, f"{track}: {len(matches)} tracks"
return matches[0]
# The separate sounds in a track: runs of frames louder than a share of the track's peak, merged across short gaps.
# Each is (start, end, peak) in samples.
def events_get(signal):
size = int(RATE * FRAME)
frames = np.abs(signal[: len(signal) // size * size]).reshape(-1, size).max(axis=1)
floor = np.percentile(frames, 10)
above = frames > max(frames.max() * THRESHOLD, floor * 5)
events = []
for i, isAbove in enumerate(above):
if not isAbove:
continue
if events and i - events[-1][1] <= GAP / FRAME:
events[-1][1] = i
else:
events.append([i, i])
return [(start * size, (end + 1) * size, frames[start:end + 1].max()) for start, end in events]
# Where the pick's sound lies in its track, in samples: from just before it starts until it has died away (or the next
# sound starts, or the length runs out), and whether the length cut it short.
def span_get(signal, pick):
events = events_get(signal)
at = int(pick["s"] * RATE) if pick.get("s") is not None else events[0][0]
nearest = min(events, key=lambda event: abs(event[0] - at))
if pick.get("x"):
containing = [event for event in events if event[0] <= at < event[1]]
nearest = containing[0] if containing else nearest
start, eventEnd, peak = nearest
start = at if pick.get("x") else max(0, start - int(0.004 * RATE))
limit = start + int(pick["n"] * pick.get("sp", 1) * RATE)
if pick.get("j"):
joined = [event for event in events if start <= event[0] < limit]
eventEnd, peak = max(event[1] for event in joined + [nearest]), max(event[2] for event in joined + [nearest])
if eventEnd <= start:
# An exact start past the sounds found (in a long, even one): take it as one sound up to the length.
eventEnd, peak = min(limit, len(signal)), np.abs(signal[start:limit]).max()
following = [event[0] for event in events if event[0] > eventEnd]
end = following[0] if following else len(signal)
quiet = np.nonzero(np.abs(signal[eventEnd:end]) > peak * TAIL)[0]
end = min(end, eventEnd + (quiet[-1] if len(quiet) else 0) + int(FADE * RATE))
return start, min(end, limit), limit < end
# The pick's sound, cut from its track (or synthesized) with its end faded: over its last third when the length cut it
# short, or over the share the pick asks.
def cut(pick):
if "synth" in pick:
piece, isTruncated = sfx_suites.SYNTHS[pick["synth"]](**pick.get("a", {})).astype(np.float64), False
else:
signal = decode(track_find(pick["f"]))
start, end, isTruncated = span_get(signal, pick)
piece = signal[start:end].copy()
speed = pick.get("sp", 1)
if speed != 1:
piece = np.interp(np.arange(0, len(piece) - 1, speed), np.arange(len(piece)), piece)
share = pick.get("fd", TRUNCATED_FADE * isTruncated)
fade = min(len(piece), max(int(FADE * RATE), int(len(piece) * share)))
piece[len(piece) - fade:] *= np.linspace(1, 0, fade)
return piece * pick.get("g", 1)
def loudness(signal):
size = int(LOUDNESS_WINDOW * RATE)
if len(signal) <= size:
return np.sqrt(np.mean(signal ** 2))
power = np.convolve(signal ** 2, np.ones(size) / size, mode="valid")
return np.sqrt(power.max())
def sound_build(name, picks):
pieces = [(cut(pick), pick.get("at", 0)) for pick in picks]
length = max(int(at * RATE) + len(piece) for piece, at in pieces)
mixed = np.zeros(length)
for piece, at in pieces:
mixed[int(at * RATE):int(at * RATE) + len(piece)] += piece
# Recordings peak far above their average where the game's sounds are dense, so matching loudness drives the peaks
# into a soft clipper, but only so far (as hot samples did on old consoles); past that they stay quieter.
target = LOUDNESS[name]
gain = min(target / max(loudness(mixed), 1e-9), PEAK * DRIVE / max(np.abs(mixed).max(), 1e-9))
return PEAK * np.tanh(mixed * gain / PEAK)
# The SNES's BRR predictors: each block predicts its samples from the two before, in one of four ways.
def predict(kind, previous, before):
if kind == 1:
return previous * 15 / 16
if kind == 2:
return previous * 61 / 32 - before * 15 / 16
if kind == 3:
return previous * 115 / 64 - before * 13 / 16
return 0 * previous
# Encodes 16-bit samples as BRR and decodes them again, as the console would hear them: each block of 16 keeps the
# predictor and shift (a power-of-two scale for its 4-bit steps) that land closest, every step clamped to 16 bits.
def brr(samples):
count = math.ceil(len(samples) / BRR_BLOCK) * BRR_BLOCK
samples = np.concatenate([samples, np.zeros(count - len(samples))])
shifts = 2.0 ** np.arange(BRR_SHIFT_MAX + 1)
out = np.zeros(count)
previous, before = 0.0, 0.0
for block in range(0, count, BRR_BLOCK):
target = samples[block:block + BRR_BLOCK]
best = None
for kind in range(4):
p = np.full(len(shifts), previous)
b = np.full(len(shifts), before)
decoded = np.zeros((len(shifts), BRR_BLOCK))
for i in range(BRR_BLOCK):
guess = predict(kind, p, b)
nibble = np.clip(np.round((target[i] - guess) / shifts * 2), -8, 7)
value = np.clip(guess + nibble * shifts / 2, -32768, 32767)
decoded[:, i] = value
b, p = p, value
errors = ((decoded - target) ** 2).sum(axis=1)
choice = int(np.argmin(errors))
if best is None or errors[choice] < best[0]:
best = (errors[choice], decoded[choice])
out[block:block + BRR_BLOCK] = best[1]
previous, before = best[1][-1], best[1][-2]
return out, count // BRR_BLOCK * BRR_BYTES
# Squeezes a sound like an old console: down to its sample rate with no filtering (keeping the aliasing), through
# BRR, then held sample by sample (no interpolation) at the output rate.
def crunch(signal, rate):
low = np.interp(np.arange(0, len(signal) - 1, RATE / rate), np.arange(len(signal)), signal)
decoded, size = brr(np.round(low * 32767))
held = decoded[np.minimum((np.arange(int(len(decoded) * OUT_RATE / rate)) * rate / OUT_RATE).astype(int),
len(decoded) - 1)]
return held / 32768, size
def save(path, signal):
with tempfile.TemporaryDirectory() as directory:
wavPath = pathlib.Path(directory) / "sound.wav"
with wave.open(str(wavPath), "wb") as file:
file.setnchannels(1)
file.setsampwidth(2)
file.setframerate(OUT_RATE)
file.writeframes((np.clip(signal, -1, 1) * 32767).astype(np.int16).tobytes())
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(wavPath), "-c:a", "libvorbis", "-q:a", "6", str(path)],
check=True)
# Builds one game sound from its picks into an ogg at path, returning its size as BRR.
def render(name, picks, path):
picks = picks if isinstance(picks, list) else [picks]
signal, size = crunch(sound_build(name, picks), picks[0].get("r", SAMPLE_RATE))
save(path, signal)
return size, len(signal) / OUT_RATE
def picks_get(name):
picks = PICKS[name]
return picks if isinstance(picks, list) else [picks]
def source_describe(pick):
if "synth" in pick:
return f"synth {pick['synth']}"
return track_find(pick["f"]).stem
def main():
names = [name for name in sys.argv[1:] if name in PICKS] or sorted(PICKS)
lines = ["# Sound sources", "",
"Cut from the Sound Ideas Series 4000 and Guns libraries and the Universal Studios one (or synthesized), "
f"resampled with no filtering to {SAMPLE_RATE} Hz unless noted, stored as SNES-style BRR (4-bit ADPCM, "
f"{BRR_BLOCK} samples in {BRR_BYTES} bytes) and played back with no interpolation. Built by "
"tools/sfx_build.py.", "",
"| Sound | Source | From | Speed | Rate | BRR bytes |", "|---|---|---|---|---|---|"]
total = 0
for name in sorted(PICKS):
picks = picks_get(name)
if "keep" in picks[0]:
lines.append(f"| {name} | kept from the {picks[0]['keep']} set | | | | |")
continue
path = SFX / f"{name}.ogg"
if name in names:
size, seconds = render(name, picks, path)
print(name, f"{seconds:.2f}s", f"{size} bytes")
else:
rate = picks[0].get("r", SAMPLE_RATE)
size = math.ceil(len(decode(path)) * rate / RATE / BRR_BLOCK) * BRR_BYTES
total += size
for pick in picks:
start = "" if "synth" in pick else f"{pick['s']:.2f}s" if pick.get("s") is not None else "first"
lines.append(f"| {name} | {source_describe(pick)} | {start} | {pick.get('sp', 1)}x | "
f"{picks[0].get('r', SAMPLE_RATE)} | {size} |")
lines += ["", f"The built sounds come to {total / 1024:.0f} KB as BRR."]
(SFX / "SOURCES.md").write_text("\n".join(lines) + "\n")
print(f"total {total / 1024:.0f} KB")
if __name__ == "__main__":
main()