# 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()