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
#!/usr/bin/env python3
"""cut_lens — find where a track REALLY starts, when the transition is a crossfade.
WHY THE EXISTING VERIFIER IS NOT ENOUGH
---------------------------------------
`tidal-ears master cuts verify` passed 13/15 boundaries on OPAL-26 while PLN's
ear found errors of 9, 10 and 21 seconds at three of the ones it passed. It is
not a bad tool — it catches the *stutter* shape (a short fragment, a gap, then
the real start) and on #3/#4 it agreed with his ear to within a second. It is
blind to the shape that actually dominates this set:
ParVagues switches tracks by CROSSFADE, and every dN is a 4-cycle fade.
Across a switch the ORBIT SET barely changes — d1 is a kick before and a
kick after — so every activity-based detector sees continuity and passes.
What changes is the SAMPLE on each orbit, i.e. the TIMBRE.
So this measures timbre. For each orbit it profiles a reference deep inside the
outgoing track and one deep inside the incoming track, then slides a window
across the transition and asks, per orbit: which reference does this sound like?
The boundary is where the incoming reference wins on the ACTIVE orbits.
VALIDATE IT BEFORE YOU BELIEVE IT
---------------------------------
`--truth` takes PLN's ear-verified boundaries and reports this lens's error
against them. Run that FIRST: an instrument that cannot reproduce the five
answers we already know has no business proposing the eight we don't.
>>> RESULT, 2026-08-16: THIS LENS FAILED ITS OWN VALIDATION. <<<
Against PLN's five ear-verified boundaries it missed by a MEDIAN OF 24 SECONDS,
worst 40 s, with only 1-5 orbits ever voting. Its proposals were discarded and
the cut was corrected from his ear instead (judge_specs/opal26_boundaries_ear
.json). It is kept because the VALIDATION HARNESS is the valuable part, and
because the next person to have this idea should see the result before spending
an afternoon on it.
Where it probably goes wrong, for whoever picks it up:
* The reference windows are taken from each track's midpoint, but a livecoded
track is not stationary — its middle can be timbrally further from its own
head than from the neighbouring track.
* Requiring `sep >= 0.08` plus audibility leaves too few voting orbits, so one
bad orbit dominates a weighted mean. A median over more orbits, or a
per-orbit change-point with agreement voting, would be sturdier.
* A 6 s sustained-crossing rule cannot resolve a 4-cycle crossfade at 124 BPM
(~7.7 s), which is precisely the span being measured.
USAGE
python3 cut_lens.py --spec judge_specs/opal26.json # propose all
python3 cut_lens.py --spec judge_specs/opal26.json --truth truth.json
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import numpy as np
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from audio_lens import load_window, profile # noqa: E402
SEARCH = 45.0 # seconds either side of the nominal cut to consider
WIN = 4.0 # profiling window
STEP = 1.0 # slide
REF = 25.0 # reference window taken from deep inside each track
ACTIVE_DB = -50.0 # an orbit must be audible to get a vote
def mmss(t):
return f"{int(t) // 60}:{int(t) % 60:02d}"
def timbral(a, b):
"""Distance between two spectral profiles: band energies + log centroid.
Bands carry the sound's shape, centroid its register. Both are needed —
two different samples can share a centroid, and a filter sweep moves the
centroid without changing the sample.
"""
if a is None or b is None:
return None
ba = np.array([a["bands"][k] for k in sorted(a["bands"])])
bb = np.array([b["bands"][k] for k in sorted(b["bands"])])
band = np.abs(ba - bb).sum() / 200.0 # 0..1
ca, cb = max(a["centroid"], 1.0), max(b["centroid"], 1.0)
cen = min(abs(np.log2(ca / cb)) / 4.0, 1.0) # 0..1, 4 octaves
return float(0.65 * band + 0.35 * cen)
def stem_time(t, keeps):
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]
def orbit_ref(path, t0, t1):
try:
sig = load_window(path, t0, t1)
return profile(sig) if sig.size else None
except Exception:
return None
def profile_slices(path, t0, span, grid_rel, win, sr=44100):
"""Profiles at many offsets from ONE decode.
The first version called ffmpeg per window: 14 boundaries x 12 orbits x ~90
windows ~= 15k process spawns, which is 25 minutes of `execve` and about
four seconds of arithmetic. Decode the span once, slice it in numpy.
"""
try:
sig = load_window(path, t0, t0 + span)
except Exception:
return [None] * len(grid_rel)
out = []
n = int(win * sr)
for rel in grid_rel:
a = int(rel * sr)
seg = sig[a:a + n]
out.append(profile(seg) if seg.size >= n // 2 else None)
return out
def analyse_boundary(stems, keeps, seg_prev, seg_next, verbose=False):
"""Estimated true boundary (master time) between two segments."""
nominal = seg_next["start"]
lo = max(seg_prev["start"] + 5, nominal - SEARCH)
hi = min(seg_next["end"] - 5, nominal + SEARCH)
# References from the CALM middle of each track, well clear of both edges.
pm = (seg_prev["start"] + seg_prev["end"]) / 2
nm = (seg_next["start"] + seg_next["end"]) / 2
votes, weights = [], []
grid = np.arange(lo, hi - WIN, STEP)
for path in stems:
rp = orbit_ref(path, stem_time(pm, keeps), stem_time(pm, keeps) + REF)
rn = orbit_ref(path, stem_time(nm, keeps), stem_time(nm, keeps) + REF)
if rp is None or rn is None:
continue
# An orbit that is silent in either track cannot discriminate, and an
# orbit whose two references are identical (same sample both sides)
# carries no information about where the switch happened.
if max(rp["rms_db"], rn["rms_db"]) < ACTIVE_DB:
continue
sep = timbral(rp, rn)
if sep is None or sep < 0.08:
continue
# One decode across the whole search span. `keeps` only matters if a cut
# falls inside the window; none of OPAL-26's boundaries do, and the
# offset is checked below.
st0 = stem_time(float(grid[0]), keeps)
if abs(stem_time(float(grid[-1]), keeps) - (st0 + (grid[-1] - grid[0]))) > 0.01:
raise SystemExit("a master edit falls inside a boundary search window — "
"decode-once is invalid there; narrow SEARCH")
profs = profile_slices(path, st0, float(grid[-1] - grid[0]) + WIN,
[float(t - grid[0]) for t in grid], WIN)
series = []
for p in profs:
if p is None or p["rms_db"] < ACTIVE_DB:
series.append(np.nan)
continue
dp, dn = timbral(p, rp), timbral(p, rn)
series.append(dp - dn) # >0 == sounds like the NEXT track
series = np.array(series, dtype=float)
if np.all(np.isnan(series)):
continue
# First sustained crossing into "sounds like next", requiring it to
# STAY there — a single window flipping is a hi-hat, not a handover.
need = int(6 / STEP)
cross = None
for i in range(len(series) - need):
w = series[i:i + need]
if np.nanmean(w > 0) >= 0.8 and np.nansum(~np.isnan(w)) >= need * 0.6:
cross = float(grid[i])
break
if cross is not None:
votes.append(cross)
weights.append(sep)
if verbose:
print(f" {path.name[-6:]:>6} sep={sep:.2f} crosses at {mmss(cross)}")
if not votes:
return None, 0, []
votes = np.array(votes)
weights = np.array(weights)
est = float(np.average(votes, weights=weights))
return est, len(votes), votes.tolist()
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--spec", required=True)
ap.add_argument("--truth", help="JSON {track_number: true_start_seconds} from PLN's ear")
ap.add_argument("--out", help="write proposed segments JSON")
ap.add_argument("-v", "--verbose", action="store_true")
a = ap.parse_args()
spec = json.loads(Path(a.spec).read_text())
segs = json.loads(Path(spec["segments"]).read_text()) if spec.get("segments") else \
json.loads((Path(spec["clipsDir"]).parent / "segments_v3.json").read_text())
keeps = spec["keeps"]
stems = sorted(Path(spec["stemsDir"]).glob(spec.get("stemsGlob", "*.wav")))
truth = {int(k): float(v) for k, v in json.loads(Path(a.truth).read_text()).items()} if a.truth else {}
print(f"cut-lens over {len(segs)} segments, {len(stems)} orbits\n")
print(f"{'#':>2} {'title':<30} {'nominal':>9} {'proposed':>9} {'shift':>8} {'n':>3} {'truth':>9} {'err':>7}")
errs, out = [], []
for i in range(1, len(segs)):
prev, nxt = segs[i - 1], segs[i]
if a.verbose:
print(f"\n -- boundary into #{nxt['track']} {nxt['title']}")
est, n, _ = analyse_boundary(stems, keeps, prev, nxt, a.verbose)
row = f"{nxt['track']:>2} {nxt['title'][:30]:<30} {nxt['start']:>9.1f}"
if est is None:
print(row + f" {'—':>9} {'—':>8} {0:>3}")
continue
shift = est - nxt["start"]
row += f" {est:>9.1f} {shift:>+8.1f} {n:>3}"
if nxt["track"] in truth:
t = truth[nxt["track"]]
err = est - t
errs.append(abs(err))
row += f" {t:>9.1f} {err:>+7.1f}"
print(row)
out.append({"track": nxt["track"], "title": nxt["title"],
"nominal": nxt["start"], "proposed": round(est, 2)})
if errs:
print(f"\nVALIDATION against {len(errs)} ear-verified boundaries: "
f"median |err| = {np.median(errs):.1f}s, worst = {max(errs):.1f}s")
print("A lens that cannot reproduce the known answers must not be trusted "
"on the unknown ones.")
if a.out:
Path(a.out).write_text(json.dumps(out, indent=2))
print(f"\n✓ {a.out}")
return 0
if __name__ == "__main__":
sys.exit(main())
{
"_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