Files
smack-a-brother/tools/sfx_review.py
T
shweetandClaude Opus 5.5 2007fb7edc Initial commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:30:33 -05:00

780 lines
42 KiB
Python

# A local page for reviewing the game's sounds (resources/sfx) and leaving notes on them. Each sound shows when it
# plays, plays beside the last round's version, the prototype's synthesized one and the first library demo's (those
# sets live in build/sfx-sets while the review lasts), and shows the library track it was cut from with the cut marked
# (click the waveform to hear any part, shift-click to suggest a new start). Its candidates (tools/sfx_suites.py, then
# the closest library sounds) can be auditioned, crunched exactly as the game's sound would be, and chosen. Verdicts,
# notes, suggested starts and choices save as they're made to tools/sfx_review_notes.json; the last round's notes
# (tools/sfx_review_round<n>.json, with that round's sounds in build/sfx-sets/round<n>) show beside them.
#
# python tools/sfx_review.py [port] [--no-browser]
import hashlib
import http.server
import json
import math
import pathlib
import re
import subprocess
import sys
import threading
import urllib.parse
import webbrowser
from multiprocessing import Pool
import numpy as np
import sfx_build as retro
import sfx_suites
ROOT = retro.ROOT
NOTES = ROOT / "tools" / "sfx_review_notes.json"
LAST_ROUND = max([int(path.stem.removeprefix("sfx_review_round")) for path in (ROOT / "tools").glob("sfx_review_round*.json")],
default=0)
PREVIOUS = ROOT / "tools" / f"sfx_review_round{LAST_ROUND}.json"
CACHE = ROOT / "build" / "sfx-review"
SETS = {"game": retro.SFX, "last": ROOT / "build" / "sfx-sets" / f"round{LAST_ROUND}",
"synth": ROOT / "build" / "sfx-sets" / "synth", "demo": ROOT / "build" / "sfx-sets" / "demo1"}
ANALYSIS_RATE = 22050
WAVE_RATE = 8000
WAVE_BINS = 900
ALTERNATIVES = 4
# Each sound's group, and when the game plays it.
CONTEXT = {
"jump": ("Movement", "Jumping off the ground, a wall or in mid-air."),
"land": ("Movement", "Landing from a fall fast enough to kick up dust."),
"step": ("Movement", "Each footstep while running."),
"crawl": ("Movement", "Each shuffle while crawling (crouched and moving)."),
"duck": ("Movement", "Crouching down."),
"unduck": ("Movement", "Standing back up from a crouch."),
"roll": ("Movement", "Each roll while sliding down a slope crouched."),
"dash": ("Movement", "Wings: an air dash toward the cursor."),
"bounce": ("Movement", "A launched fighter bouncing off a wall or floor; also a Hammer head rebounding off a wall."),
"fall": ("Movement", "Race: falling into a pit (put back on safe ground)."),
"pound": ("Movement", "Iron Weights: the blast from a hard landing."),
"death": ("Match", "A fighter running out of health and bursting."),
"finish": ("Match", "Race: a fighter crossing the finish."),
"break": ("Match", "A breakable block being knocked out."),
"ready": ("Match", "A charge reaching full (the charge is ready to release)."),
"charge": ("Match", "While charging an attack, until it's full or let go."),
"select": ("Menu", "Menu clicks: tabs, buttons, toggles, picking a weapon slot, opening the menu."),
"swap": ("Menu", "Closing the menu; switching tabs."),
"equip": ("Menu", "Putting a weapon or trinket into a slot."),
"unequip": ("Menu", "Taking a weapon or trinket out of a slot."),
"level_up": ("Menu", "Levelling an item up."),
"level_down": ("Menu", "Levelling an item down."),
"echo": ("Trinkets", "Doubler: the delayed echo of an attack (replaces the attack's own sound)."),
"ricochet": ("Trinkets", "Ricochet: a shot bouncing off a wall."),
"zap": ("Trinkets", "Tesla Coil: lightning between your attacks hitting someone."),
"slash": ("Weapons", "Sword: a normal slash."),
"slash_heavy": ("Weapons", "Sword: the charged slash that travels forward."),
"swing": ("Weapons", "Hammer: a normal overhead swing."),
"swing_heavy": ("Weapons", "Hammer: the charged swing that grows and slams."),
"slam": ("Weapons", "Hammer: the charged swing's head slamming the ground at the end."),
"poke": ("Weapons", "Lance: a normal poke."),
"poke_heavy": ("Weapons", "Lance: the charged lunge."),
"punch": ("Weapons", "Fist: a normal punch (fast, repeats a lot)."),
"uppercut": ("Weapons", "Fist: the charged uppercut that launches you up."),
"flail": ("Weapons", "Flail: each lap of the ball spinning overhead while held."),
"flail_heavy": ("Weapons", "Flail: the charged throw of the ball."),
"flail_land": ("Weapons", "Flail: the thrown ball landing on a surface."),
"whip": ("Weapons", "Whip: a normal lash."),
"whip_heavy": ("Weapons", "Whip: the charged lash."),
"grapple": ("Weapons", "Grapple: firing the hook."),
"grapple_heavy": ("Weapons", "Grapple: firing the charged hook (plunges in and yanks you)."),
"latch": ("Weapons", "Grapple: the hook catching on a wall or fighter."),
"boomerang": ("Weapons", "Boomerang: a normal throw."),
"boomerang_heavy": ("Weapons", "Boomerang: the charged throw (you can stand on it)."),
"bow": ("Weapons", "Bow: a normal arrow."),
"bow_heavy": ("Weapons", "Bow: a fully drawn arrow."),
"uzi": ("Weapons", "Uzi: each shot (very rapid fire)."),
"uzi_heavy": ("Weapons", "Uzi: the charged burst."),
"shotgun": ("Weapons", "Shotgun: a normal blast of pellets (kicks you back)."),
"shotgun_heavy": ("Weapons", "Shotgun: the charged slug."),
"blast": ("Weapons", "Blaster: a normal energy bolt."),
"beam": ("Weapons", "Blaster: the charged beam."),
"flame": ("Weapons", "Flamethrower: each puff of flame (rapid)."),
"fireball": ("Weapons", "Flamethrower: the charged fireball."),
"rocket": ("Weapons", "Rocket Launcher: a normal rocket."),
"rocket_heavy": ("Weapons", "Rocket Launcher: the charged rocket."),
"explosion": ("Weapons", "Any explosion: rockets, fireballs, mines going off."),
"mine": ("Weapons", "Mine: placing a mine (charged attack)."),
"detonate": ("Weapons", "Mine: the detonator click that sets your mines off."),
"detonate_empty": ("Weapons", "Mine: the detonator clicking with no armed mines out."),
"cast": ("Weapons", "Grimoire: casting wisps (they join the orbit around you)."),
"wisp": ("Weapons", "Grimoire: each wisp setting off toward the cursor."),
"teleport_warp": ("Weapons", "Grimoire: the teleport gathering (a short warning before you vanish)."),
"teleport": ("Weapons", "Grimoire: arriving, with a small blast."),
"impact_arrow": ("Impacts", "An arrow sticking into a wall."),
"impact_blaster": ("Impacts", "A Blaster bolt hitting a wall."),
"impact_bullet": ("Impacts", "A bullet (Uzi, Shotgun) hitting a wall."),
"impact_boomerang": ("Impacts", "A boomerang hitting a wall."),
"impact_mine": ("Impacts", "A thrown mine landing."),
"hit_blunt": ("Hits", "Blunt hit under 40 damage: Hammer, Fist, Flail, Boomerang, Pebble Helmet contact."),
"hit_blunt_heavy": ("Hits", "Blunt hit of 40+ damage; also a launched fighter slamming hard into a wall."),
"hit_slash": ("Hits", "Slash hit under 40 damage: Sword."),
"hit_slash_heavy": ("Hits", "Slash hit of 40+ damage: Sword."),
"hit_pierce": ("Hits", "Pierce hit under 40 damage: Lance, Bow, Grapple."),
"hit_pierce_heavy": ("Hits", "Pierce hit of 40+ damage: Lance, Bow, Grapple."),
"hit_lash": ("Hits", "Lash hit under 40 damage: Whip."),
"hit_lash_heavy": ("Hits", "Lash hit of 40+ damage: Whip."),
"hit_bullet": ("Hits", "Bullet hit under 40 damage: Uzi, Shotgun pellets (very frequent)."),
"hit_bullet_heavy": ("Hits", "Bullet hit of 40+ damage: mostly the Shotgun's charged slug."),
"hit_energy": ("Hits", "Energy hit under 40 damage: Blaster."),
"hit_energy_heavy": ("Hits", "Energy hit of 40+ damage: Blaster."),
"hit_fire": ("Hits", "Fire hit under 40 damage: Flamethrower (frequent)."),
"hit_fire_heavy": ("Hits", "Fire hit of 40+ damage: Flamethrower's fireball."),
"hit_explosive": ("Hits", "Explosive hit under 40 damage: Rocket Launcher, Mine."),
"hit_explosive_heavy": ("Hits", "Explosive hit of 40+ damage: Rocket Launcher, Mine."),
"hit_magic": ("Hits", "Magic hit under 40 damage: Grimoire wisps and teleport blasts."),
"hit_magic_heavy": ("Hits", "Magic hit of 40+ damage: Grimoire."),
}
GROUPS = ["Movement", "Match", "Menu", "Trinkets", "Weapons", "Impacts", "Hits"]
# Where to look for alternatives to each sound: library categories (by file name), and the length, brightness (Hz)
# and pitch direction a good one has.
CANDIDATES = {
"jump": (r"^Cartoon(Zip|Whizz)", .12, None, "up"),
"land": (r"^(BodyfallHuman|DropWood)", .1, 800, None),
"step": (r"^(CartoonFootsteps|FootstepsSpurs)", .05, None, None),
"crawl": (r"^(RipFabric|CartoonSquish)", .1, None, None),
"duck": (r"^Cartoon(Squish|Squeak)", .12, None, "down"),
"unduck": (r"^Cartoon(Squish|Squeak)", .1, None, "up"),
"roll": (r"^(CartoonRattle|Swish)", .15, None, None),
"dash": (r"^(CartoonWhizz|Swish)", .2, None, None),
"fall": (r"^Cartoon(Fall|Whistle)", .7, None, "down"),
"bounce": (r"^Cartoon(Boing|Bounce)|COMEDY - (HOLLOW|SINGLE HEAD) (BONK|DONK)", .25, None, None),
"pound": (r"^(CartoonImpact|CannonArtillery)|COMEDY - HOLLOW|ROCK - SET DOWN", .3, 600, None),
"slam": (r"^(CrashMetal|CrashGarbage)|CRASH, BUILDING|ROCK - SET DOWN|METAL, (BANG|HIT)", .5, None, None),
"death": (r"^(CartoonSplat|ClayBreaking)|CRASH, (CHINA|PIGGY)", .5, None, None),
"finish": (r"^Fanfare", 1.0, None, None),
"break": (r"^(GlassSmash|ClayBreaking)|CRASH, (BUILDING|CHINA|MISC)|ROCK - (SMALL|SINGLE|SEVERAL)", .4, None, None),
"ready": (r"^Cartoon(Bell|Bing)|COMEDY - (SMALL|METAL) PING|GLASS, TOAST", .4, None, None),
"charge": (r"^(CartoonWarble|CartoonWhistle|SpaceZap)|SPACE - (ASCENDING|INERTIA|SPACE BUZZ)|SLIDE WHISTLE UP", .8, None, "up"),
"select": (r"^(CartoonDit|CartoonBing|DropCoin)", .05, 3000, None),
"swap": (r"^(GunHolster|HandGunFoley|CoinRustle)", .1, None, None),
"equip": (r"^(HandGunFoley|MachineGunFoley|ShotgunFoley)", .25, None, None),
"unequip": (r"^(HandGunFoley|RifleFoley)", .2, None, None),
"level_up": (r"^Cartoon(Bell|Whistle|Bing)|SLIDE WHISTLE UP", .35, None, "up"),
"level_down": (r"^Cartoon(Whistle|Horn|Warble)|SLIDE WHISTLE DOWN", .35, None, "down"),
"echo": (r"^(CartoonWarble|SpaceZap)", .25, None, None),
"ricochet": (r"^BulletRicochet", .25, None, None),
"zap": (r"^SpaceZap|ELECTRICITY|RADIO STATIC", .15, 4000, None),
"slash": (r"^(Swish|CartoonWhizz)", .12, 2500, None),
"slash_heavy": (r"^(Bayonnet|Swish)", .3, None, None),
"swing": (r"^(Swish|CartoonWhizz)", .2, 1200, None),
"swing_heavy": (r"^(Swish|CartoonWhizz)", .4, 800, None),
"poke": (r"^(Bayonnet|StabHuman)", .1, None, None),
"poke_heavy": (r"^Bayonnet", .25, None, None),
"whip": (r"^Whip ", .12, None, None),
"whip_heavy": (r"^Whip ", .3, None, None),
"grapple": (r"^(ChainRattle|CartoonZip)|STRETCH WIRE|FOLEY - .*COCK", .15, None, None),
"grapple_heavy": (r"^(ChainRattle|CartoonZip)|STRETCH WIRE|FOLEY - .*COCK", .25, None, None),
"latch": (r"^ChainRattle|METAL, (BANG|HIT)|BRAKE DRUM|LEVER", .15, None, None),
"boomerang": (r"^(CartoonWhizz|Swish)", .3, None, None),
"boomerang_heavy": (r"^CartoonWhizz", .5, 800, None),
"bow": (r"^CartoonTwang|ARCHERY - .*TWANG|WIRE BREAK", .2, None, None),
"bow_heavy": (r"^CartoonTwang|ARCHERY - .*TWANG|LOW .*TWANG", .4, 1200, None),
"blast": (r"^(CartoonGun|SpaceZap)|SPACE, GUN SHOTS", .12, 3000, None),
"beam": (r"^SpaceZap", .45, None, None),
"uzi": (r"^MachineGun GUNS", .06, None, None),
"uzi_heavy": (r"^(MachineGun GUNS|GunMachineGun)", .3, None, None),
"shotgun": (r"^(ShotgunFoley|GunShotGun)|RIFLE, FOLEY", .3, None, None),
"shotgun_heavy": (r"^(ShotgunFoley|GunShotGun)|RIFLE, FOLEY", .5, None, None),
"flame": (r"^(US03-17|BulletPassBy)", .15, None, None),
"fireball": (r"^(BulletPassBy|US03-17)", .4, 1500, None),
"rocket": (r"^(RocketLauncher|MissileLauncher)", .45, None, None),
"rocket_heavy": (r"^(MissileLauncher|GrenadeLauncher)", .8, None, None),
"explosion": (r"^(Explosion GUNS|GrenadeHand|Explosion 4)", .9, None, None),
"mine": (r"^(DropCan|GunShell)|DOOR LOCK BEEP", .2, None, None),
"detonate": (r"^(CartoonDit|HandGunFoley)", .1, 3000, None),
"detonate_empty": (r"^(HandGunFoley|GunShell)", .05, None, None),
"flail": (r"^(ChainRattle|Swish)|METAL, HIT|AGAINST METAL", .3, None, None),
"flail_heavy": (r"^(ChainRattle|Swish)|METAL, HIT|METAL BUCKET", .45, 700, None),
"flail_land": (r"^(CartoonImpact|ChainRattle)|METAL, (BANG|HIT)", .2, None, None),
"punch": (r"^(Swish|CartoonWhizz)", .08, 2000, None),
"uppercut": (r"^Cartoon(Whizz|Zip)", .4, None, "up"),
"cast": (r"FIRE, FLASH|FLAMING ARROW|ROCKET - .*IGNITE", .4, None, None),
"wisp": (r"FIRE, FLASH|FLAMING ARROW|^BulletPassBy", .1, None, None),
"teleport": (r"^(CartoonZip|SpaceZap)|SPACE - DESCENDING|LASER", .2, None, "down"),
"teleport_warp": (r"^(CartoonWarble|SpaceZap)|SPACE - (ASCENDING|INERTIA)", .2, None, "up"),
"impact_arrow": (r"^(BulletImpact|DropWood)", .1, 1500, None),
"impact_blaster": (r"^(SpaceZap|BulletImpact)", .08, 4000, None),
"impact_bullet": (r"^BulletImpact", .05, None, None),
"impact_boomerang": (r"^(CartoonBonk|DropWood)", .12, None, None),
"impact_mine": (r"^(DropCan|GunShell)", .1, None, None),
"hit_blunt": (r"^(PunchBody|HitHuman|CartoonBonk|PunchFace)", .12, None, None),
"hit_blunt_heavy": (r"^(PunchBody|HitHuman|CartoonImpact)", .25, 800, None),
"hit_slash": (r"^(StabHuman|Bayonnet|RipFabric)", .08, None, None),
"hit_slash_heavy": (r"^(StabHuman|Bayonnet|RipFabric)", .2, None, None),
"hit_pierce": (r"^(StabHuman|Bayonnet|BulletImpact)", .1, None, None),
"hit_pierce_heavy": (r"^(StabHuman|Bayonnet)", .2, None, None),
"hit_lash": (r"^(SlapFace|Whip )", .08, None, None),
"hit_lash_heavy": (r"^(SlapFace|Whip )", .15, None, None),
"hit_bullet": (r"^(BulletImpact|PaintballGunImp)", .05, None, None),
"hit_bullet_heavy": (r"^(BulletImpact|HitHuman)", .3, None, None),
"hit_energy": (r"^(SpaceZap|CartoonGun)", .1, None, None),
"hit_energy_heavy": (r"^SpaceZap", .25, None, None),
"hit_fire": (r"^(US03-17|BulletPassBy)", .25, None, None),
"hit_fire_heavy": (r"^(US03-17|GrenadeHand)", .45, None, None),
"hit_explosive": (r"^(GrenadeHand|Explosion |MortarCannon)", .3, None, None),
"hit_explosive_heavy": (r"^(Explosion |CannonArtillery)", .5, None, None),
"hit_magic": (r"^CartoonBing|ELECTRICITY|RADIO STATIC", .1, 3500, None),
"hit_magic_heavy": (r"^SpaceZap|ELECTRICITY", .25, None, None),
}
def decode(path, rate):
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(path), "-ac", "1", "-ar", str(rate), "-f", "f32le", "-"],
capture_output=True).stdout
return np.frombuffer(raw, dtype=np.float32).astype(np.float64)
def track_id(stem):
match = re.search(r"(\d{4}_\d+(?:_\d+)?|GUNS\d+_\d+(?:_\d+)?|US\d\d-\d\d-\d+)", stem)
return match.group(1) if match else stem
def centroid(piece):
if len(piece) < 256:
return 0.0
spectrum = np.abs(np.fft.rfft(piece * np.hanning(len(piece))))
freqs = np.fft.rfftfreq(len(piece), 1 / ANALYSIS_RATE)
return float((spectrum * freqs).sum() / max(spectrum.sum(), 1e-12))
# A track's separate sounds (each loud enough to matter): when they start, how long they last, how loud they peak
# against the track's loudest, their brightness and whether their pitch rises (above 1) or falls.
def track_measure(path):
signal = decode(path, ANALYSIS_RATE)
size = int(ANALYSIS_RATE * retro.FRAME)
if len(signal) < size * 4:
return {"path": str(path), "events": []}
frames = np.abs(signal[: len(signal) // size * size]).reshape(-1, size).max(axis=1)
above = frames > max(frames.max() * retro.THRESHOLD, np.percentile(frames, 10) * 5)
runs = []
for i, isAbove in enumerate(above):
if isAbove and runs and i - runs[-1][1] <= retro.GAP / retro.FRAME:
runs[-1][1] = i
elif isAbove:
runs.append([i, i])
loudest = float(np.abs(signal).max())
events = []
for first, last in runs[:16]:
piece = signal[first * size:(last + 1) * size]
peak = float(np.abs(piece).max())
if peak < loudest * 0.3:
continue
half = len(piece) // 2
events.append({"start": round(first * size / ANALYSIS_RATE, 3), "length": round(len(piece) / ANALYSIS_RATE, 3),
"peak": round(peak / max(loudest, 1e-9), 2), "centroid": round(centroid(piece[:int(0.4 * ANALYSIS_RATE)])),
"rise": round(centroid(piece[half:]) / max(centroid(piece[:half]), 1), 2) if half > 512 else 1.0})
return {"path": str(path), "events": events}
# Measures every library track once (about a minute) and keeps the results.
def features_get():
path = CACHE / "features.json"
if path.exists():
return json.loads(path.read_text())
print("Measuring the library tracks (once)...")
retro.track_find("US03-17-1")
with Pool() as pool:
tracks = pool.map(track_measure, list(retro.TRACKS.values()), chunksize=8)
for track in tracks:
stem = pathlib.Path(track["path"]).stem
track["id"], track["name"] = track_id(stem), stem
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(tracks))
return tracks
def score(event, length, bright, pitch):
value = abs(math.log(max(event["length"], .005) / length)) + (1 - event["peak"]) * .5
if bright and event["centroid"] > 0:
value += .5 * abs(math.log(event["centroid"] / bright))
if pitch == "up":
value += max(0, 1.3 - event["rise"])
if pitch == "down":
value += max(0, event["rise"] - .8)
return value + 5 * (event["centroid"] < 150)
def picks_get(name):
return retro.picks_get(name)
# The best few library sounds for a game sound besides its own, each from a different track, noting which other game
# sound already uses that track. Each is auditioned as the game's sound with its first layer swapped for it.
def alternatives_get(name, tracks, users):
pattern, length, bright, pitch = CANDIDATES[name]
picks = picks_get(name)
own = {pick.get("f") for pick in picks} | {pick.get("f") for _, suite in sfx_suites.SUITES.get(name, [])
for pick in (suite if isinstance(suite, list) else [suite])}
base = {key: value for key, value in picks[0].items() if key in ("n", "sp", "g", "r")} if "f" in picks[0] else {}
best = {}
for track in tracks:
if track["id"] in own or not re.search(pattern, track["name"]):
continue
for event in track["events"]:
value = score(event, length, bright, pitch)
if track["id"] not in best or value < best[track["id"]][0]:
best[track["id"]] = (value, track, event)
ranked = sorted(best.values(), key=lambda entry: entry[0])[:ALTERNATIVES]
return [{"label": track["name"], "pick": {"n": length, **base, "f": track["id"], "s": event["start"]},
"detail": f"@ {event['start']:.2f}s · {event['length']:.2f}s · {event['centroid']} Hz" +
(f" · used by {', '.join(users[track['id']])}" if users.get(track["id"]) else "")}
for _, track, event in ranked]
def layer_describe(pick):
if "synth" in pick:
arguments = ", ".join(f"{key}={value}" for key, value in pick.get("a", {}).items())
return {**pick, "track": f"synth {pick['synth']}({arguments})"}
return {**pick, "track": retro.track_find(pick["f"]).stem}
def suite_get(name):
candidates = []
for label, pick in sfx_suites.SUITES.get(name, []):
layers = pick if isinstance(pick, list) else [pick]
candidates.append({"label": label, "pick": pick, "suite": True,
"detail": " + ".join(layer_describe(layer)["track"] for layer in layers)})
return candidates
def sounds_get(tracks):
users = {}
for name in retro.PICKS:
for pick in picks_get(name):
if "f" in pick:
users.setdefault(pick["f"], []).append(name)
sounds = []
for name in sorted(retro.PICKS, key=lambda name: (GROUPS.index(CONTEXT[name][0]), name)):
group, context = CONTEXT[name]
picks = picks_get(name)
kept = picks[0].get("keep")
sounds.append({"name": name, "group": group, "context": context, "kept": kept,
"pick": retro.PICKS[name], "layers": [] if kept else [layer_describe(pick) for pick in picks],
"sets": {key: (folder / f"{name}.ogg").exists() for key, folder in SETS.items()},
"alternatives": suite_get(name) + alternatives_get(name, tracks, users)})
return sounds
def notes_load():
return json.loads(NOTES.read_text()) if NOTES.exists() else {}
LOCK = threading.Lock()
def cache_path(kind, key, suffix):
return CACHE / kind / f"{hashlib.sha1(key.encode()).hexdigest()[:16]}{suffix}"
def cached(kind, key, suffix, build):
path = cache_path(kind, key, suffix)
with LOCK:
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
build(path)
return path
# The whole source track as a small ogg, for listening around the cut.
def source_get(track):
def build(path):
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(retro.track_find(track)), "-ac", "1", "-ar", "22050",
"-c:a", "libvorbis", "-q:a", "4", str(path)], check=True)
return cached("source", track, ".ogg", build)
# A track's waveform (peak per slice) and where a pick's cut lies in it, in seconds.
def wave_get(pick):
def build(path):
signal = retro.decode(retro.track_find(pick["f"]))
first, end, _ = retro.span_get(signal, pick)
low = signal[::retro.RATE // WAVE_RATE]
bins = np.array_split(np.abs(low), WAVE_BINS)
peaks = [round(float(chunk.max()) if len(chunk) else 0.0, 3) for chunk in bins]
path.write_text(json.dumps({"duration": len(signal) / retro.RATE, "peaks": peaks,
"cut": [first / retro.RATE, end / retro.RATE]}))
return cached("wave", json.dumps(pick, sort_keys=True), ".json", build)
# A game sound built from another pick, crunched as the game's would be, for auditioning alternatives.
def preview_key(name, pick):
return f"{name}|{json.dumps(pick if isinstance(pick, list) else [pick], sort_keys=True)}"
def preview_get(name, pick):
picks = pick if isinstance(pick, list) else [pick]
return cached("preview", preview_key(name, pick), ".ogg", lambda path: retro.render(name, picks, path))
def preview_build(job):
name, pick, path = job
try:
retro.render(name, pick if isinstance(pick, list) else [pick], path)
return None
except Exception as error:
return f"{name}: {error}"
# Builds every candidate's preview up front, all at once, so they play straight away.
def previews_warm(sounds):
jobs = [(sound["name"], alt["pick"], cache_path("preview", preview_key(sound["name"], alt["pick"]), ".ogg"))
for sound in sounds for alt in sound["alternatives"]]
jobs = [job for job in jobs if not job[2].exists()]
if not jobs:
return
print(f"Building {len(jobs)} candidate previews...")
(CACHE / "preview").mkdir(parents=True, exist_ok=True)
with Pool() as pool:
for failure in pool.imap_unordered(preview_build, jobs):
if failure:
print(" failed:", failure)
class Handler(http.server.BaseHTTPRequestHandler):
sounds = []
def log_message(self, *args):
pass
def reply(self, body, kind, status=200):
self.send_response(status)
self.send_header("Content-Type", kind)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def file_reply(self, path):
if not path.exists():
return self.reply(b"missing", "text/plain", 404)
self.reply(path.read_bytes(), "application/json" if path.suffix == ".json" else "audio/ogg")
def do_GET(self):
url = urllib.parse.urlparse(self.path)
query = {key: values[0] for key, values in urllib.parse.parse_qs(url.query).items()}
try:
if url.path == "/":
return self.reply(PAGE.encode(), "text/html; charset=utf-8")
if url.path == "/api/sounds":
previous = json.loads(PREVIOUS.read_text()) if PREVIOUS.exists() else {}
return self.reply(json.dumps({"sounds": self.sounds, "notes": notes_load(), "previous": previous,
"round": LAST_ROUND}).encode(),
"application/json")
if url.path.startswith("/audio/"):
_, _, kind, name = url.path.split("/")
if kind in SETS and re.fullmatch(r"\w+\.ogg", name):
return self.file_reply(SETS[kind] / name)
if url.path == "/source":
return self.file_reply(source_get(query["f"]))
if url.path == "/wave":
start = float(query["s"])
return self.file_reply(wave_get({"f": query["f"], "s": None if start < 0 else start, "n": float(query["n"]),
"sp": float(query.get("sp", 1)), "x": query.get("x") == "1",
"j": query.get("j") == "1"}))
if url.path == "/preview" and query.get("name") in retro.PICKS:
return self.file_reply(preview_get(query["name"], json.loads(query["pick"])))
except Exception as error:
return self.reply(str(error).encode(), "text/plain", 500)
self.reply(b"not found", "text/plain", 404)
def do_POST(self):
if self.path != "/api/notes":
return self.reply(b"not found", "text/plain", 404)
entry = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
name = entry.pop("name")
with LOCK:
notes = notes_load()
kept = {key: value for key, value in entry.items() if value not in (None, "", [])}
if kept:
notes[name] = kept
else:
notes.pop(name, None)
NOTES.write_text(json.dumps(dict(sorted(notes.items())), indent=2) + "\n")
self.reply(b"{}", "application/json")
PAGE = r"""<!DOCTYPE html>
<meta charset="utf-8">
<title>Sound review</title>
<style>
:root { --bg: #111; --panel: #1b1b1f; --line: #2c2c33; --text: #ddd; --dim: #888; --accent: #7cf;
--good: #5c5; --tweak: #db4; --replace: #e55; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px system-ui, sans-serif; background: var(--bg); color: var(--text); display: flex;
height: 100vh; }
#list { width: 250px; overflow-y: auto; border-right: 1px solid var(--line); padding: 8px 0; flex-shrink: 0; }
#list h3 { margin: 12px 12px 4px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--dim); }
#list div.item { padding: 3px 12px; cursor: pointer; display: flex; align-items: center; gap: 8px; }
#list div.item:hover { background: #222; }
#list div.item.active { background: #2a3440; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #444; flex-shrink: 0; }
.dot.good { background: var(--good); } .dot.tweak { background: var(--tweak); } .dot.replace { background: var(--replace); }
.noted::after { content: "✎"; color: var(--dim); font-size: 11px; }
#main { flex: 1; overflow-y: auto; padding: 18px 26px 60px; }
#top { display: flex; gap: 16px; align-items: center; margin-bottom: 10px; color: var(--dim); }
h1 { margin: 0 0 4px; font-size: 22px; font-family: ui-monospace, monospace; }
.context { font-size: 15px; margin-bottom: 4px; }
.meta { color: var(--dim); font-size: 12px; margin-bottom: 14px; }
section { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 12px 14px;
margin-bottom: 12px; }
section h2 { margin: 0 0 8px; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--dim);
font-weight: 600; }
button { background: #2a2a31; color: var(--text); border: 1px solid #3a3a44; border-radius: 4px; padding: 5px 10px;
cursor: pointer; font: inherit; }
button:hover { background: #34343d; } button:disabled { opacity: .35; cursor: default; }
button.on { border-color: var(--accent); color: var(--accent); }
button.verdict.good.on { border-color: var(--good); color: var(--good); }
button.verdict.tweak.on { border-color: var(--tweak); color: var(--tweak); }
button.verdict.replace.on { border-color: var(--replace); color: var(--replace); }
kbd { font: 11px ui-monospace, monospace; color: var(--dim); border: 1px solid #444; border-radius: 3px;
padding: 0 3px; margin-left: 4px; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
canvas { width: 100%; height: 90px; display: block; margin: 8px 0 4px; cursor: crosshair; background: #141417;
border-radius: 4px; }
.layer + .layer { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
.track { font-family: ui-monospace, monospace; font-size: 13px; }
.small { color: var(--dim); font-size: 12px; }
.alt { display: flex; gap: 10px; align-items: center; padding: 5px 0; border-bottom: 1px solid #24242a; }
.alt:last-child { border-bottom: 0; }
.alt .track { flex: 1; }
.alt.chosen .track { color: var(--accent); }
.tag { font-size: 11px; color: var(--good); border: 1px solid var(--good); border-radius: 3px; padding: 0 4px; }
.previous { border-color: #3a3a2a; }
.previous .said { margin-top: 6px; font-style: italic; color: #cc9; }
.dot.inline { display: inline-block; margin-right: 6px; }
textarea { width: 100%; min-height: 110px; background: #141417; color: var(--text); border: 1px solid #3a3a44;
border-radius: 4px; padding: 8px; font: 14px system-ui, sans-serif; resize: vertical; }
#saved { color: var(--dim); font-size: 12px; }
#help { color: var(--dim); font-size: 12px; line-height: 1.7; }
select { background: #2a2a31; color: var(--text); border: 1px solid #3a3a44; border-radius: 4px; padding: 3px; }
</style>
<div id="list"></div>
<div id="main"><div id="top"><span id="progress"></span>
<label>Show <select id="filter"><option value="all">all</option><option value="open">not reviewed</option>
<option value="tweak">tweak</option><option value="replace">replace</option><option value="good">good</option>
</select></label></div><div id="card">Loading…</div></div>
<script>
let sounds = [], notes = {}, previous = {}, lastRound = 0, index = 0, player = new Audio(), saveTimer = null;
const $ = (selector) => document.querySelector(selector);
const esc = (text) => String(text).replace(/[&<>"]/g, (c) => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;"})[c]);
function play(url, from = 0) {
player.pause();
player = new Audio(url);
player.addEventListener("loadedmetadata", () => { player.currentTime = from; player.play(); }, {once: true});
player.load();
}
function entry() { return notes[sounds[index].name] ??= {}; }
function save() {
const sound = sounds[index];
$("#saved").textContent = "saving…";
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
await fetch("/api/notes", {method: "POST", body: JSON.stringify({name: sound.name, ...notes[sound.name]})});
$("#saved").textContent = "saved";
listDraw();
}, 300);
}
function visible() {
const filter = $("#filter").value;
return sounds.map((sound, i) => i).filter((i) => {
const verdict = notes[sounds[i].name]?.verdict;
return filter === "all" || (filter === "open" ? !verdict : verdict === filter);
});
}
function listDraw() {
let html = "", group = "";
for (const i of visible()) {
const sound = sounds[i], note = notes[sound.name] ?? {};
if (sound.group !== group) html += `<h3>${group = sound.group}</h3>`;
html += `<div class="item ${i === index ? "active" : ""} ${note.note ? "noted" : ""}" data-i="${i}">` +
`<span class="dot ${note.verdict ?? ""}"></span>${esc(sound.name)}</div>`;
}
$("#list").innerHTML = html;
const reviewed = sounds.filter((sound) => notes[sound.name]?.verdict).length;
$("#progress").textContent = `${reviewed} / ${sounds.length} reviewed`;
$("#list .active")?.scrollIntoView({block: "nearest"});
}
function waveDraw(canvas, wave, layer) {
const width = canvas.width = canvas.clientWidth * devicePixelRatio, height = canvas.height = 90 * devicePixelRatio;
const context = canvas.getContext("2d"), x = (time) => time / wave.duration * width;
context.fillStyle = "rgba(120,200,255,.16)";
context.fillRect(x(wave.cut[0]), 0, x(wave.cut[1]) - x(wave.cut[0]), height);
const top = Math.max(...wave.peaks, 1e-6);
context.fillStyle = "#6a7a8a";
wave.peaks.forEach((peak, i) => {
const bar = peak / top * (height / 2 - 2);
context.fillRect(i / wave.peaks.length * width, height / 2 - bar, Math.max(1, width / wave.peaks.length), bar * 2);
});
context.fillStyle = "#7cf";
context.fillRect(x(wave.cut[0]), 0, 2, height);
const suggested = entry().start?.[layer];
if (suggested != null) { context.fillStyle = "#db4"; context.fillRect(x(suggested), 0, 2, height); }
canvas.wave = wave;
}
let SETS = [];
const ALT_KEYS = "qwerasdf";
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
function altRow(sound, alt, i) {
const note = entry(), chosen = note.choice && same(note.choice.pick, alt.pick), inGame = same(alt.pick, sound.pick);
return `<div class="alt ${chosen ? "chosen" : ""}"><button data-alt="${i}">▶${i < ALT_KEYS.length ? `<kbd>${ALT_KEYS[i]}</kbd>` : ""}</button>` +
`<span class="track">${esc(alt.label)}${inGame ? ' <span class="tag">in game now</span>' : ""}<br>` +
`<span class="small">${esc(alt.detail)}</span></span>` +
`<button data-choose="${i}" class="${chosen ? "on" : ""}">${chosen ? "Chosen" : "Use this"}</button></div>`;
}
async function cardDraw() {
const sound = sounds[index], note = entry(), before = previous[sound.name];
let html = `<h1>${esc(sound.name)}</h1><div class="context">${esc(sound.context)}</div>` +
`<div class="meta">${esc(sound.group)}</div>` +
`<section><h2>Listen</h2><div class="row">` +
SETS.map(([key, label, n]) => `<button data-play="${key}" ${sound.sets[key] ? "" : "disabled"}>▶ ${label}<kbd>${n}</kbd></button>`).join("") +
`</div></section>`;
if (before) {
html += `<section class="previous"><h2>Round ${lastRound}</h2><span class="dot inline ${before.verdict ?? ""}"></span>${esc(before.verdict ?? "no verdict")}` +
(before.choice ? ` · chose ${esc(before.choice.track ?? before.choice.label ?? "")}` : "") +
(before.note ? `<div class="said">“${esc(before.note)}”</div>` : "") + `</section>`;
}
html += `<section><h2>Source</h2>`;
if (sound.kept) html += `<div class="small">Kept as made, from the ${esc(sound.kept)} set.</div>`;
sound.layers.forEach((layer, i) => {
const isSynth = layer.synth != null;
html += `<div class="layer"><div class="track">${esc(layer.track)}</div>` +
`<div class="small">${isSynth ? "synthesized" : `cut at ${layer.s == null ? "its first sound" : layer.s.toFixed(2) + "s"}` +
(layer.x ? " exactly" : "") + `, up to ${layer.n}s` + (layer.j ? ", joining its sounds" : "")}` +
(layer.sp ? `, speed ${layer.sp}x` : "") + (layer.g ? `, gain ${layer.g}` : "") + (layer.fd ? `, fading over ${layer.fd * 100}%` : "") +
(layer.r ? `, ${layer.r} Hz` : "") + (layer.at ? `, layered at +${layer.at}s` : "") + `</div>` +
(isSynth ? "" : `<canvas data-layer="${i}"></canvas><div class="row"><button data-cut="${i}">▶ Cut${i ? "" : "<kbd>5</kbd>"}</button>` +
`<button data-whole="${i}">▶ Whole track${i ? "" : "<kbd>6</kbd>"}</button>` +
`<span class="small">click the waveform to listen from there · shift-click to suggest a new start</span></div>`) + `</div>`;
});
html += `</section>`;
const suite = sound.alternatives.filter((alt) => alt.suite), more = sound.alternatives.filter((alt) => !alt.suite);
if (suite.length)
html += `<section><h2>Round 2 candidates</h2>` + suite.map((alt) => altRow(sound, alt, sound.alternatives.indexOf(alt))).join("") + `</section>`;
html += `<section><h2>${suite.length ? "More from the libraries" : "Alternatives"}</h2>` +
more.map((alt) => altRow(sound, alt, sound.alternatives.indexOf(alt))).join("") +
`<div class="small" style="margin-top:6px">Everything plays crunched exactly as the game's sound would be.</div></section>` +
`<section><h2>Verdict</h2><div class="row">` +
[["good", "Good", "g"], ["tweak", "Tweak", "t"], ["replace", "Replace", "x"]].map(([key, label, k]) =>
`<button class="verdict ${key} ${note.verdict === key ? "on" : ""}" data-verdict="${key}">${label}<kbd>${k}</kbd></button>`).join("") +
`<span id="saved"></span></div>` +
`<textarea id="note" placeholder="Notes for Claude: too long, start later, pitch up, wants more crunch, try a chain rattle… (n to type, Esc to leave)">${esc(note.note ?? "")}</textarea></section>` +
`<div id="help">j / k or ↓ / ↑: next / previous · 1 2 3 4: game now, last round, synth, demo 1 · 5 / 6: source cut / whole track · ` +
`q w e r a s d f: candidates · g t x: verdict · n: notes · space: stop</div>`;
$("#card").innerHTML = html;
$("#note").addEventListener("input", (event) => { entry().note = event.target.value; save(); });
for (const canvas of document.querySelectorAll("canvas")) {
const layer = sound.layers[canvas.dataset.layer];
const params = new URLSearchParams({f: layer.f, s: layer.s ?? -1, n: layer.n, sp: layer.sp ?? 1, x: layer.x ? 1 : 0, j: layer.j ? 1 : 0});
fetch(`/wave?${params}`).then((response) => response.json()).then((wave) => waveDraw(canvas, wave, canvas.dataset.layer));
canvas.addEventListener("click", (event) => {
if (!canvas.wave) return;
const time = (event.offsetX / canvas.clientWidth) * canvas.wave.duration;
if (event.shiftKey) {
(entry().start ??= {})[canvas.dataset.layer] = Math.round(time * 100) / 100;
save();
waveDraw(canvas, canvas.wave, canvas.dataset.layer);
}
play(`/source?f=${encodeURIComponent(layer.f)}`, time);
});
}
}
function select(i) { index = i; listDraw(); cardDraw(); }
function step(direction) {
const shown = visible();
if (!shown.length) return;
const at = shown.indexOf(index);
select(shown[at < 0 ? 0 : Math.min(shown.length - 1, Math.max(0, at + direction))]);
}
function action(kind, value) {
const sound = sounds[index], note = entry();
if (kind === "play" && sound.sets[value]) play(`/audio/${value}/${sound.name}.ogg`);
if ((kind === "cut" || kind === "whole") && sound.layers[value]?.f) {
const canvas = document.querySelector(`canvas[data-layer="${value}"]`), layer = sound.layers[value];
play(`/source?f=${encodeURIComponent(layer.f)}`, kind === "cut" && canvas.wave ? canvas.wave.cut[0] : 0);
if (kind === "cut" && canvas.wave) {
const end = canvas.wave.cut[1], current = player;
const stop = () => { if (current.currentTime >= end) current.pause(); else requestAnimationFrame(stop); };
requestAnimationFrame(stop);
}
}
const alt = sound.alternatives[value];
if (kind === "alt" && alt) play(`/preview?${new URLSearchParams({name: sound.name, pick: JSON.stringify(alt.pick)})}`);
if (kind === "choose" && alt) {
note.choice = note.choice && same(note.choice.pick, alt.pick) ? undefined : {label: alt.label, pick: alt.pick};
if (note.choice && !note.verdict) note.verdict = "replace";
save(); cardDraw();
}
if (kind === "verdict") { note.verdict = note.verdict === value ? undefined : value; save(); cardDraw(); }
}
document.addEventListener("click", (event) => {
const item = event.target.closest(".item");
if (item) return select(Number(item.dataset.i));
const button = event.target.closest("button");
if (!button) return;
for (const kind of ["play", "cut", "whole", "alt", "choose", "verdict"])
if (button.dataset[kind] != null) action(kind, isNaN(button.dataset[kind]) ? button.dataset[kind] : Number(button.dataset[kind]));
});
document.addEventListener("keydown", (event) => {
if (event.target.tagName === "TEXTAREA") { if (event.key === "Escape") event.target.blur(); return; }
if (event.target.tagName === "SELECT" || event.ctrlKey || event.metaKey || event.altKey) return;
const keys = {j: () => step(1), ArrowDown: () => step(1), k: () => step(-1), ArrowUp: () => step(-1),
1: () => action("play", "game"), 2: () => action("play", "last"), 3: () => action("play", "synth"),
4: () => action("play", "demo"), 5: () => action("cut", 0), 6: () => action("whole", 0),
g: () => action("verdict", "good"), t: () => action("verdict", "tweak"), x: () => action("verdict", "replace"),
n: () => $("#note").focus(), " ": () => player.pause()};
[...ALT_KEYS].forEach((key, i) => { keys[key] = () => action("alt", i); });
if (keys[event.key]) { event.preventDefault(); keys[event.key](); }
});
$("#filter").addEventListener("change", () => { listDraw(); });
fetch("/api/sounds").then((response) => response.json()).then((data) => {
sounds = data.sounds; notes = data.notes; previous = data.previous; lastRound = data.round;
SETS = [["game", "Game now", 1], ["last", `Round ${lastRound}`, 2], ["synth", "Synth", 3], ["demo", "Demo 1", 4]];
select(0);
});
</script>
"""
def main():
numbers = [arg for arg in sys.argv[1:] if arg.isdigit()]
port = int(numbers[0]) if numbers else 8770
missing = [name for name in retro.PICKS if name not in CONTEXT or name not in CANDIDATES]
assert not missing, f"no context or candidates for {missing}"
Handler.sounds = sounds_get(features_get())
previews_warm(Handler.sounds)
server = http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler)
url = f"http://127.0.0.1:{port}/"
print(f"Reviewing {len(Handler.sounds)} sounds at {url} (notes go to {NOTES.relative_to(ROOT)}). Ctrl+C to stop.")
if "--no-browser" not in sys.argv:
webbrowser.open(url)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()