# 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.json, with that round's sounds in build/sfx-sets/round) 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""" Sound review
Loading…
""" 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()