---
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.
