Commit 9c6a24cb by PLN (Algolia)

feat(judge): a set-judge — 15 tracks, a verdict each, one decisions.json

PLN: "make me a tiny spa to see the heads, soundwaves and metadata of what dX
are running at that moment / what samples/synth they are, so i can go/nogo /
comment each, then download decisions.json. its a reusable tool tbh we do this
flow often."

That flow was being run in a music player with the answers landing in a chat
log, so three weeks later nobody could say why a track was cut. Ear-feedback is
the scarcest input in this pipeline and it was the only one with no artifact.

Built on the existing A/B Judge rather than beside it: OrbitRail now takes
binS + orbits instead of a whole Take, so one rail serves both UIs. Shapes live
in models.py and the TS is regenerated from them, as before.

FIFTEEN 16-SECOND HEADS ARE FIFTEEN INTROS. The first build showed 1-4 orbits
per track and I nearly shipped that as a finding. The heads are bit-exact with
each track's first 16s (r=1.0000 by correlation against the full track), so the
sparse lanes were TRUE and completely misleading — these tracks run 7-10 orbits
once they open. A ship/cut call on an intro is a call on the wrong evidence.
Activity now spans the whole track; the head stays as a running-order scan,
with a banner saying exactly what it is.

PEAKS ARE THE FEATURE, NOT AN OPTIMISATION. Without precomputed peaks
wavesurfer downloads and decodes the entire file before drawing one pixel —
102 MB per track here. Blank screen at the desk, unusable over wifi on a phone,
which is where the mono-compat check happens. But passing `peaks` + `url`
together makes wavesurfer treat the track as pre-decoded and never wire up
playback: waveform draws, transport looks alive, play throws "No audio loaded".
Both `tsc -b` and `vite build` were green through all of that. Fixed by owning
the <audio> element via `media:`.

THREE VERIFICATION PROBES IN A ROW TESTED NOTHING, each green or red for
reasons unrelated to the app:
  1. fetch(document.querySelector('audio').src) asserting 206 — there was no
     <audio> in the light DOM, so it fetched the empty string, got THE PAGE,
     and read a 206 from somewhere else. It passed.
  2. querySelectorAll('li canvas') — wavesurfer renders into a SHADOW ROOT.
     Playwright's selectors pierce it; querySelector inside evaluate does not.
     It failed while the waveform drew perfectly.
  3. A tally assertion matching "2 judged" against text reading "2/15 judged".
The probe that works asserts what PLN can see: the clock advances and the
button flips to Pause. Prefer the assertion a human could make by looking.

Two bugs fell out of reusing existing parts, which is the argument for reusing
existing parts:
  - classify_family filed vec1_claps, drumtraks, realclaps, clubkick and 808bd
    under MELODIC — it matched perc names by exact-match or prefix, and in
    these scores the perc word is a suffix or an underscore token. A rail that
    shows claps as melody is worse than no rail. Widened to suffix + token
    matching; the test pins that cpluck, dropbass and snippet still must NOT
    match, since those collisions are why exact-matching existed.
  - mmss() padded a spurious zero onto every sub-10s time ("0:002.2"), off by
    one since it was written, in the shared helper. Caught by the smoke test
    reading the clock back.

Titles carried markdown into the release path: tracks.json holds
"There's **Something About Drums**" because backlog.md is prose, and those
strings flow decisions.json -> upload metadata. Emphasis stripped; "<3" and the
shouting caps are PLN's and stay.

Spec-driven so the next gig is a copy of a JSON file, not a code change. The
master->stem map is explicit numbers (keeps summing to 4786.370s, the master's
duration to the millisecond) and the builder asserts it rather than trusting
it. audio-mounts.json is read by BOTH vite's dev middleware and serve.py, so a
URL that works in dev works on the phone over LAN.

Validated: 11/11 smoke green in dev AND against serve.py on the built dist;
22 classifier tests; path traversal out of an audio mount returns 404.
parent 5a97d0ed
...@@ -7,11 +7,27 @@ the printed LAN URL on a phone on the same wifi. ...@@ -7,11 +7,27 @@ the printed LAN URL on a phone on the same wifi.
python3 serve.py --dir tide-table/punkachien --port 8731 python3 serve.py --dir tide-table/punkachien --port 8731
""" """
import argparse, json, os, re, socket, sys, threading import argparse, json, os, re, socket, sys, threading, urllib.parse
from functools import partial from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
# ── /audio/<prefix> mounts, shared verbatim with vite.config.ts ───────────────
def _load_audio_mounts():
cfg = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui", "audio-mounts.json")
try:
with open(cfg) as fh:
m = json.load(fh)["mounts"]
except Exception:
return []
base = os.path.dirname(cfg)
# Longest prefix first, so "full-club" is not swallowed by "full".
return sorted(((p, os.path.normpath(os.path.join(base, d))) for p, d in m.items()),
key=lambda kv: -len(kv[0]))
_AUDIO_MOUNTS = _load_audio_mounts()
# ── optional CLAP vibe-search API (lazy: torch loads on first /vibe hit) ─────── # ── optional CLAP vibe-search API (lazy: torch loads on first /vibe hit) ───────
# Serves /vibe?q=<phrase> and /similar?name=folder/stem over the cached sample # Serves /vibe?q=<phrase> and /similar?name=folder/stem over the cached sample
# embeddings (semantics_embeds.npz in --dir). Absent/unbuilt → 503 with a hint, # embeddings (semantics_embeds.npz in --dir). Absent/unbuilt → 503 with a hint,
...@@ -101,6 +117,29 @@ class RangeHandler(SimpleHTTPRequestHandler): ...@@ -101,6 +117,29 @@ class RangeHandler(SimpleHTTPRequestHandler):
self.send_header("Cache-Control", "no-cache") self.send_header("Cache-Control", "no-cache")
super().end_headers() super().end_headers()
def translate_path(self, path):
"""Resolve /audio/<prefix>/... through ui/audio-mounts.json.
The masters are gigabytes and live outside the repo, so they are mounted
rather than copied into dist/. Vite's dev server reads the SAME file, so
a URL that works under `npm run dev` works here — a judge UI that only
works in dev is a demo, and the phone audition (the mono-compat check)
happens over LAN, i.e. here.
"""
clean = urllib.parse.urlparse(path).path
rel = urllib.parse.unquote(clean).lstrip("/")
if rel.startswith("audio/"):
rest = rel[len("audio/"):]
for prefix, root in _AUDIO_MOUNTS:
if prefix and not (rest == prefix or rest.startswith(prefix + "/")):
continue
tail = rest[len(prefix):].lstrip("/") if prefix else rest
cand = os.path.normpath(os.path.join(root, tail))
# Never let "../" climb out of the mount.
if cand.startswith(root) and os.path.isfile(cand):
return cand
return super().translate_path(path)
def send_head(self): def send_head(self):
rng = self.headers.get("Range") rng = self.headers.get("Range")
if not rng: if not rng:
......
---
log: 025
title: "Fifteen intros are not a set"
date: 2026-08-16
task: "#147 #152 the set-judge, and three probes that tested nothing"
tags: [judge, ui, mastering, opal26, playwright, verification]
shareable: true
---
## Cap (what & why)
PLN asked for a small SPA to go/no-go a whole gig — heads, waveforms, which
orbits are running and what they are, a comment per track, and a
`decisions.json` at the end. His words: *"its a reusable tool tbh we do this
flow often."*
That flow was being run in a music player, with the answers landing in a chat
log. Three weeks later nobody could say why track 11 was cut. Ear-feedback is
the scarcest input in this whole pipeline and it was the only one with no
artifact.
## What shipped
* `armada/tide-table/build_judge_set.py` — spec-driven, so the next gig is a
copy of a JSON file rather than a code change. Orbit labels come from each
track's `.tidal` score, activity from the per-orbit stems, role family
validated against the measured spectral centroid at that orbit's loudest
moment in that track.
* `armada/ui/judge.html` + `src/judge/` — the list, the verdicts, the export.
Reuses `WaveformPlayer` and `OrbitRail` from the A/B judge; `OrbitRail` now
takes `binS` + `orbits` instead of a whole `Take`, so one rail serves both.
* `models.py``JudgeSet` / `JudgeTrack` / `Decision` / `DecisionSet`, with the
TS regenerated from them. One source of truth, still.
* `audio-mounts.json`, read by BOTH vite's dev middleware and `serve.py`, so a
URL that works in dev works on the phone over LAN.
## Learnings
**Fifteen 16-second heads are fifteen intros.** The first build showed 1–4
orbits per track and I nearly shipped it as a result. The heads are bit-exact
with each track's first 16 s (`r = 1.0000` against the full track) — so the
sparse lanes were true, and completely misleading: these tracks run seven to ten
orbits once they open up. A ship/cut call made on an intro is a call made on the
wrong evidence. Activity is now computed across the whole track, and the head
stays as a quick running-order scan with a banner saying exactly what it is.
**Three verification probes in a row tested nothing.** Each one was green or red
for a reason that had nothing to do with the app:
1. `fetch(document.querySelector('audio').src)` asserting a 206 — there was no
`<audio>` in the light DOM, so it fetched the empty string, got *the page*,
and read a 206 from somewhere else entirely. It passed.
2. `document.querySelectorAll('li canvas')` — wavesurfer renders into a **shadow
root**. Playwright's own selectors pierce it; `querySelector` inside
`page.evaluate` does not. It failed while the waveform was drawing perfectly.
3. A tally assertion looking for `"2 judged"` in text that read `"2/15 judged"`.
The probe that finally worked asserts what PLN can see: the transport clock
advances and the button flips to Pause. *Prefer the assertion a human could make
by looking.* This is the same lesson as three "the rig is broken" findings that
were all the measuring tool — it just keeps arriving in new clothes.
**A green `tsc -b` and a green `vite build` said nothing.** Both passed while
pressing play threw `No audio loaded`: supplying `peaks` + `url` makes wavesurfer
treat a track as pre-decoded and it never wires up playback. The waveform draws,
the transport looks alive, and the thing does not play. Fixed by owning the
`<audio>` element (`media:`), which keeps rendering and playback separate.
**Peaks are not an optimisation here, they are the feature.** Without them
wavesurfer downloads and decodes the entire file before drawing one pixel —
102 MB per track for this set. That is a blank screen on the desk and unusable
over wifi on a phone, which is where the mono-compat check happens.
**Two bugs fell out of building on top of existing parts**, which is the
argument for building on top of existing parts:
* `classify_family` filed `vec1_claps`, `drumtraks`, `realclaps`, `clubkick`
and `808bd` under **Melodic** — it matched perc names by exact-match or
prefix, and in these scores the perc word is a suffix or an underscore token.
A rail that shows claps as melody is worse than no rail. Widened to suffix +
token matching, with a test pinning that `cpluck`, `dropbass` and `snippet`
still must NOT match — the collisions the exact-set existed to prevent.
* `mmss()` padded a spurious zero onto every sub-10-second time: `0:002.2`.
Off by one since it was written, in the shared format helper. Caught by the
smoke test reading the clock back — a test that reads what the user reads
finds what the user sees.
**Titles carried markdown into the release path.** `tracks.json` holds
`There's **Something About Drums**` and `Am i _Doing it Right_` because
backlog.md is prose. Those strings flow decisions.json → upload metadata, so the
asterisks would have shipped. Stripped emphasis only; `<3` and the shouting caps
are PLN's and stay.
## Numbers
* 15 tracks, 6–10 orbit lanes each, 229 KB of precomputed data.
* Stem-map: 12 orbits × 2 s bins over 5208 s, built once from the clean stems.
* Master → stem is exact and piecewise: `keeps [[6.0,4540.0],[4569.9,4822.27]]`
sums to **4786.370 s**, the master's duration to the millisecond. The builder
asserts that rather than trusting it.
* Smoke: 11/11 green in dev AND against `serve.py` on the built `dist/`.
## Deps
Unblocks #147 (publish Sunset Forest) and #152 (SC re-upload) — the go/no-go
that gates both now has a tool and an artifact. Spawned nothing; reused #28/#40's
Judge components rather than forking them.
...@@ -18,6 +18,7 @@ Library: profile(), load_window(), orbit_envelope(), mix_take() — reuse in the ...@@ -18,6 +18,7 @@ Library: profile(), load_window(), orbit_envelope(), mix_take() — reuse in the
renderer (#36), Judge mix path (#33), and locate-matrix L3 fingerprint (#34). renderer (#36), Judge mix path (#33), and locate-matrix L3 fingerprint (#34).
""" """
from __future__ import annotations from __future__ import annotations
import re
import argparse, subprocess, sys import argparse, subprocess, sys
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
...@@ -82,17 +83,42 @@ _PERC_EXACT = {"kick", "bd", "snare", "sn", "sd", "dr", "drum", "clap", "cp", ...@@ -82,17 +83,42 @@ _PERC_EXACT = {"kick", "bd", "snare", "sn", "sd", "dr", "drum", "clap", "cp",
"rim", "rs", "hh", "hat", "hats", "cym", "cy", "tom", "perc", "rim", "rs", "hh", "hat", "hats", "cym", "cy", "tom", "perc",
"crash", "ride", "shaker", "conga", "bongo"} "crash", "ride", "shaker", "conga", "bongo"}
_PERC_PREFIX = ("kick", "snare", "clap", "hat", "perc", "rample", "h2ogm", _PERC_PREFIX = ("kick", "snare", "clap", "hat", "perc", "rample", "h2ogm",
"reverbkick", "808kick", "909") "reverbkick", "808kick", "909", "drum")
# The perc word is often a SUFFIX or an underscore-token, not a prefix: the OPAL
# scores alone carry vec1_claps, vec1_snare, realclaps, clubkick, bskick,
# jbk_kick, 808bd. Exact+prefix matching filed every one of them under Melodic —
# a rail that shows claps as melody is worse than no rail. Suffix and token
# matching stay narrow (whole words only), so the collisions the exact-set exists
# to avoid — cpluck, dropbass, snippet — still cannot match.
_PERC_WORDS = _PERC_EXACT | {"claps", "kicks", "snares", "drums", "bd"}
# Safe to match at the END of a name or of an underscore token. Deliberately
# EXCLUDES the short/ambiguous ones the exact-set guards ("dr", "sn", "cp",
# "rs", "cy") and the English-word collisions ("tom" in bottom, "ride" in
# override) — a suffix rule is only as good as the words you leave out of it.
_PERC_SUFFIX = ("kick", "kicks", "snare", "snares", "clap", "claps",
"hat", "hats", "drum", "drums", "perc", "bd", "cym",
"crash", "shaker")
_TOKEN_SPLIT = re.compile(r"[^a-z]+")
_BASS_HINTS = ("bass", "sub", "808", "reese", "moog", "fbass", "acid", "wobble") _BASS_HINTS = ("bass", "sub", "808", "reese", "moog", "fbass", "acid", "wobble")
def _is_perc_name(s):
if s in _PERC_EXACT or any(s.startswith(h) for h in _PERC_PREFIX):
return True
if s.endswith(_PERC_SUFFIX): # clubkick, realclaps, 808bd
return True
toks = [t for t in _TOKEN_SPLIT.split(s) if t]
return any(t in _PERC_WORDS or t.endswith(_PERC_SUFFIX) # vec1_claps, hardkick_rha
for t in toks)
def classify_family(sound, prof=None): def classify_family(sound, prof=None):
"""(family_key, centroid|None) for a sound, measurement-aware.""" """(family_key, centroid|None) for a sound, measurement-aware."""
s = (sound or "").lower() s = (sound or "").lower()
cen = prof["centroid"] if prof else None cen = prof["centroid"] if prof else None
if any(h in s for h in _BREAK_HINTS): if any(h in s for h in _BREAK_HINTS):
return "tops", cen # breakbeats → tops lane return "tops", cen # breakbeats → tops lane
if s in _PERC_EXACT or any(s.startswith(h) for h in _PERC_PREFIX): if _is_perc_name(s):
return "percs", cen # drums/hats → percs return "percs", cen # drums/hats → percs
bass_name = any(h in s for h in _BASS_HINTS) bass_name = any(h in s for h in _BASS_HINTS)
if cen is None: # no audio → fall back to name if cen is None: # no audio → fall back to name
......
#!/usr/bin/env python3
"""build_judge_set — precompute the set-judge SPA's data for a whole gig.
WHY THIS EXISTS
---------------
Before every release we ask the same question, by hand, in a music player:
"of the N tracks this set split into, which ones ship?" It takes an evening,
the answers live in a chat scrollback, and three weeks later nobody can say why
track 11 was cut. PLN, 2026-08-16: *"its a reusable tool tbh we do this flow
often"*.
So: same evidence the A/B Judge already shows (orbit lanes labelled from the
SCORE, activity measured from the STEMS), but the unit of judgement is a LIST —
15 tracks, each with a verdict and a comment, exported as one decisions.json
the release tooling reads directly.
WHAT IS NOT GUESSED
-------------------
* **Orbit labels come from the .tidal score**, via tidal_score.orbit_sounds —
the actual sound on each dN, not a role inferred from the orbit number.
* **Role family is validated against audio** (spectral centroid at that orbit's
loudest moment in THAT track), never from the sample name alone. A name-only
classification is exactly the mistake `meth_bass` is the standing example of:
it is a noisy wobble, not a sub.
* **Activity is measured** from the per-orbit stems, on the time axis, so a
sparse pattern and a fade are distinguishable.
THE CALIBRATION THAT MATTERS
----------------------------
Clip time is MASTER time; the stems are on TAKE time, and the master is an
edited subset of the take. `keeps` is that edit as explicit numbers — the same
list the mix plan carries — so master→stem is exact and piecewise, including
across a cut. Deriving it by content-detection instead would slide every
boundary; see MIXING.md. The sum of the keeps must equal the master duration,
and this script asserts it rather than trusting it.
USAGE
python3 build_judge_set.py SPEC.json [--out PATH]
SPEC.json — see judge_specs/opal26.json for a worked example.
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
import numpy as np
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from audio_lens import classify_family, load_window, profile # noqa: E402
from models import (JudgeSet, JudgeTrack, MasterInfo, OrbitActivity, # noqa: E402
RoleGroup, Variant)
from tidal_score import orbit_sounds # noqa: E402
# Role families (DESIGN.md). Colour reinforces label + glyph + lane; never hue alone.
ROLE_GROUPS = [
RoleGroup(key="percs", label="Percs", color="#ff8c00", glyph="▰"),
RoleGroup(key="bass", label="Bass", color="#7c5cff", glyph="▂"),
RoleGroup(key="melodic", label="Melodic", color="#36c5f0", glyph="♪"),
RoleGroup(key="tops", label="Tops", color="#2dd4bf", glyph="≈"),
RoleGroup(key="atmos", label="Atmos", color="#8a93a6", glyph="◌"),
]
ACTIVE_DB = -38.0 # "strongly on / driving"
LIT_FLOOR = -52.0 # audible floor — an orbit dims toward this, it never vanishes
_EMPH = re.compile(r"(\*\*|\*|__|_)(?=\S)(.+?)(?<=\S)\1")
def clean_title(s: str) -> str:
"""Strip markdown emphasis the backlog parse leaves in track names.
tracks.json carries titles like `There's **Something About Drums**` and
`Am i _Doing it Right_` — backlog.md is prose and the emphasis is PLN's, not
part of the name. These titles end up in decisions.json and from there in
upload metadata, so the asterisks would ship. Only MARKUP is removed: "<3"
and capitalisation are his, and stay.
"""
prev = None
while prev != s:
prev, s = s, _EMPH.sub(r"\2", s)
return s.strip()
def slugify(s: str) -> str:
s = re.sub(r"[^\w\s-]", "", s.lower())
return re.sub(r"[\s_-]+", "-", s).strip("-")
def ffprobe_dur(path: Path) -> float:
r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", str(path)],
capture_output=True, text=True)
try:
return round(float(r.stdout.strip()), 3)
except ValueError:
return 0.0
def loudness(path: Path) -> dict:
"""Integrated LUFS / LRA / true peak for one file."""
r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-i", str(path),
"-af", "ebur128=peak=true", "-f", "null", "-"],
capture_output=True, text=True)
summ = r.stderr.split("Summary:")[-1]
def g(pat):
m = re.search(pat, summ)
return round(float(m.group(1)), 1) if m else None
return {"I": g(r"I:\s*(-?[\d.]+)\s*LUFS"),
"LRA": g(r"LRA:\s*(-?[\d.]+)\s*LU"),
"TP": g(r"Peak:\s*(-?[\d.]+)\s*dBFS")}
def peaks(path: Path, n: int = 900) -> list[float]:
"""Waveform overview: `n` buckets of absolute peak, normalised to 0..1.
Decoded at full rate and bucket-MAXed rather than resampled — resampling
smooths transients away, and a waveform whose peaks are averaged out is a
picture of a different, quieter track. Streamed through a pipe so an 80 min
file never has to be resident.
"""
dur = ffprobe_dur(path)
if dur <= 0:
return []
proc = subprocess.Popen(
["ffmpeg", "-v", "error", "-i", str(path), "-ac", "1", "-ar", "48000",
"-f", "f32le", "-"], stdout=subprocess.PIPE)
per = max(1, int(dur * 48000 / n))
out, buf = [], b""
try:
while True:
chunk = proc.stdout.read(per * 4 * 8)
if not chunk:
break
buf += chunk
usable = (len(buf) // (per * 4)) * (per * 4)
if usable:
a = np.frombuffer(buf[:usable], dtype=np.float32).reshape(-1, per)
out.extend(np.abs(a).max(axis=1).tolist())
buf = buf[usable:]
if buf:
a = np.frombuffer(buf[:len(buf) // 4 * 4], dtype=np.float32)
if a.size:
out.append(float(np.abs(a).max()))
finally:
proc.stdout.close()
proc.wait()
hi = max(out) if out else 0.0
return [round(float(v / hi), 4) for v in out] if hi > 0 else []
def make_master_to_stem(keeps, master_dur=None):
"""master-time -> stem-time, piecewise across the edit.
`keeps` is [[a,b], ...] in STEM time; the master is their concatenation. A
bin landing inside a cut would otherwise read the wrong orbit's activity —
which is how a judge UI ends up confidently lighting a lane that is not
playing.
"""
total = sum(b - a for a, b in keeps)
if master_dur is not None and abs(total - master_dur) > 0.05:
raise SystemExit(
f"keeps sum to {total:.3f}s but the master is {master_dur:.3f}s — "
"the edit map is wrong, and every orbit lane would be shifted. "
"Fix `keeps` in the spec (see MIXING.md: edits are explicit numbers).")
def f(t: float) -> float:
acc = 0.0
for a, b in keeps:
span = b - a
if t < acc + span:
return a + (t - acc)
acc += span
return keeps[-1][1]
return f
def track_orbits(tidal_path, stemmap, m2s, start_s, dur_s, bin_s, stems_dir, glob):
"""Per-orbit lanes for one track: label from the score, activity from the stems,
family validated by the measured centroid at that orbit's loudest moment."""
if not tidal_path or not Path(tidal_path).exists():
return []
sounds = orbit_sounds(tidal_path) # {orbit:int -> {sound, raw_all, ...}}
rms = stemmap["rms_db"]
names = stemmap["orbits"]
sm_bin = stemmap.get("bin_s", bin_s)
n_bins = int(round(dur_s / bin_s))
stem_paths = sorted(Path(stems_dir).glob(glob))
out = []
for i, name in enumerate(names):
# Stem-map rows are named after the FILE, and those names differ per
# export ("orbit-03", "Opal26_clean_Tidal 03"). The orbit is the LAST
# integer in the name — "Opal26" would otherwise win and every lane
# would be attributed to orbit 26.
m = re.search(r"(\d+)\s*$", str(name))
if not m:
continue
o = int(m.group(1))
if o not in sounds: # not triggered in this score
continue
row = rms[i]
act = []
for b in range(n_bins):
st = m2s(start_s + b * bin_s) # master -> stem, per bin
idx = int(round(st / sm_bin))
act.append(round(float(row[idx]), 1) if 0 <= idx < len(row) else -240.0)
if not act or max(act) <= LIT_FLOOR: # never audible in this track
continue
sound = sounds[o]["sound"]
# Measure, don't infer: profile this orbit's stem at its loudest bin here.
prof = None
if i < len(stem_paths):
t_peak = m2s(start_s + int(np.argmax(act)) * bin_s)
try:
prof = profile(load_window(stem_paths[i], max(0, t_peak - 8), t_peak + 8))
except Exception:
prof = None
group, cen = classify_family(sound, prof)
out.append(OrbitActivity(
orbit=f"{o:02d}", label=sound, sound=sound, group=group,
centroid=round(cen) if cen else None, activity=act))
out.sort(key=lambda x: x.orbit)
return out
def build(spec: dict) -> JudgeSet:
root = Path(spec.get("liveRoot", "/home/pln/Work/Sound/Tidal"))
clips_dir = Path(spec["clipsDir"])
tracks_dir = Path(spec["tracksDir"]) if spec.get("tracksDir") else None
stemmap = json.loads(Path(spec["stemmap"]).read_text())
meta = json.loads(Path(spec["tracksJson"]).read_text())
bin_s = float(spec.get("binS", stemmap.get("bin_s", 2.0)))
variant = Variant(spec.get("variant", "stream"))
master_dur = ffprobe_dur(Path(spec["master"])) if spec.get("master") else None
m2s = make_master_to_stem(spec["keeps"], master_dur)
tracks = []
for i, t in enumerate(meta["tracks"], start=1):
title = clean_title(t["name"])
# Clips are named "NN - Title.flac" by `master split`; match on the index,
# not on the title — titles get punctuation-normalised and would miss.
clip = next((p for p in sorted(clips_dir.glob("*.flac"))
if p.name.startswith(f"{i:02d} ")), None)
full = next((p for p in sorted(tracks_dir.glob("*.flac"))
if p.name.startswith(f"{i:02d} ")), None) if tracks_dir else None
if clip is None:
print(f" ! no clip for #{i} {title} — skipped", file=sys.stderr)
continue
clip_dur = ffprobe_dur(clip)
# Activity spans the WHOLE track, never just the head. Measured: the head
# is bit-exact with the track's first 16 s (r=1.0000), i.e. it is the
# INTRO — 1 to 4 orbits where the track proper runs nine. Lanes built from
# the head would show a track as thin when it is only starting quietly,
# and that is a ship/cut call made on the wrong evidence. One activity
# array, indexed by track time, serves both transports.
track_dur = t.get("duration_s", t["end_s"] - t["start_s"])
tidal = str(root / t["file"]) if t.get("file") else None
masters = {}
if full is not None:
ld = loudness(full)
masters[variant] = MasterInfo(file=full.name, **ld)
orbits = track_orbits(tidal, stemmap, m2s, t["start_s"], track_dur,
bin_s, spec["stemsDir"], spec.get("stemsGlob", "*.wav"))
tracks.append(JudgeTrack(
n=i, id=slugify(title), title=title,
audio=f"{spec['audioBase']}/{clip.name}",
fullAudio=f"{spec['fullAudioBase']}/{full.name}" if full else None,
startS=t["start_s"], endS=t["end_s"], durS=clip_dur,
trackDurS=track_dur,
bpm=t.get("bpm"), section=t.get("section", ""), style=t.get("style", ""),
tidal=tidal, binS=bin_s, orbits=orbits, masters=masters,
peaks=peaks(full) if full else [],
samples=t.get("samples", []),
))
lanes = ", ".join(f"{o.label}·{o.orbit}[{o.group}]" for o in orbits)
i_db = masters[variant].I if variant in masters else None
print(f" #{i:>2} {title[:30]:<30} {clip_dur:>5.1f}s "
f"I={i_db if i_db is not None else '—':>6} "
f"{len(orbits)} orbits → {lanes}", flush=True)
return JudgeSet(
gig=spec["gig"], title=spec["title"], date=spec["date"],
calibration=spec.get("calibration", ""),
activeDb=ACTIVE_DB, litFloorDb=LIT_FLOOR, roleGroups=ROLE_GROUPS,
note=spec.get("note", ""), variant=variant, tracks=tracks,
)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("spec", help="path to a judge-set spec JSON")
ap.add_argument("--out", help="output path (default: ui/public/judge-<gig>.json)")
args = ap.parse_args()
spec = json.loads(Path(args.spec).read_text())
print(f"building judge set for {spec['gig']}…", flush=True)
data = build(spec)
out = Path(args.out) if args.out else (
HERE.parent / "ui" / "public" / f"judge-{data.gig}.json")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(data.model_dump_json()) # validated on the way out
print(f"✓ {out} ({out.stat().st_size / 1024:.0f} KB, {len(data.tracks)} tracks)")
return 0
if __name__ == "__main__":
sys.exit(main())
{
"_comment": [
"Judge-set spec for OPAL 2026 'Sunset Forest'. Everything the builder needs,",
"as data — so the next gig is a copy of this file, not a code change.",
"`keeps` is the master's edit as EXPLICIT NUMBERS (stem time). Its sum must",
"equal the master duration (4786.370000 s) and the builder asserts it; a",
"content-detected edit would slide every boundary. See tidal-ears/MIXING.md."
],
"gig": "opal-festival-2026",
"title": "Sunset Forest",
"date": "2026-08-08",
"variant": "stream",
"liveRoot": "/home/pln/Work/Sound/Tidal",
"clipsDir": "/home/pln/Work/Sound/Prod/Opal26_master/heads_v4",
"tracksDir": "/home/pln/Work/Sound/Prod/Opal26_master/tracks_v4_streaming",
"master": "/home/pln/Work/Sound/Prod/Opal26_master/Opal26_SunsetForest_v4_streaming.flac",
"stemsDir": "/home/pln/Work/Sound/Prod/Opal26_master/stems_clean",
"stemsGlob": "*.wav",
"stemmap": "/home/pln/Work/Sound/Tidal/armada/tide-table/stemmap_opal26.json",
"tracksJson": "/home/pln/Work/Web/www/content/lives/2026/opal-festival-2026/tracks.json",
"audioBase": "/audio/heads",
"fullAudioBase": "/audio/full",
"keeps": [[6.0, 4540.0], [4569.9, 4822.27]],
"binS": 2.0,
"calibration": "clip time = master time; master → stem via keeps [[6.0,4540.0],[4569.9,4822.27]] (sums to 4786.370 s = the master to the millisecond)",
"note": "v4 = the premix pass that trimmed d4, which owned 76-91% of the low-frequency bed floor. Per-track loudness deliberately keeps the live arc: tracks range roughly -12.5 to -18.2 LUFS rather than being flattened to a single number."
}
...@@ -223,6 +223,81 @@ class PlayerData(BaseModel): ...@@ -223,6 +223,81 @@ class PlayerData(BaseModel):
takes: list[Take] takes: list[Take]
# ── set-judge: go/no-go audition of a whole set, one pass ────────────────────
# PlayerData above answers "which of these two takes is better?". This answers
# the other question we keep asking by hand before every release: "of the N
# tracks this set split into, which ones ship?" Same evidence (orbit lanes from
# the score, activity measured from the stems), different unit of judgement —
# a LIST of tracks, each carrying a verdict and a comment.
#
# The output (DecisionSet) is the artifact: PLN's ear, captured once, in a form
# the release tooling can read. Ear-feedback is the scarce input here; losing it
# to a chat scrollback is how the same track gets re-auditioned three times.
class Verdict(str, Enum):
pending = "pending" # not yet heard — the honest default, never "go"
go = "go"
nogo = "nogo"
maybe = "maybe" # ships only if something changes; the comment says what
class JudgeTrack(BaseModel):
"""One track up for judgement, with everything needed to decide in one screen."""
n: int # 1-indexed position in the set (the running order)
id: str # stable slug — the key decisions join on
title: str
audio: str # clip URL under /audio (the head, usually)
fullAudio: Optional[str] = None # the whole track, when 30 s isn't enough to call it
startS: float # position within the set master
endS: float
durS: float # duration of the CLIP, not of the track
trackDurS: float # duration of the full track
bpm: Optional[int] = None
section: str = "" # the movement it belongs to ("SUNSET", "NUIT"…)
style: str = ""
tidal: Optional[str] = None # the score its orbit labels were derived from
binS: float
clipStartS: float = 0.0 # where the clip sits inside the track (activity offset)
# Waveform overview for the FULL track, 0..1 peak per bucket. Precomputed
# because wavesurfer otherwise downloads and decodes the whole file before it
# draws a single pixel — 102 MB per track here, which is a blank screen on
# the desk and unusable over wifi on a phone. With peaks it paints at once
# and the audio streams behind it by Range.
peaks: list[float] = []
orbits: list[OrbitActivity] = []
masters: dict[Variant, MasterInfo] = {}
samples: list[str] = [] # sample banks the score names, whole-track
class JudgeSet(BaseModel):
gig: str # slug — matches content/lives/{year}/{slug}
title: str
date: str
calibration: str # how clip time maps to stem time; state it or don't ship
activeDb: float
litFloorDb: float = -52.0
roleGroups: list[RoleGroup]
note: str = ""
variant: Variant = Variant.stream
tracks: list[JudgeTrack]
class Decision(BaseModel):
trackId: str
n: int
title: str
verdict: Verdict = Verdict.pending
comment: str = ""
tS: Optional[float] = None # playhead when the call was made, if it was made playing
class DecisionSet(BaseModel):
"""What comes back out of the SPA. Feeds the release step directly."""
gig: str
variant: Variant = Variant.stream
decidedAt: str = "" # ISO8601
decisions: list[Decision]
# ── catalog view — the TRIANGLE: A=score ⋈ C=metadata ⋈ B=recording (#46) ───── # ── catalog view — the TRIANGLE: A=score ⋈ C=metadata ⋈ B=recording (#46) ─────
# Generated downstream artifact (build_catalog_view.build), validated on emit. # Generated downstream artifact (build_catalog_view.build), validated on emit.
# A confirmation map: does the .tidal score, the site metadata, and the recordings # A confirmation map: does the .tidal score, the site metadata, and the recordings
......
This source diff could not be displayed because it is too large. You can view the blob instead.
"""classify_family — role families from the sound name + measured spectrum.
The rail in the Judge / set-judge UIs groups orbits by these families, so a
misfile is directly visible to PLN: OPAL's `vec1_claps` and `drumtraks` were
showing up under *Melodic*, which makes the rail actively misleading.
These tests pin BOTH directions. The widening (suffix + token matching) is only
safe if the collisions the exact-match set was built to avoid still cannot
match — `cpluck` is not a clap, `dropbass` is not a drum, `snippet` is not a
snare. A test that only asserts the new hits would happily green-light a rule
that swallows half the melodic lane.
"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from audio_lens import classify_family # noqa: E402
# Real sound names from the OPAL-26 scores that were misfiled before.
@pytest.mark.parametrize("name", [
"vec1_claps", "vec1_snare", "realclaps", "clubkick", "bskick",
"jbk_kick", "808bd", "drumtraks", "drums_nes", "hardkick_rha",
])
def test_percussion_names_land_in_percs(name):
fam, _ = classify_family(name)
assert fam == "percs", f"{name} should be percs, got {fam}"
# The whole reason the classifier used exact-matching. These must NOT be percs.
@pytest.mark.parametrize("name,expected", [
("cpluck", "melodic"), # 'cp' is a clap; cpluck is a pluck
("dropbass", "bass"), # 'dr' is a drum; this is a bass
("snippet", "melodic"), # 'sn' is a snare
("subbass", "bass"),
("meth_bass", "bass"), # a noisy wobble, but its REGISTER is bass
])
def test_lookalikes_are_not_percussion(name, expected):
fam, _ = classify_family(name)
assert fam == expected, f"{name} should be {expected}, got {fam}"
@pytest.mark.parametrize("name", ["jungle_breaks", "org_jungle_breaks", "breaks165", "amen"])
def test_breaks_go_to_tops(name):
fam, _ = classify_family(name)
assert fam == "tops"
def test_measured_centroid_decides_register_not_the_name():
"""A name with no bass hint but a sub-register spectrum is still bass.
Register comes from the measurement; identity only decides percs/tops."""
lo = {"centroid": 90.0, "bands": {}}
hi = {"centroid": 2200.0, "bands": {}}
assert classify_family("ghost", lo)[0] == "bass"
assert classify_family("ghost", hi)[0] == "melodic"
def test_percussion_beats_the_spectrum():
"""A kick sits under 150 Hz like a sub does. Identity has to win, or every
kick in the set collapses into the Bass lane and the rail stops meaning
anything."""
sub_like = {"centroid": 70.0, "bands": {}}
assert classify_family("clubkick", sub_like)[0] == "percs"
def test_empty_name_is_survivable():
fam, cen = classify_family("", None)
assert fam in {"melodic", "bass"} and cen is None
{
"_comment": [
"Where /audio/<prefix>/... resolves to on disk. Read by vite.config.ts in dev",
"and by armada/serve.py in production, so the SPA's URLs are identical in both",
"— a UI that only works under `npm run dev` is a demo, not a tool.",
"",
"Masters live outside the repo (they are gigabytes). Mounting them by prefix",
"keeps the URLs stable while the paths move between gigs, and keeps `heads`",
"and `full` apart — `master split` names both '01 - Title.flac', so a single",
"flat mount would serve the 98 MB track when the UI asked for the 16 s head."
],
"mounts": {
"heads": "/home/pln/Work/Sound/Prod/Opal26_master/heads_v4",
"full": "/home/pln/Work/Sound/Prod/Opal26_master/tracks_v4_streaming",
"full-club": "/home/pln/Work/Sound/Prod/Opal26_master/tracks_v4_club",
"": "/home/pln/Work/Sound/Tidal/armada/tide-table/punkachien"
}
}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Set Judge · L'Armada</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/judge.tsx"></script>
</body>
</html>
This source diff could not be displayed because it is too large. You can view the blob instead.
/**
* End-to-end smoke for the set-judge.
*
* A clean `tsc -b` and a clean `vite build` say nothing about whether the thing
* works: the two failures this catches — an audio mount that resolves to the
* wrong file, and an export that writes a shape the release step can't read —
* are both invisible to the compiler. Assert something POSITIVE at every step.
*
* npx vite --port 5199 --strictPort &
* node scripts/judge-smoke.mjs
*/
import { chromium } from 'playwright'
const URL = process.env.JUDGE_URL ?? 'http://localhost:5199/judge.html'
const fail = (m) => { console.error(`✗ ${m}`); process.exitCode = 1 }
const ok = (m) => console.log(`✓ ${m}`)
// Use the system Chromium rather than Playwright's download. The box already
// ships one, and `npx playwright install` pulls ~200 MB per version bump for a
// browser we would only ever point at localhost.
import { existsSync } from 'node:fs'
const SYSTEM_CHROME = ['/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome-stable']
.find((p) => existsSync(p))
const b = await chromium.launch(SYSTEM_CHROME ? { executablePath: SYSTEM_CHROME } : {})
const p = await b.newPage({ viewport: { width: 1280, height: 1000 } })
const bad = []
p.on('console', (m) => m.type() === 'error' && bad.push(m.text()))
p.on('pageerror', (e) => bad.push(String(e)))
await p.goto(URL, { waitUntil: 'networkidle' })
await p.waitForSelector('li', { timeout: 15000 })
// 1 — the set loaded, and every track in it is present
const rows = await p.$$('ul > li')
rows.length === 15 ? ok(`15 track rows`) : fail(`expected 15 rows, got ${rows.length}`)
// 2 — the focused row shows real evidence, not an empty shell.
// `li canvas` pierces wavesurfer's shadow root (Playwright selectors do).
await p.waitForSelector('li canvas', { timeout: 20000 })
const lanes = await p.$$eval('li [title^="orbit-"]', (n) => n.length)
lanes > 0 ? ok(`orbit rail rendered (${lanes} lanes)`) : fail('no orbit lanes in the focused row')
// 3 — audio actually PLAYS.
//
// Two wrong versions of this check before it worked, both of which PASSED or
// failed for reasons unrelated to the app:
// a) fetching `document.querySelector('audio').src` and asserting a 206 —
// there was no <audio> in the light DOM, so it fetched the empty string,
// got the PAGE back, and read a 206 from somewhere else entirely;
// b) counting `document.querySelectorAll('li canvas')` — wavesurfer renders
// into a SHADOW ROOT, which Playwright's own selectors pierce but
// `querySelector` inside page.evaluate does not.
// So: drive it through Playwright's locators, and assert the playhead moves.
// The media element is detached (`new Audio()`), so it is not queryable at all —
// which is fine, because the honest assertion is the one PLN can see: the
// transport's own clock has to advance, and the button has to flip to Pause.
const clock = p.locator('li [tabindex="0"] span').first()
await p.locator('li [aria-label="Play"]').first().click()
await p.waitForTimeout(2500)
const shown = (await clock.textContent())?.trim() ?? ''
const flipped = await p.locator('li [aria-label="Pause"]').first().isVisible().catch(() => false)
const secs = /^(\d+):(\d+(?:\.\d+)?)$/.exec(shown)
const elapsed = secs ? Number(secs[1]) * 60 + Number(secs[2]) : 0
elapsed > 0.2 && flipped
? ok(`audio plays — transport reads ${shown}, button flipped to Pause`)
: fail(`playback did not advance: clock="${shown}" pauseButton=${flipped}`)
await p.locator('li [aria-label="Pause"]').first().click().catch(() => {})
// 4 — the keyboard pass: g on #1, j then n on #2
await p.keyboard.press('g')
await p.keyboard.press('j')
await p.keyboard.press('n')
await p.waitForTimeout(300)
const tally = await p.$$eval('header + div span', (n) => n.map((x) => x.textContent?.trim()))
tally.some((x) => /\b2\/\d+ judged/.test(x ?? ''))
? ok('2 judged after g / j / n')
: fail(`tally reads ${JSON.stringify(tally)}`)
// 5 — the artifact: a DecisionSet the release step can actually consume
const [dl] = await Promise.all([
p.waitForEvent('download', { timeout: 10000 }),
p.click('button:has-text("decisions.json")'),
])
const stream = await dl.createReadStream()
let raw = ''
for await (const c of stream) raw += c
let d
try { d = JSON.parse(raw) } catch (e) { fail(`export is not valid JSON: ${e}`) }
if (d) {
const checks = [
[d.gig === 'opal-festival-2026', `gig is ${d.gig}`],
[Array.isArray(d.decisions) && d.decisions.length === 15, `${d.decisions?.length} decisions`],
[d.decisions?.[0]?.verdict === 'go', `#1 verdict is ${d.decisions?.[0]?.verdict}`],
[d.decisions?.[1]?.verdict === 'nogo', `#2 verdict is ${d.decisions?.[1]?.verdict}`],
[d.decisions?.every((x) => x.trackId && x.title && x.n), 'every decision carries id/title/n'],
[Boolean(d.decidedAt), 'decidedAt stamped'],
]
for (const [pass, msg] of checks) (pass ? ok : fail)(msg)
}
// 6 — no errors on the console; a UI that throws while looking fine is the worst kind
bad.length === 0 ? ok('no console errors') : fail(`console errors: ${bad.slice(0, 3).join(' | ')}`)
await p.screenshot({ path: '/tmp/judge-set.png', fullPage: true })
await b.close()
console.log(process.exitCode ? '\nSMOKE FAILED' : '\nsmoke passed — /tmp/judge-set.png')
import { useMemo } from 'react' import { useMemo } from 'react'
import type { RoleGroup, Take } from '@/types' import type { OrbitActivity, RoleGroup } from '@/types'
interface Props { interface Props {
take: Take /** seconds per activity bin */
binS: number
orbits: OrbitActivity[]
roleGroups: RoleGroup[] roleGroups: RoleGroup[]
currentTime: number currentTime: number
/** "strongly on / driving" — full intensity at/above this dB */ /** "strongly on / driving" — full intensity at/above this dB */
...@@ -21,14 +23,17 @@ interface Props { ...@@ -21,14 +23,17 @@ interface Props {
* d3 OR d8 while hearing both). Now an orbit shows above `litFloorDb` (~−52) and * d3 OR d8 while hearing both). Now an orbit shows above `litFloorDb` (~−52) and
* its intensity ramps to full by `activeDb`, so a quiet-but-playing orbit never * its intensity ramps to full by `activeDb`, so a quiet-but-playing orbit never
* vanishes — it just dims. * vanishes — it just dims.
*
* Takes `binS` + `orbits` rather than a whole Take so the set-judge (a LIST of
* tracks, not an A/B of two takes) reuses the same rail. One rail, one meaning.
*/ */
export function OrbitRail({ take, roleGroups, currentTime, activeDb, litFloorDb }: Props) { export function OrbitRail({ binS, orbits: all, roleGroups, currentTime, activeDb, litFloorDb }: Props) {
const bin = Math.max(0, Math.floor(currentTime / take.binS)) const bin = Math.max(0, Math.floor(currentTime / binS))
const byGroup = useMemo(() => { const byGroup = useMemo(() => {
const m: Record<string, Take['orbits']> = {} const m: Record<string, OrbitActivity[]> = {}
for (const o of take.orbits) (m[o.group] ??= []).push(o) for (const o of all) (m[o.group] ??= []).push(o)
return m return m
}, [take]) }, [all])
// dB → 0..1: 0 at the audible floor, 1 by the "driving" threshold. // dB → 0..1: 0 at the audible floor, 1 by the "driving" threshold.
const intensity = (db: number) => { const intensity = (db: number) => {
......
...@@ -51,7 +51,7 @@ export function TakePanel({ take, variant, roleGroups, activeDb, litFloorDb, cal ...@@ -51,7 +51,7 @@ export function TakePanel({ take, variant, roleGroups, activeDb, litFloorDb, cal
<Meter master={master} loud={take.loud[variant]} currentTime={t} /> <Meter master={master} loud={take.loud[variant]} currentTime={t} />
<OrbitRail take={take} roleGroups={roleGroups} currentTime={t} activeDb={activeDb} litFloorDb={litFloorDb} /> <OrbitRail binS={take.binS} orbits={take.orbits} roleGroups={roleGroups} currentTime={t} activeDb={activeDb} litFloorDb={litFloorDb} />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
......
...@@ -7,6 +7,13 @@ import { mmss } from '@/lib/format' ...@@ -7,6 +7,13 @@ import { mmss } from '@/lib/format'
interface Props { interface Props {
url: string url: string
dur: number dur: number
/**
* Precomputed waveform overview (0..1 per bucket). Without it wavesurfer
* downloads and DECODES the whole file before drawing — fine for a 3-minute
* take, a blank screen for an 80-minute set's 100 MB tracks. With peaks it
* paints immediately and the media element streams the audio by Range.
*/
peaks?: number[]
/** called with current playback time (s) on every frame + on seek */ /** called with current playback time (s) on every frame + on seek */
onTime: (t: number) => void onTime: (t: number) => void
} }
...@@ -21,7 +28,7 @@ const ZOOM_STEP = 1.6 ...@@ -21,7 +28,7 @@ const ZOOM_STEP = 1.6
* on the waveform, toggle Repeat to loop it (great for auditing a transition or * on the waveform, toggle Repeat to loop it (great for auditing a transition or
* a single moment while iterating on the sound). Space = play/pause. * a single moment while iterating on the sound). Space = play/pause.
*/ */
export function WaveformPlayer({ url, dur, onTime }: Props) { export function WaveformPlayer({ url, dur, peaks, onTime }: Props) {
const elRef = useRef<HTMLDivElement>(null) const elRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WaveSurfer | null>(null) const wsRef = useRef<WaveSurfer | null>(null)
const regionsRef = useRef<ReturnType<typeof RegionsPlugin.create> | null>(null) const regionsRef = useRef<ReturnType<typeof RegionsPlugin.create> | null>(null)
...@@ -44,6 +51,17 @@ export function WaveformPlayer({ url, dur, onTime }: Props) { ...@@ -44,6 +51,17 @@ export function WaveformPlayer({ url, dur, onTime }: Props) {
useEffect(() => { useEffect(() => {
if (!elRef.current) return if (!elRef.current) return
const regions = RegionsPlugin.create() const regions = RegionsPlugin.create()
// With precomputed peaks we must ALSO supply the media element. Passing
// `peaks` + `url` alone makes wavesurfer treat the track as fully
// pre-decoded and it never wires up playback — the waveform draws, the
// transport looks fine, and pressing play throws "No audio loaded". Owning
// the <audio> keeps rendering (peaks) and playback (Range-streamed) separate.
const media = peaks?.length ? new Audio() : undefined
if (media) {
media.preload = 'metadata'
media.crossOrigin = 'anonymous'
media.src = url
}
const ws = WaveSurfer.create({ const ws = WaveSurfer.create({
container: elRef.current, container: elRef.current,
height: 76, height: 76,
...@@ -56,7 +74,9 @@ export function WaveformPlayer({ url, dur, onTime }: Props) { ...@@ -56,7 +74,9 @@ export function WaveformPlayer({ url, dur, onTime }: Props) {
barRadius: 1, barRadius: 1,
normalize: true, normalize: true,
autoScroll: true, autoScroll: true,
url, // Either we own the media (peaks path) or wavesurfer fetches + decodes
// the file itself (fine for the short heads).
...(media ? { media, peaks: [peaks!], duration: dur } : { url }),
plugins: [regions], plugins: [regions],
}) })
wsRef.current = ws wsRef.current = ws
...@@ -102,8 +122,13 @@ export function WaveformPlayer({ url, dur, onTime }: Props) { ...@@ -102,8 +122,13 @@ export function WaveformPlayer({ url, dur, onTime }: Props) {
return () => { return () => {
ws.destroy() ws.destroy()
if (media) {
media.pause()
media.removeAttribute('src')
media.load() // release the connection; otherwise switching
} // tracks leaves the old stream downloading
} }
}, [url]) }, [url, peaks, dur])
// apply zoom when it changes (and once ready) // apply zoom when it changes (and once ready)
useEffect(() => { useEffect(() => {
......
...@@ -21,9 +21,11 @@ ...@@ -21,9 +21,11 @@
--color-atmos: #8a93a6; --color-atmos: #8a93a6;
--color-vox: #ff3d7b; --color-vox: #ff3d7b;
/* functional */ /* functional — same three used for lifecycle + agreement, so a verdict reads
the same as a status anywhere else on the bridge (see tokens.css) */
--color-blocked: #ff5252; --color-blocked: #ff5252;
--color-ready: #5bc091; --color-ready: #5bc091;
--color-wip: #e0a82e;
--font-sans: "Geist Variable", Inter, system-ui, sans-serif; --font-sans: "Geist Variable", Inter, system-ui, sans-serif;
--font-mono: "Geist Mono Variable", ui-monospace, SFMono-Regular, monospace; --font-mono: "Geist Mono Variable", ui-monospace, SFMono-Regular, monospace;
......
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@fontsource-variable/geist'
import '@fontsource-variable/geist-mono'
import './index.css'
import SetJudge from './judge/SetJudge.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<SetJudge />
</StrictMode>,
)
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Anchor, Download, Headphones, Disc3 } from 'lucide-react'
import type { Decision, DecisionSet, JudgeSet, Verdict } from '@/types'
import { TrackRow } from './TrackRow'
import { VERDICTS } from './verdict'
type Source = 'head' | 'full'
type Calls = Record<string, { verdict: Verdict; comment: string; tS?: number }>
/** Which set to judge: ?set=<gig>, else the only one we ship. */
const gigParam = new URLSearchParams(location.search).get('set') ?? 'opal-festival-2026'
const KEY = `pv_setjudge_${gigParam}`
/**
* The set-judge: one pass down a whole gig, a verdict + a comment per track,
* exported as decisions.json.
*
* The reason it exists is that this call was being made in a music player with
* the answers landing in a chat log — so the same track got re-auditioned weeks
* later with nobody able to say why it was cut. Ear-feedback is the scarce
* input in this pipeline; this makes it an artifact.
*
* Calls persist to localStorage on every keystroke. An hour of listening lost to
* a refresh is not a bug you get to make twice.
*/
export default function SetJudge() {
const [data, setData] = useState<JudgeSet | null>(null)
const [err, setErr] = useState<string | null>(null)
const [calls, setCalls] = useState<Calls>(() => {
try { return JSON.parse(localStorage.getItem(KEY) || '{}') } catch { return {} }
})
const [focus, setFocus] = useState(0)
const [source, setSource] = useState<Source>('full')
const [t, setT] = useState(0)
useEffect(() => {
fetch(`/judge-${gigParam}.json`)
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${r.status} ${r.statusText}`))))
.then(setData)
.catch((e) => setErr(String(e.message ?? e)))
}, [])
useEffect(() => { localStorage.setItem(KEY, JSON.stringify(calls)) }, [calls])
const tracks = data?.tracks ?? []
const call = (id: string) => calls[id] ?? { verdict: 'pending' as Verdict, comment: '' }
const setVerdict = useCallback((id: string, v: Verdict, tS?: number) => {
setCalls((c) => ({ ...c, [id]: { ...(c[id] ?? { comment: '' }), verdict: v, tS } }))
}, [])
const setComment = useCallback((id: string, comment: string) => {
setCalls((c) => ({ ...c, [id]: { verdict: 'pending', ...(c[id] ?? {}), comment } }))
}, [])
const decided = useMemo(
() => tracks.filter((x) => (calls[x.id]?.verdict ?? 'pending') !== 'pending').length,
[tracks, calls],
)
const tally = useMemo(() => {
const m: Record<string, number> = { go: 0, maybe: 0, nogo: 0, pending: 0 }
for (const x of tracks) m[calls[x.id]?.verdict ?? 'pending']++
return m
}, [tracks, calls])
// Keyboard: the whole point of a judging pass is not touching the mouse.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = e.target as HTMLElement
if (el?.tagName === 'TEXTAREA' || el?.tagName === 'INPUT') {
if (e.key === 'Escape') el.blur()
return
}
const cur = tracks[focus]
if (!cur) return
const k = e.key.toLowerCase()
if (k === 'j' || e.key === 'ArrowDown') { e.preventDefault(); setFocus((i) => Math.min(tracks.length - 1, i + 1)) }
else if (k === 'k' || e.key === 'ArrowUp') { e.preventDefault(); setFocus((i) => Math.max(0, i - 1)) }
else if (k === 'g') setVerdict(cur.id, 'go', t)
else if (k === 'm') setVerdict(cur.id, 'maybe', t)
else if (k === 'n') setVerdict(cur.id, 'nogo', t)
else if (k === '0') setVerdict(cur.id, 'pending')
else if (k === 'c') {
e.preventDefault()
document.querySelector<HTMLTextAreaElement>('li textarea')?.focus()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [tracks, focus, t, setVerdict])
const exportDecisions = () => {
if (!data) return
const out: DecisionSet = {
gig: data.gig,
variant: source === 'full' ? data.variant : data.variant,
decidedAt: new Date().toISOString(),
decisions: tracks.map<Decision>((x) => ({
trackId: x.id,
n: x.n,
title: x.title,
verdict: call(x.id).verdict,
comment: call(x.id).comment,
tS: call(x.id).tS ?? null,
})),
}
const blob = new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `decisions-${data.gig}.json`
a.click()
URL.revokeObjectURL(a.href)
}
if (err) {
return (
<div className="grid min-h-svh place-items-center px-6 text-center">
<div>
<p className="text-sm text-blocked">Couldn&apos;t load /judge-{gigParam}.json — {err}</p>
<p className="mt-2 text-xs text-ink-faint">
Build it: <span className="font-mono">python3 armada/tide-table/build_judge_set.py
armada/tide-table/judge_specs/&lt;gig&gt;.json</span>
</p>
</div>
</div>
)
}
if (!data) {
return (
<div className="grid min-h-svh place-items-center text-ink-muted">
<p className="animate-pulse text-sm">loading the bridge…</p>
</div>
)
}
return (
<div className="mx-auto flex min-h-svh max-w-4xl flex-col gap-4 px-4 py-5">
<header className="flex flex-wrap items-end justify-between gap-3 border-b border-hairline pb-4">
<div className="min-w-0">
<p className="flex items-center gap-1.5 text-[11px] uppercase tracking-widest text-ink-faint">
<Anchor size={12} /> L&apos;Armada · set judge
</p>
<h1 className="truncate text-2xl font-semibold tracking-tight">
{data.title} <span className="text-ink-muted">{data.date}</span>
</h1>
<p className="mt-0.5 text-xs text-ink-faint">{data.calibration}</p>
</div>
<div className="flex items-center gap-3">
<div className="flex rounded-md border border-hairline p-0.5 text-xs">
{([['head', Headphones, '16 s'], ['full', Disc3, 'full']] as const).map(
([v, Icon, label]) => (
<button
key={v}
onClick={() => setSource(v)}
className={`flex items-center gap-1.5 rounded px-2.5 py-1 transition-colors ${
source === v ? 'bg-ink text-surface' : 'text-ink-muted hover:text-ink'
}`}
>
<Icon size={12} /> {label}
</button>
),
)}
</div>
<button
onClick={exportDecisions}
disabled={!decided}
className="flex items-center gap-1.5 rounded-md bg-raised px-3 py-1.5 text-xs
font-medium hover:bg-overlay disabled:opacity-30"
>
<Download size={13} /> decisions.json
</button>
</div>
</header>
{/* progress — the tally is the state of the pass, at a glance */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
<span className="text-ink-muted tnum">
{decided}/{tracks.length} judged
</span>
{VERDICTS.map((v) => (
<span key={v.key} className="flex items-center gap-1 tnum" style={{ color: v.color }}>
<span aria-hidden>{v.glyph}</span>
{tally[v.key] ?? 0} {v.label.toLowerCase()}
</span>
))}
<span className="ml-auto font-mono text-[11px] text-ink-faint">
j/k move · g go · m maybe · n no-go · c comment
</span>
</div>
{source === 'head' && (
<p className="rounded-md border border-hairline bg-raised px-3 py-2 text-[11px] text-ink-muted">
Heads are each track&apos;s first 16 s — verified bit-exact against the track, so
you are hearing 15 <em>intros</em>. Good for a running-order scan, thin evidence for a
ship/cut call. The orbit rail always covers the whole track.
</p>
)}
<ul className="flex flex-col gap-1">
{tracks.map((track, i) => (
<TrackRow
key={track.id}
track={track}
roleGroups={data.roleGroups}
activeDb={data.activeDb}
litFloorDb={data.litFloorDb}
expanded={i === focus}
source={source}
verdict={call(track.id).verdict}
comment={call(track.id).comment}
currentTime={i === focus ? t : 0}
onExpand={() => setFocus(i)}
onTime={setT}
onVerdict={(v) => setVerdict(track.id, v, t)}
onComment={(c) => setComment(track.id, c)}
/>
))}
</ul>
<footer className="mt-auto border-t border-hairline pt-3 text-[11px] text-ink-faint">
{data.note && <p className="mb-1">{data.note}</p>}
Calls persist locally as you make them; export writes the decisions.json the release
step reads. Orbit labels come from each track&apos;s <span className="font-mono">.tidal</span>{' '}
score, activity is measured from the stems.
</footer>
</div>
)
}
import { useRef } from 'react'
import type { JudgeTrack, RoleGroup, Verdict } from '@/types'
import { WaveformPlayer } from '@/components/WaveformPlayer'
import { OrbitRail } from '@/components/OrbitRail'
import { mmss, fmtDb } from '@/lib/format'
import { VERDICTS, verdictMeta } from './verdict'
type Source = 'head' | 'full'
interface Props {
track: JudgeTrack
roleGroups: RoleGroup[]
activeDb: number
litFloorDb: number
expanded: boolean
source: Source
verdict: Verdict
comment: string
currentTime: number
onExpand: () => void
onTime: (t: number) => void
onVerdict: (v: Verdict) => void
onComment: (c: string) => void
}
export function TrackRow({
track, roleGroups, activeDb, litFloorDb, expanded, source,
verdict, comment, currentTime, onExpand, onTime, onVerdict, onComment,
}: Props) {
const vm = verdictMeta(verdict)
const commentRef = useRef<HTMLTextAreaElement>(null)
const stream = track.masters?.stream ?? Object.values(track.masters ?? {})[0]
// The head is the track's first 16 s — verified bit-exact — so the orbit rail
// indexes the SAME activity array either way; only the transport changes.
const url = source === 'head' ? track.audio : (track.fullAudio ?? track.audio)
const dur = source === 'head' ? track.durS : track.trackDurS
return (
<li
className={`rounded-lg border transition-colors ${
expanded ? 'border-hairline bg-raised' : 'border-transparent hover:bg-raised/60'
}`}
>
{/* ── the row: readable without expanding ── */}
<button
onClick={onExpand}
className="flex w-full items-center gap-3 px-3 py-2 text-left"
aria-expanded={expanded}
>
<span
className="w-6 shrink-0 text-center font-mono text-xs tnum"
style={{ color: vm.color }}
title={vm.label}
>
{vm.glyph}
</span>
<span className="w-6 shrink-0 font-mono text-xs text-ink-faint tnum">{track.n}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{track.title}</span>
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-ink-faint">
{track.section && <span>{track.section}</span>}
{track.bpm ? <span className="tnum">{track.bpm} BPM</span> : null}
<span className="tnum">{mmss(track.trackDurS, 0)}</span>
{stream?.I != null && <span className="tnum">{fmtDb(stream.I, ' LUFS')}</span>}
<span className="tnum">{track.orbits.length} orbits</span>
{comment && !expanded && (
<span className="truncate text-ink-muted italic">{comment}</span>
)}
</span>
</span>
</button>
{/* ── expanded: the evidence + the call ── */}
{expanded && (
<div className="flex flex-col gap-3 border-t border-hairline px-3 pb-3 pt-3">
<WaveformPlayer
key={url}
url={url}
dur={dur}
// Peaks describe the FULL track; the 16 s head is small enough to
// decode on its own, and reusing full-track peaks for it would draw
// the wrong picture.
peaks={source === 'full' ? track.peaks : undefined}
onTime={onTime}
/>
<OrbitRail
binS={track.binS}
orbits={track.orbits}
roleGroups={roleGroups}
currentTime={currentTime}
activeDb={activeDb}
litFloorDb={litFloorDb}
/>
{track.samples.length > 0 && (
<p className="text-[11px] leading-relaxed text-ink-faint">
<span className="text-ink-muted">banks</span>{' '}
<span className="font-mono">{track.samples.join(' · ')}</span>
</p>
)}
<div className="flex flex-wrap items-center gap-2">
{VERDICTS.filter((v) => v.key !== 'pending').map((v) => {
const on = verdict === v.key
return (
<button
key={v.key}
onClick={() => onVerdict(on ? 'pending' : v.key)}
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs
font-medium transition-colors"
style={{
borderColor: on ? v.color : 'var(--color-hairline)',
color: on ? v.color : 'var(--color-ink-muted)',
background: on ? `color-mix(in oklab, ${v.color} 14%, transparent)` : 'transparent',
}}
title={`${v.label} (${v.hotkey})`}
>
<span aria-hidden>{v.glyph}</span> {v.label}
</button>
)
})}
<span className="ml-auto font-mono text-[11px] text-ink-faint tnum">
set {mmss(track.startS, 0)}{mmss(track.endS, 0)}
</span>
</div>
<textarea
ref={commentRef}
value={comment}
onChange={(e) => onComment(e.target.value)}
placeholder="What did you hear? (timecode, what's wrong, what to fix)"
rows={2}
className="w-full resize-y rounded-md border border-hairline bg-surface px-2.5 py-2
text-xs leading-relaxed placeholder:text-ink-faint/70
focus:border-ink-faint focus:outline-none"
/>
</div>
)}
</li>
)
}
import type { Verdict } from '@/types'
/**
* Verdict presentation, in one place. Colour is never the only signal — each
* verdict also carries a glyph and a word, so the list stays readable in a
* screenshot, on a phone in sunlight, and to anyone who doesn't separate red
* from green (DESIGN.md).
*/
export const VERDICTS: { key: Verdict; label: string; glyph: string; color: string; hotkey: string }[] = [
{ key: 'go', label: 'Go', glyph: '✓', color: 'var(--color-ready)', hotkey: 'g' },
{ key: 'maybe', label: 'Maybe', glyph: '~', color: 'var(--color-wip)', hotkey: 'm' },
{ key: 'nogo', label: 'No-go', glyph: '✕', color: 'var(--color-blocked)', hotkey: 'n' },
{ key: 'pending', label: 'Pending', glyph: '·', color: 'var(--color-ink-faint)', hotkey: '0' },
]
export const verdictMeta = (v: Verdict | undefined) =>
VERDICTS.find((x) => x.key === (v ?? 'pending'))!
...@@ -3,7 +3,10 @@ export function mmss(t: number, decimals = 1): string { ...@@ -3,7 +3,10 @@ export function mmss(t: number, decimals = 1): string {
if (!isFinite(t)) return '0:00' if (!isFinite(t)) return '0:00'
const m = Math.floor(t / 60) const m = Math.floor(t / 60)
const s = t - m * 60 const s = t - m * 60
return `${m}:${s.toFixed(decimals).padStart(decimals ? 4 + decimals : 2, '0')}` // "SS.d" is 3 chars + the decimals, not 4 — the old `4 + decimals` padded a
// spurious zero onto every sub-10s time ("0:002.2"). Caught by the set-judge
// smoke test reading the transport clock back.
return `${m}:${s.toFixed(decimals).padStart(decimals ? 3 + decimals : 2, '0')}`
} }
/** dB value → 0..1 across a loudness window (default -40..-3 LUFS) */ /** dB value → 0..1 across a loudness window (default -40..-3 LUFS) */
......
...@@ -15,6 +15,8 @@ export type Source = 'user' | 'ear' | 'file' | 'web' | 'derived' ...@@ -15,6 +15,8 @@ export type Source = 'user' | 'ear' | 'file' | 'web' | 'derived'
export type Variant = 'stream' | 'club' export type Variant = 'stream' | 'club'
export type Verdict = 'pending' | 'go' | 'nogo' | 'maybe'
/** A↔C: how well the site's claimed ingredients match the actual score. */ /** A↔C: how well the site's claimed ingredients match the actual score. */
export interface AgreeResult { export interface AgreeResult {
level: AgreeLevel level: AgreeLevel
...@@ -46,6 +48,15 @@ export interface CatalogStats { ...@@ -46,6 +48,15 @@ export interface CatalogStats {
gigs_total: number gigs_total: number
} }
export interface Decision {
trackId: string
n: number
title: string
verdict?: Verdict
comment?: string
tS?: number | null
}
/** A raw corner-C ingredient (the site's claim) — ground truth for the drawer. */ /** A raw corner-C ingredient (the site's claim) — ground truth for the drawer. */
export interface Ingredient { export interface Ingredient {
type?: string type?: string
...@@ -54,6 +65,29 @@ export interface Ingredient { ...@@ -54,6 +65,29 @@ export interface Ingredient {
gig?: string gig?: string
} }
/** One track up for judgement, with everything needed to decide in one screen. */
export interface JudgeTrack {
n: number
id: string
title: string
audio: string
fullAudio?: string | null
startS: number
endS: number
durS: number
trackDurS: number
bpm?: number | null
section?: string
style?: string
tidal?: string | null
binS: number
clipStartS?: number
peaks?: number[]
orbits?: OrbitActivity[]
masters?: Record<Variant, MasterInfo>
samples?: string[]
}
export interface LoudTrace { export interface LoudTrace {
trace: number[] trace: number[]
stepS: number stepS: number
...@@ -227,6 +261,27 @@ export interface Note { ...@@ -227,6 +261,27 @@ export interface Note {
text: string text: string
} }
export interface JudgeSet {
gig: string
title: string
date: string
calibration: string
activeDb: number
litFloorDb?: number
roleGroups: RoleGroup[]
note?: string
variant?: Variant
tracks: JudgeTrack[]
}
/** What comes back out of the SPA. Feeds the release step directly. */
export interface DecisionSet {
gig: string
variant?: Variant
decidedAt?: string
decisions: Decision[]
}
export interface CatalogView { export interface CatalogView {
schema: string schema: string
as_of: string as_of: string
......
...@@ -4,23 +4,41 @@ import tailwindcss from '@tailwindcss/vite' ...@@ -4,23 +4,41 @@ import tailwindcss from '@tailwindcss/vite'
import path from 'node:path' import path from 'node:path'
import fs from 'node:fs' import fs from 'node:fs'
const AUDIO_DIR = path.resolve(__dirname, '../tide-table/punkachien') /**
* /audio/<prefix>/... → a real directory, from audio-mounts.json (shared with
* serve.py so dev and production resolve identically). Longest prefix wins; ""
* is the fallback mount. Masters are gigabytes and live outside the repo, so
* they are mounted, never copied into the bundle.
*/
function loadMounts(): [string, string][] {
try {
const cfg = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'audio-mounts.json'), 'utf8'))
return Object.entries(cfg.mounts as Record<string, string>)
.map(([k, v]) => [k, path.resolve(__dirname, v)] as [string, string])
.sort((a, b) => b[0].length - a[0].length) // longest prefix first
} catch {
return [['', path.resolve(__dirname, '../tide-table/punkachien')]]
}
}
/** /**
* Dev-only: serve /audio/* from the punkachien dir with HTTP Range support, * Dev-only: serve /audio/* with HTTP Range support, so wavesurfer can stream +
* so wavesurfer can stream + seek the big FLACs without copying them into the * seek the big FLACs without copying them into the bundle. In production,
* bundle. In production, `serve.py --dir dist` with a `dist/audio` symlink does * `serve.py --dir dist` reads the same mounts, keeping /audio stable everywhere.
* the same job (created post-build), keeping /audio a stable base everywhere.
*/ */
function audioDevServer(): Plugin { function audioDevServer(): Plugin {
const mounts = loadMounts()
return { return {
name: 'armada-audio-dev', name: 'armada-audio-dev',
apply: 'serve', apply: 'serve',
configureServer(server) { configureServer(server) {
server.middlewares.use('/audio', (req, res, next) => { server.middlewares.use('/audio', (req, res, next) => {
const rel = decodeURIComponent((req.url || '').split('?')[0]) const rel = decodeURIComponent((req.url || '').split('?')[0]).replace(/^\/+/, '')
const file = path.join(AUDIO_DIR, rel) const hit = mounts.find(([p]) => p === '' || rel === p || rel.startsWith(p + '/'))
if (!file.startsWith(AUDIO_DIR) || !fs.existsSync(file) || !fs.statSync(file).isFile()) if (!hit) return next()
const [prefix, dir] = hit
const file = path.join(dir, prefix ? rel.slice(prefix.length) : rel)
if (!file.startsWith(dir) || !fs.existsSync(file) || !fs.statSync(file).isFile())
return next() return next()
const size = fs.statSync(file).size const size = fs.statSync(file).size
const range = req.headers.range const range = req.headers.range
...@@ -51,9 +69,11 @@ export default defineConfig({ ...@@ -51,9 +69,11 @@ export default defineConfig({
build: { build: {
rollupOptions: { rollupOptions: {
input: { input: {
// Multi-page: the take-judge (index) + the distribution simulator (sextant). // Multi-page: the A/B take-judge (index), the distribution simulator
// (sextant), and the set-judge (judge) — go/no-go across a whole gig.
main: path.resolve(__dirname, 'index.html'), main: path.resolve(__dirname, 'index.html'),
sextant: path.resolve(__dirname, 'sextant.html'), sextant: path.resolve(__dirname, 'sextant.html'),
judge: path.resolve(__dirname, 'judge.html'),
}, },
}, },
}, },
......
...@@ -14,9 +14,11 @@ from pathlib import Path ...@@ -14,9 +14,11 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "armada" / "tide-table")) sys.path.insert(0, str(ROOT / "armada" / "tide-table"))
from models import CatalogView, Note, PatternRegistry, PlayerData, Presence # noqa: E402 from models import (CatalogView, DecisionSet, JudgeSet, Note, # noqa: E402
PatternRegistry, PlayerData, Presence)
ROOTS = [PlayerData, Note, CatalogView, PatternRegistry, Presence] # top-level UI-facing models # top-level UI-facing models
ROOTS = [PlayerData, Note, JudgeSet, DecisionSet, CatalogView, PatternRegistry, Presence]
OUT = ROOT / "armada" / "ui" / "src" / "types.gen.ts" OUT = ROOT / "armada" / "ui" / "src" / "types.gen.ts"
SCALARS = {"string": "string", "number": "number", "integer": "number", SCALARS = {"string": "string", "number": "number", "integer": "number",
"boolean": "boolean", "null": "null"} "boolean": "boolean", "null": "null"}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment