Commit 20b91515 by PLN (Algolia)

fix(judge): chain into the next track — and PLN's ear beats both cut detectors

First real use of the set-judge, and it did its job: it found that the PROBLEM
IS NOT THE MASTER, IT IS THE CUTS. PLN judged through #10 and stopped —
"listened to 7, then errors seem to compound".

  #3  893.0   WAP bass bleeds in, real start 0:03, and the END misses a note
  #4  1118.4  4s too much at the head; Sunshine begins at 7:15 INSIDE it
  #5  1549.4  missing its own head (it is sitting inside #4); ends abrupt
  #6  1808.1  "still some sunshine bass here :(" — real start 0:10
  #7  2248.8  "this start is still the drops" — 21s of the previous track
  #9  2954.4  correct: "perfect starts perfect :lol:"
  #10 3269.0  "starts on a perfect sound lol", real Gimme Acid at 0:16.7

Magnitude grows through the middle of the set and the direction flips, so it is
not clock drift — it is per-boundary detection error. PLN named the mechanism
himself: "check careful youll see the xfade". Every dN is a 4-cycle crossfade,
so across a switch the ORBIT SET barely changes (d1 is a kick before and after)
and every activity-based detector sees continuity. What changes is the SAMPLE.

TWO INSTRUMENTS FAILED, AND BOTH REPORTED SUCCESS.

`tidal-ears master cuts verify` said 13/15 PASS. It genuinely catches the
stutter shape — on #3 and #4 it agreed with his ear to within a second — and it
passed #5, #6 and #7 where the errors were 9, 10 and 21 seconds.

So I wrote cut_lens.py to measure TIMBRE rather than activity, with a --truth
harness to check it against the five boundaries his ear had already settled.
It missed by a MEDIAN OF 24 SECONDS, worst 40 s, with only 1-5 orbits voting.
Discarded. Committed anyway, with the negative result and three concrete
hypotheses at the top of the file, because the harness is the valuable part and
the next person to have this idea deserves to see the result first. The
ear-verified boundaries are now DATA (judge_specs/opal26_boundaries_ear.json),
marked do-not-overwrite-with-a-detector.

Also fixed the first version of cut_lens spawning ffmpeg once per window —
14 boundaries x 12 orbits x ~90 windows = 15k execve for four seconds of
arithmetic. Decode the span once, slice in numpy.

FEATURE, from "players need to autoplay next so we can see how it goes into ;)":
the judge now chains into the next track by default. A per-track list otherwise
makes transitions the one thing you cannot hear — which is exactly what this
whole pass turned out to be about. Manual j/k navigation never auto-plays, so
moving around stays quiet; the toggle is in the header.

All his notes archived verbatim in performance_notes.md with the implied
absolute boundary for each, per the archivist rule.

Validated: 11/11 smoke green against the built dist; tsc clean.
parent 9c6a24cb
{
"_comment": [
"PLN's ear-verified boundary corrections for OPAL-26 v4, 2026-08-16.",
"GROUND TRUTH. Two automated detectors were validated against these and both",
"failed (cuts verify passed 3 of them wrongly; cut_lens.py missed by a median",
"24s). Do not overwrite these with a detector's output.",
"Times are absolute MASTER time in seconds. `start` corrections only; ends",
"follow from the next track's start since the set is butt-joined.",
"Source: decisions-opal-festival-2026.json comments + follow-up messages."
],
"gig": "opal-festival-2026",
"verified": {
"3": {
"start": 893.0,
"note": "WAP bass bleeds ~1.5s, silence, real start 0:03; END misses the last note"
},
"4": {
"start": 1118.4,
"note": "4s too much at start; Sunshine begins at 7:15 inside this track"
},
"5": {
"start": 1549.4,
"note": "missing its head (it is inside #4); ends abrupt"
},
"6": {
"start": 1808.1,
"note": "sunshine bass still present; clear bass-texture change at 0:10"
},
"7": {
"start": 2248.8,
"note": "first 21s are still Take Five Drops — the xfade"
},
"9": {
"start": 2954.4,
"note": "CORRECT as-is: 'perfect starts perfect'"
},
"10": {
"start": 3269.0,
"note": "opens on a Perfect sound; real Gimme Acid at 0:16.7"
}
},
"approximate": {
"8": {
"start": 2723.0,
"note": "'almost there' — a few seconds of Piment bleed in; needs a precise listen"
}
},
"unjudged": [
11,
12,
13,
14,
15
]
}
......@@ -420,3 +420,56 @@ not call it (d12 fit the theory perfectly and turned out to be −72.6 dBFS —
inaudible). Forty seconds of PLN's ear closed it, and the answer was that the
premise was wrong. Reserve his ears for identification and taste; use the
machine for objective errors.
---
## 2026-08-16 — OPAL-26 v4 splits: the boundary pass (set-judge, first use)
First run of the set-judge SPA. PLN judged through #10 and stopped: *"listened
to 7, then errors seem to compound"*. Every note below is his, verbatim, with
the absolute master-time boundary it implies against `segments_v3.json`.
**The finding: the track boundaries are wrong, and the error grows through the
middle of the set.** These are not mastering complaints — the audio is fine, the
CUTS are in the wrong place.
| # | track | verdict | what he heard | implied true start |
|---|---|---|---|---|
| 1 | Ceci n'est pas Une Bombe | GO | — | 0.0 (ok) |
| 2 | WAP | GO | — | 531.35 (ok) |
| 3 | There's Something About Drums | NOGO | *"theres a bit of WAP bass till like 001:50? then silence, then at 0:03 maybe SabDrums real starts. also at end 3:44 misses last note!!"* | **893.0** |
| 4 | Am i Doing it Right | NOGO | *"starts really at 0:04 on the overlap of melodc and start of the daft sound, we have 4s too much at start. and at 0715 its the start of sunshine!!!"* | **1118.4** (and #5 starts inside it) |
| 5 | You My Sunshine | MAYBE | *"we lack some at start as said before. ends abrupt?"* | **1549.4** |
| 6 | Take five Drops | — | *"yea still some sunshine bass here :( starts real at 00:10 or sth you have a clear bass change texture!"* | **1808.1** |
| 7 | Piment Bresilien | — | *"this start is still the drops! piment starts real at 0:21 only or sth, check careful youll see the xfade!!!"* | **2248.8** |
| 8 | Eh ouais je Funk | — | *"almost there, a few secs of piment bleed in, maybe a single note of perfect in at the end, not even sure"* | ~2723 (small) |
| 9 | Perfect | — | *"perfect starts perfect :lol:"* | 2954.4 **(correct)** |
| 10 | Gimme Acid | — | *"gimme however starts on a perfect sound lol, 0.16.7 is the moment to gimme acid i think"* | **3269.0** |
| 11–15 | — | not yet judged | | |
**Mechanism.** He named it himself: *"check careful youll see the xfade"*. Every
`dN` is a 4-cycle crossfade, so across a switch the ORBIT SET barely changes —
d1 is a kick before and a kick after. Every activity-based detector therefore
sees continuity and passes the cut. What changes is the SAMPLE on each orbit.
The longer and more relaxed the transition, the further the true boundary drifts
from the detected one: 3 s at #3, 21 s at #7.
**Two instruments failed on this, and both said they passed.**
* `tidal-ears master cuts verify` reported **13/15 boundaries pass**. It caught
#3 and #4 (agreeing with his ear to within a second — it detects the *stutter*
shape: short fragment, gap, real start) and passed #5, #6, #7 where the errors
were 9, 10 and 21 seconds.
* `cut_lens.py`, written that afternoon specifically to see timbre rather than
activity, was validated against these five ear-verified boundaries and missed
by a **median 24 s, worst 40 s**. Discarded, not shipped. The validation
harness is the only part worth keeping.
**Feature this produced:** *"players need to autoplay next so we can see how it
goes into ;)"* — the set-judge now chains into the next track by default, since
a per-track list otherwise makes transitions the one thing you cannot hear.
**Method note.** The corpus rule held again: the machine was supposed to catch
objective errors so his ears are spent on taste, and here the objective error
was one no machine we own can currently see. Ear-truth first; instruments earn
trust by reproducing it.
......@@ -16,6 +16,10 @@ interface Props {
peaks?: number[]
/** called with current playback time (s) on every frame + on seek */
onTime: (t: number) => void
/** fired when the track reaches its end — drives auto-advance */
onFinish?: () => void
/** start playing as soon as the transport is ready (auto-advance landing) */
autoPlay?: boolean
}
const MIN_ZOOM = 0 // 0 = fit-to-width
......@@ -28,13 +32,17 @@ const ZOOM_STEP = 1.6
* 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.
*/
export function WaveformPlayer({ url, dur, peaks, onTime }: Props) {
export function WaveformPlayer({ url, dur, peaks, onTime, onFinish, autoPlay }: Props) {
const elRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WaveSurfer | null>(null)
const regionsRef = useRef<ReturnType<typeof RegionsPlugin.create> | null>(null)
const activeRegionRef = useRef<{ start: number; end: number; play: (from?: boolean) => void } | null>(null)
const onTimeRef = useRef(onTime)
onTimeRef.current = onTime
const onFinishRef = useRef(onFinish)
onFinishRef.current = onFinish
const autoPlayRef = useRef(autoPlay)
autoPlayRef.current = autoPlay
const loopRef = useRef(true)
const [playing, setPlaying] = useState(false)
......@@ -90,7 +98,13 @@ export function WaveformPlayer({ url, dur, peaks, onTime }: Props) {
setT(time)
onTimeRef.current(time)
}
ws.on('ready', () => setReady(true))
ws.on('ready', () => {
setReady(true)
// Auto-advance lands here. Browsers only allow this because the user
// already gestured on the previous track; if they haven't, play()
// rejects and we simply stay paused rather than throwing.
if (autoPlayRef.current) ws.play().catch(() => {})
})
ws.on('timeupdate', emit)
ws.on('interaction', () => {
activeRegionRef.current = null
......@@ -98,7 +112,10 @@ export function WaveformPlayer({ url, dur, peaks, onTime }: Props) {
})
ws.on('play', () => setPlaying(true))
ws.on('pause', () => setPlaying(false))
ws.on('finish', () => setPlaying(false))
ws.on('finish', () => {
setPlaying(false)
onFinishRef.current?.()
})
// single drag-selection that can be looped
regions.enableDragSelection({ color: 'rgba(217, 0, 255, 0.12)' })
......
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Anchor, Download, Headphones, Disc3 } from 'lucide-react'
import { Anchor, Download, Headphones, Disc3, ListVideo } from 'lucide-react'
import type { Decision, DecisionSet, JudgeSet, Verdict } from '@/types'
import { TrackRow } from './TrackRow'
import { VERDICTS } from './verdict'
......@@ -32,6 +32,12 @@ export default function SetJudge() {
const [focus, setFocus] = useState(0)
const [source, setSource] = useState<Source>('full')
const [t, setT] = useState(0)
// Auto-advance: PLN judges TRANSITIONS as much as tracks — "players need to
// autoplay next so we can see how it goes into". Landing on the next row
// already playing is the only way to hear a handover in a per-track list.
const [chain, setChain] = useState(true)
const [armed, setArmed] = useState(false) // this row landed via auto-advance
useEffect(() => {
fetch(`/judge-${gigParam}.json`)
......@@ -49,7 +55,10 @@ export default function SetJudge() {
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 } }))
setCalls((c) => ({
...c,
[id]: { ...(c[id] ?? { verdict: 'pending' as Verdict }), comment },
}))
}, [])
const decided = useMemo(
......@@ -73,8 +82,8 @@ export default function SetJudge() {
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)) }
if (k === 'j' || e.key === 'ArrowDown') { e.preventDefault(); setArmed(false); setFocus((i) => Math.min(tracks.length - 1, i + 1)) }
else if (k === 'k' || e.key === 'ArrowUp') { e.preventDefault(); setArmed(false); 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)
......@@ -88,6 +97,15 @@ export default function SetJudge() {
return () => window.removeEventListener('keydown', onKey)
}, [tracks, focus, t, setVerdict])
const advance = useCallback(() => {
if (!chain) return
setFocus((i) => {
if (i >= tracks.length - 1) return i
setArmed(true)
return i + 1
})
}, [chain, tracks.length])
const exportDecisions = () => {
if (!data) return
const out: DecisionSet = {
......@@ -100,7 +118,7 @@ export default function SetJudge() {
title: x.title,
verdict: call(x.id).verdict,
comment: call(x.id).comment,
tS: call(x.id).tS ?? null,
tS: call(x.id).tS,
})),
}
const blob = new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' })
......@@ -162,6 +180,18 @@ export default function SetJudge() {
)}
</div>
<button
onClick={() => setChain((v) => !v)}
aria-pressed={chain}
title="Play straight into the next track, so you hear the transition"
className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs
font-medium transition-colors ${
chain ? 'border-magenta/50 bg-magenta/15 text-magenta'
: 'border-hairline text-ink-muted hover:text-ink'
}`}
>
<ListVideo size={13} /> chain
</button>
<button
onClick={exportDecisions}
disabled={!decided}
className="flex items-center gap-1.5 rounded-md bg-raised px-3 py-1.5 text-xs
......@@ -203,14 +233,16 @@ export default function SetJudge() {
track={track}
roleGroups={data.roleGroups}
activeDb={data.activeDb}
litFloorDb={data.litFloorDb}
litFloorDb={data.litFloorDb ?? -52}
expanded={i === focus}
source={source}
verdict={call(track.id).verdict}
comment={call(track.id).comment}
currentTime={i === focus ? t : 0}
onExpand={() => setFocus(i)}
onExpand={() => { setArmed(false); setFocus(i) }}
onTime={setT}
onFinish={advance}
autoPlay={i === focus && armed}
onVerdict={(v) => setVerdict(track.id, v, t)}
onComment={(c) => setComment(track.id, c)}
/>
......
......@@ -21,13 +21,20 @@ interface Props {
onTime: (t: number) => void
onVerdict: (v: Verdict) => void
onComment: (c: string) => void
onFinish: () => void
autoPlay: boolean
}
export function TrackRow({
track, roleGroups, activeDb, litFloorDb, expanded, source,
verdict, comment, currentTime, onExpand, onTime, onVerdict, onComment,
onFinish, autoPlay,
}: Props) {
const vm = verdictMeta(verdict)
// `orbits`/`samples` carry pydantic defaults, so the generated TS marks them
// optional. Normalise once here instead of guarding at every use.
const orbits = track.orbits ?? []
const samples = track.samples ?? []
const commentRef = useRef<HTMLTextAreaElement>(null)
const stream = track.masters?.stream ?? Object.values(track.masters ?? {})[0]
......@@ -64,7 +71,7 @@ export function TrackRow({
{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>
<span className="tnum">{orbits.length} orbits</span>
{comment && !expanded && (
<span className="truncate text-ink-muted italic">{comment}</span>
)}
......@@ -84,21 +91,23 @@ export function TrackRow({
// the wrong picture.
peaks={source === 'full' ? track.peaks : undefined}
onTime={onTime}
onFinish={onFinish}
autoPlay={autoPlay}
/>
<OrbitRail
binS={track.binS}
orbits={track.orbits}
orbits={orbits}
roleGroups={roleGroups}
currentTime={currentTime}
activeDb={activeDb}
litFloorDb={litFloorDb}
/>
{track.samples.length > 0 && (
{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>
<span className="font-mono">{samples.join(' · ')}</span>
</p>
)}
......
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