commit
2007fb7edc
200 files changed
+27710
No files matched your search
@@ -0,0 +1,779 @@
|
||||
# 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) => ({"&": "&", "<": "<", ">": ">", '"': """})[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()
|
||||
Reference in new issue
Block a user