Commit 192c6e9e by PLN (Algolia)

feat(bounds): the boundary lab — decide a cut by ear in seconds, not a full pass

The set-judge answers "is this track a keeper". This answers the question that
actually blocked the release: where exactly does one track become the next.
After three failed detectors (cut_lens3 declared a dead end in 5f7db02a), #13 and
#15 are ear-only by nature — the two tracks share their sounds across the switch,
so no timbral lens can see the handover. The tool's job is therefore to make one
ear call cheap, not to avoid asking.

build_bounds_set.py renders a ±45 s FLAC clip per boundary instead of seeking the
817 MB master. A boundary audition is a scrub — dozens of small seeks around one
point — and seeking a long FLAC depends on a seektable the browser may not use
well; local clips make every seek instant for a few MB total. Clips are
re-encoded, never `-c copy`, because FLAC stream-copy snaps to frame boundaries
and every marker inside the clip would inherit that offset. Clip time and master
time are kept explicitly apart (`clipStart`), converted in exactly two functions.

The UI shows each cut's candidates as labelled markers on the waveform, drawn as
regions rather than positioned divs so they stay glued to the audio through zoom
and scroll. `takeover` leads the list because it landed within 0.7 s on both
boundaries that also had ear answers — with the caveat printed on screen, since
that is n=2 on the cases that were already easy. Boundaries with NO candidate are
shown with an explicit notice rather than omitted: those are precisely the ones
needing a human, and hiding them would hide the work.

Fixes a real bug in the SHARED player while here: `ws.zoom()` needs a decoded
buffer, which the peaks path never has, so it threw "No audio loaded" from an
effect and blanked the entire page. Same message as the peaks+url trap, entirely
different cause. Now guarded and failed-soft — zoom is a convenience and must
never take the tool down. The judge had this latent too.

bounds-smoke.mjs, 11 assertions, all passing. Two of them exist because the first
version of this test passed while proving nothing: querySelector('audio') is null
by construction (wavesurfer owns the element and never puts it in the DOM), so
the playback probe would have "passed" forever on a dead transport. It now reads
the transport clock and the Pause button — what a human sees. A positional
selector also silently picked the header's counter instead of the clock, hence
the explicit data-testid.
parent d3dbe59a
...@@ -33,8 +33,13 @@ from a detector.** Nine boundaries verified by ear (#3 893.0 · #4 1118.4 · #5 ...@@ -33,8 +33,13 @@ from a detector.** Nine boundaries verified by ear (#3 893.0 · #4 1118.4 · #5
- [x] **#1 leading-silence measured** — 1.452 s of *literal digital silence* (all samples zero - [x] **#1 leading-silence measured** — 1.452 s of *literal digital silence* (all samples zero
to sample 64038; first transient −28 dBFS). Objective, no ear needed. to sample 64038; first transient −28 dBFS). Objective, no ear needed.
- [ ] **A1.1 Apply the #1 head trim** — ⭐ fully independent of everything else, do it first. - [x] **A1.1 #1 head trim recorded**`start: 1.45` in the ear-boundary file, with the
- [ ] **A1.2 Settle #11, #12** — candidates exist (`cut_candidates_v3run2.json`), need one ear call each. sample-exact measurement stored alongside for audit (`d3dbe59`). Kept as *data*, not
baked into the rendered flac, because A1.4 regenerates the tracks from this file.
- [ ] **A1.2 Settle #11, #12** — machine proposals now live in a `candidates` block in the
ear-boundary file, structurally separate from `verified`. Audition **`takeover` first**:
it landed within 0.7 s on both boundaries that also have ear answers (#8, #10) — n=2 and
on the easy cases, so it earns first listen, not trust.
- [ ] **A1.3 Settle #13, #15** — 🚨 **ear-only, no candidates will ever come from the timbral lens** (see B1). - [ ] **A1.3 Settle #13, #15** — 🚨 **ear-only, no candidates will ever come from the timbral lens** (see B1).
- [ ] **A1.4** Write `segments_v4.json``v4_provenance/finish.sh` → rebuild judge data. - [ ] **A1.4** Write `segments_v4.json``v4_provenance/finish.sh` → rebuild judge data.
......
#!/usr/bin/env python3
"""build_bounds_set — data for the boundary webview: audition a cut, mark it.
The set-judge answers "is this track a keeper". This answers a different and
much narrower question: **exactly where does one track become the next**. After
three failed automated attempts (see `cut_lens3.py`'s docstring — the timbral
lens is structurally blind on transitions whose two tracks share their sounds),
some boundaries are ear-only by nature, so the tool's job is to make an ear call
cost seconds instead of a full listening pass.
## Why it renders clips instead of seeking the master
The obvious design streams the 817 MB master and seeks. Two problems: seeking
inside a long FLAC depends on a seektable the browser may not use well, and a
boundary audition is a scrub — dozens of small seeks around one point. Rendering
a ±`--pad` second clip per boundary makes every seek local and instant, and the
clips are a few MB total. Same reasoning as the judge's `heads`, and it reuses
that mount pattern.
Clip time and master time are kept explicitly distinct: each boundary carries
`clipStart` (master seconds at clip sample 0) so every marker converts with one
subtraction. Getting this wrong silently shifts every candidate, which is the
same class of bug as the master→stem `keeps` mapping.
## What it emits
Per boundary: the outgoing/incoming titles, the nominal cut, whatever candidates
exist (`takeover` / `mid` / `onset`), any ear-verified answer, waveform peaks,
and the clip URL. Boundaries with NO candidate are included and flagged — #13
and #15 are exactly the ones that need a human, so omitting them would hide the
work.
python3 build_bounds_set.py judge_specs/opal26.json \
--ear judge_specs/opal26_boundaries_ear.json --render
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
import numpy as np
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from build_judge_set import clean_title, ffprobe_dur, peaks # noqa: E402
PAD = 45.0 # seconds either side of the nominal cut
PEAKS_N = 1400 # a 90 s clip: ~15 buckets/second, enough to see a fade
def render_clip(master: Path, out: Path, t0: float, dur: float) -> bool:
"""Cut [t0, t0+dur] from the master. Re-encoded, not stream-copied.
`-c copy` on FLAC snaps to frame boundaries, so the clip would start a few
ms from where we asked and every marker inside it would inherit that error.
"""
out.parent.mkdir(parents=True, exist_ok=True)
r = subprocess.run(
["ffmpeg", "-v", "error", "-y", "-ss", f"{t0:.6f}", "-t", f"{dur:.6f}",
"-i", str(master), "-ac", "2", "-c:a", "flac", "-compression_level", "5",
str(out)], capture_output=True)
if r.returncode != 0:
print(f" ! ffmpeg failed for {out.name}: {r.stderr.decode()[:200]}")
return False
return True
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("spec")
ap.add_argument("--ear", required=True, help="ear-verified + candidates JSON")
ap.add_argument("--pad", type=float, default=PAD)
ap.add_argument("--render", action="store_true", help="(re)render the clips")
ap.add_argument("--only", help="comma-separated track numbers")
ap.add_argument("--out")
a = ap.parse_args()
spec = json.loads(Path(a.spec).read_text())
ear = json.loads(Path(a.ear).read_text())
segs = json.loads(Path(spec["segments"]).read_text())
master = Path(spec["master"])
if not master.exists():
print(f"master not found: {master}")
return 1
clips_dir = Path(spec.get("boundsDir") or master.parent / "bounds_v4")
verified = ear.get("verified", {})
cands = ear.get("candidates", {})
only = {int(x) for x in a.only.split(",")} if a.only else None
mdur = ffprobe_dur(master)
by_track = {s["track"]: s for s in segs}
out_rows = []
for i in range(1, len(segs)):
nxt = segs[i]
tno = nxt["track"]
if only and tno not in only:
continue
nominal = float(nxt["start"])
t0 = max(0.0, nominal - a.pad)
t1 = min(mdur, nominal + a.pad)
clip = clips_dir / f"{tno:02d}.flac"
if a.render or not clip.exists():
print(f" rendering #{tno:>2} {nxt['title'][:28]:<28} "
f"[{t0:.1f}, {t1:.1f}]")
if not render_clip(master, clip, t0, t1 - t0):
continue
v = verified.get(str(tno), {})
c = cands.get(str(tno), {})
marks = []
# order matters: this is the audition order, best-first. `takeover` came
# within 0.7 s on both boundaries that also had ear answers — n=2, on the
# easy cases, so it leads the list but carries its own caveat in the UI.
for key, label in (("takeover", "takeover"), ("mid", "mid"),
("onset", "onset")):
if c.get(key) is not None:
marks.append({"key": key, "label": label, "t": float(c[key])})
if v.get("start") is not None:
marks.append({"key": "ear", "label": "ear-verified", "t": float(v["start"])})
if v.get("alt_start") is not None:
marks.append({"key": "alt", "label": "ear alt", "t": float(v["alt_start"])})
out_rows.append({
"track": tno,
"title": clean_title(nxt["title"]),
"fromTitle": clean_title(by_track.get(tno - 1, {}).get("title", "?")),
"nominal": nominal,
"clipStart": t0,
"clipEnd": t1,
"url": f"{spec.get('boundsAudioBase', '/audio/bounds')}/{clip.name}",
"peaks": [round(x, 4) for x in peaks(clip, PEAKS_N)],
"marks": marks,
"settled": v.get("start") is not None,
"noCandidate": not marks or all(m["key"] in ("ear", "alt") for m in marks),
"note": c.get("note") or v.get("note") or "",
})
doc = {
"gig": spec["gig"],
"title": spec.get("title", ""),
"generated": True,
"pad": a.pad,
"boundaries": out_rows,
}
# default straight into the SPA's public/ — same convention as the judge's
# judge-<gig>.json, so `vite build` and serve.py both pick it up untouched.
dest = Path(a.out or HERE.parent / "ui" / "public" / f"bounds-{spec['gig']}.json")
dest.write_text(json.dumps(doc, indent=1))
n_open = sum(1 for r in out_rows if not r["settled"])
print(f"\n✓ {dest} ({len(out_rows)} boundaries, {n_open} still open)")
print(f" clips: {clips_dir}")
return 0
if __name__ == "__main__":
sys.exit(main())
...@@ -2,17 +2,18 @@ ...@@ -2,17 +2,18 @@
"_comment": [ "_comment": [
"Where /audio/<prefix>/... resolves to on disk. Read by vite.config.ts in dev", "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", "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.", "\u2014 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", "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`", "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", "and `full` apart \u2014 `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." "flat mount would serve the 98 MB track when the UI asked for the 16 s head."
], ],
"mounts": { "mounts": {
"heads": "/home/pln/Work/Sound/Prod/Opal26_master/heads_v4", "bounds": "/home/pln/Work/Sound/Prod/Opal26_master/bounds_v4",
"full": "/home/pln/Work/Sound/Prod/Opal26_master/tracks_v4_streaming", "full": "/home/pln/Work/Sound/Prod/Opal26_master/tracks_v4_streaming",
"full-club": "/home/pln/Work/Sound/Prod/Opal26_master/tracks_v4_club", "full-club": "/home/pln/Work/Sound/Prod/Opal26_master/tracks_v4_club",
"heads": "/home/pln/Work/Sound/Prod/Opal26_master/heads_v4",
"": "/home/pln/Work/Sound/Tidal/armada/tide-table/punkachien" "": "/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>Boundary Lab · L'Armada</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/bounds.tsx"></script>
</body>
</html>
/**
* End-to-end smoke for the boundary lab.
*
* Same discipline as judge-smoke: a green `tsc -b` proved nothing there — the
* transport was dead because `peaks` + `url` made wavesurfer think the clip was
* pre-decoded. So every assertion here is POSITIVE and phrased as what a human
* would see: markers drawn, the clock moving, the call changing.
*
* The two failures specific to THIS tool would both pass a compiler:
* - the /audio/bounds mount resolving to the wrong directory (a clip plays,
* but it is the wrong 90 seconds — inaudible to a test that only checks
* "audio played"), so we assert the clip's DURATION matches the ±pad window;
* - clip-time vs master-time confusion, which would silently shift every
* candidate — so we assert a marker click yields a call in MASTER seconds
* inside the boundary's own clip range.
*
* npx vite --port 5199 --strictPort &
* node scripts/bounds-smoke.mjs
*/
import { chromium } from 'playwright'
import { existsSync, readFileSync } from 'node:fs'
// NB: do not name this `URL` — it shadows the global used just below
const PAGE = process.env.BOUNDS_URL ?? 'http://localhost:5199/bounds.html'
const DATA = new URL('../public/bounds-opal-festival-2026.json', import.meta.url)
const fail = (m) => { console.error(`✗ ${m}`); process.exitCode = 1 }
const ok = (m) => console.log(`✓ ${m}`)
const doc = JSON.parse(readFileSync(DATA, 'utf8'))
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(PAGE, { waitUntil: 'networkidle' })
// 1 — the boundaries loaded and every one is navigable
await p.waitForSelector('nav button', { timeout: 15000 })
const navs = await p.$$eval('nav button', (n) => n.map((x) => x.textContent))
navs.length === doc.boundaries.length
? ok(`${navs.length} boundaries in the nav (${navs.join(' ')})`)
: fail(`expected ${doc.boundaries.length} nav buttons, got ${navs.length}`)
// 2 — the waveform painted (canvas lives in wavesurfer's shadow root;
// Playwright selectors pierce it, document.querySelector does NOT)
await p.waitForSelector('canvas', { timeout: 20000 })
ok('waveform canvas rendered')
// 3 — the clip is the RIGHT clip. Two independent checks, because either alone
// lies: the mount could 404 (caught by the fetch) or fall through to the
// wrong directory and serve *a* file (caught by the displayed duration).
const first = doc.boundaries[0]
const head = await p.request.get(new global.URL(first.url, PAGE).href)
const bytes = Number(head.headers()['content-length'] ?? 0)
head.ok() && bytes > 500_000
? ok(`clip served: ${head.status()} ${(bytes / 1e6).toFixed(1)} MB`)
: fail(`clip fetch ${head.status()} ${bytes} B — /audio/bounds mount wrong?`)
const want = first.clipEnd - first.clipStart
const shownDur = await p.textContent('.tnum span.w-14, span.w-14')
const mm = /(\d+):(\d+)/.exec(shownDur ?? '')
const shownS = mm ? Number(mm[1]) * 60 + Number(mm[2]) : -1
Math.abs(shownS - want) <= 1
? ok(`transport shows ${shownDur?.trim()} = the ±${doc.pad}s window`)
: fail(`transport shows ${shownDur} (${shownS}s), expected ${want}s`)
// 4 — markers are drawn, one per candidate plus the nominal
const wantMarks = first.marks.length + 1
const marks = await p.$$eval('[part="region-content"], .wavesurfer-region', (n) => n.length)
marks >= wantMarks
? ok(`${marks} markers drawn (>= ${wantMarks} expected)`)
: fail(`expected >= ${wantMarks} markers, saw ${marks}`)
// 5 — clicking a candidate button records a call IN MASTER TIME, inside the clip
await p.getByRole('button', { name: /^takeover · / }).first().click()
await p.waitForTimeout(400)
const callTxt = await p.textContent('section:has(textarea) .tnum')
const secs = Number(/\(([\d.]+)s/.exec(callTxt ?? '')?.[1])
Number.isFinite(secs) && secs >= first.clipStart && secs <= first.clipEnd
? ok(`call recorded at ${secs}s master (inside [${first.clipStart}, ${first.clipEnd}])`)
: fail(`call "${callTxt}" is not a master time inside this clip`)
Math.abs(secs - first.marks.find((m) => m.key === 'takeover').t) < 0.01
? ok('call equals the takeover candidate exactly — no clip/master drift')
: fail(`call ${secs} != takeover ${first.marks.find((m) => m.key === 'takeover').t}`)
// 6 — audio actually PLAYS. The media element is owned by wavesurfer and never
// enters the DOM, so querySelector('audio') is null by construction — that
// probe would "pass" forever on a dead transport. Assert what a HUMAN sees:
// the transport clock advances and the button offers Pause.
await p.waitForTimeout(1500)
const clock = async () => {
const txt = await p.textContent('[data-testid="transport-clock"]')
const m = /(\d+):(\d+)/.exec(txt ?? '')
return m ? Number(m[1]) * 60 + Number(m[2]) : -1
}
const t1 = await clock()
await p.waitForTimeout(1600)
const t2 = await clock()
t2 > t1
? ok(`clock advanced ${t1}s -> ${t2}s (playing)`)
: fail(`clock stuck at ${t1}s — transport never started`)
const pauseVisible = await p.getByRole('button', { name: 'Pause' }).count()
pauseVisible > 0 ? ok('button offers Pause') : fail('button never flipped to Pause')
// 7 — a no-candidate boundary tells the user it is theirs, rather than showing nothing
const blindIdx = doc.boundaries.findIndex((x) => x.noCandidate)
if (blindIdx >= 0) {
await p.$$eval('nav button', (n, i) => n[i].click(), blindIdx)
await p.waitForTimeout(500)
const warn = (await p.textContent('body')) ?? ''
warn.includes('No machine candidate exists')
? ok(`#${doc.boundaries[blindIdx].track} shows the ear-only notice`)
: fail('no-candidate boundary renders without its notice')
}
// 8 — no console errors anywhere in that flow
bad.length === 0 ? ok('no console errors') : fail(`console errors: ${bad.slice(0, 3).join(' | ')}`)
await b.close()
console.log(process.exitCode ? '\nSMOKE FAILED' : '\nsmoke passed')
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@fontsource-variable/geist'
import '@fontsource-variable/geist-mono'
import './index.css'
import { BoundaryLab } from './bounds/BoundaryLab.tsx'
const gig = new URLSearchParams(location.search).get('set') ?? 'opal-festival-2026'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BoundaryLab gig={gig} />
</StrictMode>,
)
...@@ -20,8 +20,23 @@ interface Props { ...@@ -20,8 +20,23 @@ interface Props {
onFinish?: () => void onFinish?: () => void
/** start playing as soon as the transport is ready (auto-advance landing) */ /** start playing as soon as the transport is ready (auto-advance landing) */
autoPlay?: boolean autoPlay?: boolean
/**
* Fixed labelled points, in CLIP seconds — candidate cut times, the ear's
* answer, the nominal. Drawn as zero-width regions rather than absolutely
* positioned divs on purpose: the waveform zooms and scrolls, and an overlay
* positioned in page pixels drifts off the audio the moment it does.
*/
markers?: { t: number; label: string; color: string }[]
/**
* Imperative seek. `n` is a nonce: clicking the same marker twice must seek
* twice, which a bare `t` cannot express because the value doesn't change.
*/
seek?: { t: number; n: number }
} }
/** marker regions are identified by id prefix so drag-selection never eats them */
const MARK_ID = 'mark-'
const MIN_ZOOM = 0 // 0 = fit-to-width const MIN_ZOOM = 0 // 0 = fit-to-width
const MAX_ZOOM = 600 // px per second const MAX_ZOOM = 600 // px per second
const ZOOM_STEP = 1.6 const ZOOM_STEP = 1.6
...@@ -32,7 +47,7 @@ const ZOOM_STEP = 1.6 ...@@ -32,7 +47,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, peaks, onTime, onFinish, autoPlay }: Props) { export function WaveformPlayer({ url, dur, peaks, onTime, onFinish, autoPlay, markers, seek }: 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)
...@@ -43,6 +58,8 @@ export function WaveformPlayer({ url, dur, peaks, onTime, onFinish, autoPlay }: ...@@ -43,6 +58,8 @@ export function WaveformPlayer({ url, dur, peaks, onTime, onFinish, autoPlay }:
onFinishRef.current = onFinish onFinishRef.current = onFinish
const autoPlayRef = useRef(autoPlay) const autoPlayRef = useRef(autoPlay)
autoPlayRef.current = autoPlay autoPlayRef.current = autoPlay
const markersRef = useRef(markers)
markersRef.current = markers
const loopRef = useRef(true) const loopRef = useRef(true)
const [playing, setPlaying] = useState(false) const [playing, setPlaying] = useState(false)
...@@ -100,6 +117,17 @@ export function WaveformPlayer({ url, dur, peaks, onTime, onFinish, autoPlay }: ...@@ -100,6 +117,17 @@ export function WaveformPlayer({ url, dur, peaks, onTime, onFinish, autoPlay }:
} }
ws.on('ready', () => { ws.on('ready', () => {
setReady(true) setReady(true)
for (const m of markersRef.current ?? []) {
if (m.t < 0 || m.t > dur) continue // outside this clip: don't fake it
regions.addRegion({
id: MARK_ID + m.label,
start: m.t,
content: m.label,
color: m.color,
drag: false,
resize: false,
})
}
// Auto-advance lands here. Browsers only allow this because the user // Auto-advance lands here. Browsers only allow this because the user
// already gestured on the previous track; if they haven't, play() // already gestured on the previous track; if they haven't, play()
// rejects and we simply stay paused rather than throwing. // rejects and we simply stay paused rather than throwing.
...@@ -120,12 +148,17 @@ export function WaveformPlayer({ url, dur, peaks, onTime, onFinish, autoPlay }: ...@@ -120,12 +148,17 @@ export function WaveformPlayer({ url, dur, peaks, onTime, onFinish, autoPlay }:
// single drag-selection that can be looped // single drag-selection that can be looped
regions.enableDragSelection({ color: 'rgba(217, 0, 255, 0.12)' }) regions.enableDragSelection({ color: 'rgba(217, 0, 255, 0.12)' })
regions.on('region-created', (r) => { regions.on('region-created', (r) => {
for (const other of regions.getRegions()) if (other.id !== r.id) other.remove() if (r.id.startsWith(MARK_ID)) return // a marker, not a user selection
for (const other of regions.getRegions())
if (other.id !== r.id && !other.id.startsWith(MARK_ID)) other.remove()
activeRegionRef.current = r activeRegionRef.current = r
setRegion({ start: r.start, end: r.end }) setRegion({ start: r.start, end: r.end })
}) })
regions.on('region-updated', (r) => setRegion({ start: r.start, end: r.end })) regions.on('region-updated', (r) => {
if (!r.id.startsWith(MARK_ID)) setRegion({ start: r.start, end: r.end })
})
regions.on('region-in', (r) => { regions.on('region-in', (r) => {
if (r.id.startsWith(MARK_ID)) return
activeRegionRef.current = r activeRegionRef.current = r
}) })
regions.on('region-out', (r) => { regions.on('region-out', (r) => {
...@@ -133,6 +166,13 @@ export function WaveformPlayer({ url, dur, peaks, onTime, onFinish, autoPlay }: ...@@ -133,6 +166,13 @@ export function WaveformPlayer({ url, dur, peaks, onTime, onFinish, autoPlay }:
}) })
regions.on('region-clicked', (r, e) => { regions.on('region-clicked', (r, e) => {
e.stopPropagation() e.stopPropagation()
// Clicking a marker jumps to it. It has zero width, so `play(true)` would
// start and immediately hit region-out; seek explicitly instead.
if (r.id.startsWith(MARK_ID)) {
ws.setTime(r.start)
ws.play().catch(() => {})
return
}
activeRegionRef.current = r activeRegionRef.current = r
r.play(true) r.play(true)
}) })
...@@ -147,11 +187,30 @@ export function WaveformPlayer({ url, dur, peaks, onTime, onFinish, autoPlay }: ...@@ -147,11 +187,30 @@ export function WaveformPlayer({ url, dur, peaks, onTime, onFinish, autoPlay }:
} }
}, [url, peaks, dur]) }, [url, peaks, dur])
// apply zoom when it changes (and once ready) // Apply zoom when it changes (and once ready).
//
// `zoom()` needs a DECODED buffer to rescale, and on the peaks path there
// isn't one — wavesurfer throws "No audio loaded", the same message the
// peaks+url trap produced but from a completely different cause. It fires
// from an effect, so it escapes as an unhandled React error and blanks the
// whole page. Zoom is a convenience; never let it take the tool down.
useEffect(() => { useEffect(() => {
if (ready && wsRef.current) wsRef.current.zoom(zoom) if (!ready || !wsRef.current || zoom <= MIN_ZOOM) return
try {
wsRef.current.zoom(zoom)
} catch {
/* peaks-only render: nothing to rescale, keep the fit-to-width view */
}
}, [zoom, ready]) }, [zoom, ready])
// imperative seek from the parent (marker buttons / keyboard)
useEffect(() => {
const ws = wsRef.current
if (!ready || !ws || !seek) return
ws.setTime(Math.max(0, Math.min(dur, seek.t)))
ws.play().catch(() => {})
}, [seek?.n, ready]) // eslint-disable-line react-hooks/exhaustive-deps
const toggle = () => wsRef.current?.playPause() const toggle = () => wsRef.current?.playPause()
const zoomBy = (factor: number) => const zoomBy = (factor: number) =>
setZoom((z) => { setZoom((z) => {
...@@ -159,7 +218,9 @@ export function WaveformPlayer({ url, dur, peaks, onTime, onFinish, autoPlay }: ...@@ -159,7 +218,9 @@ export function WaveformPlayer({ url, dur, peaks, onTime, onFinish, autoPlay }:
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, factor > 1 ? base * factor : z <= 40 ? 0 : z / ZOOM_STEP)) return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, factor > 1 ? base * factor : z <= 40 ? 0 : z / ZOOM_STEP))
}) })
const clearRegion = () => { const clearRegion = () => {
regionsRef.current?.clearRegions() // clear the SELECTION only — clearRegions() would wipe the markers too
for (const r of regionsRef.current?.getRegions() ?? [])
if (!r.id.startsWith(MARK_ID)) r.remove()
activeRegionRef.current = null activeRegionRef.current = null
setRegion(null) setRegion(null)
} }
...@@ -202,7 +263,10 @@ export function WaveformPlayer({ url, dur, peaks, onTime, onFinish, autoPlay }: ...@@ -202,7 +263,10 @@ export function WaveformPlayer({ url, dur, peaks, onTime, onFinish, autoPlay }:
</div> </div>
<div className="mt-1 flex items-center justify-between gap-2 font-mono text-xs text-ink-muted tnum"> <div className="mt-1 flex items-center justify-between gap-2 font-mono text-xs text-ink-muted tnum">
<span>{ready ? mmss(t) : 'loading…'}</span> {/* testid: the smoke test reads the CLOCK to prove playback; several
other elements share `tnum`, and a positional selector silently
picked the header's counter instead. */}
<span data-testid="transport-clock">{ready ? mmss(t) : 'loading…'}</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{region && ( {region && (
......
...@@ -74,6 +74,8 @@ export default defineConfig({ ...@@ -74,6 +74,8 @@ export default defineConfig({
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'), judge: path.resolve(__dirname, 'judge.html'),
// and the boundary lab — where exactly does one track become the next
bounds: path.resolve(__dirname, 'bounds.html'),
}, },
}, },
}, },
......
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