/ proof-of-concept / audio-spike-sorting
Sign in
proof-of-concept / audio-spike-sorting
Explorer playback: play the run or a shift-drag selected interval, with a tracking playhead
Jeremy Magland <jmagland@flatironinstitute.org> committed commit d6b5949a3d93 parent 21abc12 Browse files
4 changed files+138−7
README.mdmodified+4−1View file
@@ -61,7 +61,10 @@ above the waveform and one algorithm's detections below it. A correct
6161 detection is a filled marker in its type's color, a misclassification is
6262 ringed in red, a false positive is a red cross, and a missed truth event
6363 is a hollow circle. The wheel zooms, dragging pans, and a click auditions
64-the audio at that time.
64+the audio at that time. The whole recording can also be played through,
65+with a playhead tracking the sound: shift-drag selects an interval and
66+the play button then plays just that selection, which is the quickest way
67+to listen closely to a cluster of errors; a shift-click clears it.
6568
6669 ## Baseline algorithms
6770
index.htmlmodified+2−1View file
@@ -136,10 +136,11 @@
136136 </div>
137137 <div id="results"></div>
138138
139- <h2>Explore <span class="cost">wheel zooms, drag pans, click auditions</span></h2>
139+ <h2>Explore <span class="cost">wheel zooms &middot; drag pans &middot; click auditions &middot; shift-drag selects an interval</span></h2>
140140 <div class="bar">
141141 <label>run <select id="explore-run"></select></label>
142142 <label>algorithm <select id="explore-algo"></select></label>
143+ <button id="explore-play" disabled>&#9654; Play</button>
143144 <span id="explore-hover" class="hint"></span>
144145 </div>
145146 <canvas id="explorer" class="explorer"></canvas>
src/main.tsmodified+63−2View file
@@ -628,6 +628,65 @@ function currentExplorerRun() {
628628 return bench.runs[ri] ?? null;
629629 }
630630
631+// ---- run playback ----------------------------------------------------------
632+
633+let playing: { src: AudioBufferSourceNode; ctxT0: number; startT: number; endT: number; raf: number } | null = null;
634+
635+function stopPlayback(): void {
636+ if (!playing) return;
637+ cancelAnimationFrame(playing.raf);
638+ try { playing.src.stop(); } catch { /* already ended */ }
639+ playing = null;
640+ explorer.setPlayhead(null);
641+ updatePlayButton();
642+}
643+
644+function startPlayback(): void {
645+ const run = currentExplorerRun();
646+ if (!run) return;
647+ const dur = run.x.length / FS;
648+ const sel = explorer.getSelection();
649+ const startT = sel ? sel.t0 : 0;
650+ const endT = sel ? sel.t1 : dur;
651+ const s0 = Math.max(0, Math.round(startT * FS));
652+ const s1 = Math.min(run.x.length, Math.round(endT * FS));
653+ if (s1 - s0 < 1) return;
654+
655+ const ctx = audio();
656+ const buf = ctx.createBuffer(1, s1 - s0, FS);
657+ buf.getChannelData(0).set(run.x.subarray(s0, s1));
658+ const src = ctx.createBufferSource();
659+ src.buffer = buf;
660+ src.connect(ctx.destination);
661+ src.onended = () => { if (playing?.src === src) stopPlayback(); };
662+ const begin = () => {
663+ src.start();
664+ playing = { src, ctxT0: ctx.currentTime, startT, endT, raf: 0 };
665+ const tick = () => {
666+ if (!playing || playing.src !== src) return;
667+ explorer.setPlayhead(Math.min(playing.endT, playing.startT + (ctx.currentTime - playing.ctxT0)));
668+ playing.raf = requestAnimationFrame(tick);
669+ };
670+ playing.raf = requestAnimationFrame(tick);
671+ updatePlayButton();
672+ };
673+ if (ctx.state === 'suspended') void ctx.resume().then(begin);
674+ else begin();
675+}
676+
677+function updatePlayButton(): void {
678+ const btn = $('explore-play') as HTMLButtonElement;
679+ btn.disabled = !currentExplorerRun();
680+ if (playing) {
681+ btn.innerHTML = '&#9632; Stop';
682+ } else {
683+ btn.innerHTML = explorer.getSelection() ? '&#9654; Play selection' : '&#9654; Play';
684+ }
685+}
686+
687+$('explore-play').onclick = () => (playing ? stopPlayback() : startPlayback());
688+explorer.onSelectionChange = () => { if (!playing) updatePlayButton(); };
689+
631690 function populateExplorerControls(): void {
632691 const runSel = $('explore-run') as HTMLSelectElement;
633692 const algoSel = $('explore-algo') as HTMLSelectElement;
@@ -650,12 +709,13 @@ function populateExplorerControls(): void {
650709 }
651710
652711 function updateExplorer(): void {
653- if (!bench) { explorer.set(null); return; }
712+ stopPlayback();
713+ if (!bench) { explorer.set(null); updatePlayButton(); return; }
654714 const ri = Number(($('explore-run') as HTMLSelectElement).value || '0');
655715 const algoId = ($('explore-algo') as HTMLSelectElement).value;
656716 const run = bench.runs[ri];
657717 const result = bench.results.find((r) => r.runIndex === ri && r.algorithmId === algoId);
658- if (!run || !result) { explorer.set(null); return; }
718+ if (!run || !result) { explorer.set(null); updatePlayButton(); return; }
659719 explorer.set({
660720 x: run.x,
661721 fs: FS,
@@ -663,6 +723,7 @@ function updateExplorer(): void {
663723 colors: bench.colors,
664724 events: result.score.events,
665725 });
726+ updatePlayButton();
666727 }
667728
668729 ($('explore-run') as HTMLSelectElement).onchange = updateExplorer;
src/ui/explorer.tsmodified+69−3View file
@@ -6,7 +6,8 @@
66 * with a red ring, a false positive is a red cross, and a missed truth
77 * event is a hollow red-edged circle in the truth lanes. Wheel zooms
88 * around the cursor, dragging pans, and a click auditions the audio
9- * around that time.
9+ * around that time. Shift-drag selects an interval (shown highlighted) for
10+ * the owner's playback control; a playhead line tracks playback.
1011 */
1112 import type { ScoredEvent } from '../bench/types.ts';
1213
@@ -31,8 +32,12 @@ export class Explorer {
3132 private t0 = 0;
3233 private t1 = 1;
3334 private drag: { x: number; t0: number; t1: number; moved: boolean } | null = null;
35+ private select: { tAnchor: number; moved: boolean } | null = null;
36+ private selection: { t0: number; t1: number } | null = null;
37+ private playT: number | null = null;
3438 onAudition: ((t: number) => void) | null = null;
3539 onHover: ((text: string) => void) | null = null;
40+ onSelectionChange: (() => void) | null = null;
3641
3742 constructor(canvas: HTMLCanvasElement) {
3843 this.canvas = canvas;
@@ -46,10 +51,24 @@ export class Explorer {
4651 set(data: ExplorerData | null): void {
4752 this.data = data;
4853 if (data) { this.t0 = 0; this.t1 = data.x.length / data.fs; }
54+ this.selection = null;
55+ this.playT = null;
56+ this.onSelectionChange?.();
4957 this.canvas.style.height = `${this.height()}px`;
5058 this.draw();
5159 }
5260
61+ /** The selected interval in seconds, or null. */
62+ getSelection(): { t0: number; t1: number } | null {
63+ return this.selection;
64+ }
65+
66+ /** Playback cursor position in seconds; null hides it. */
67+ setPlayhead(t: number | null): void {
68+ this.playT = t;
69+ this.draw();
70+ }
71+
5372 private height(): number {
5473 const n = this.data ? this.data.names.length : 0;
5574 return n * LANE_H * 2 + WAVE_H + AXIS_H + 8;
@@ -92,7 +111,11 @@ export class Explorer {
92111 private pointerDown(e: PointerEvent): void {
93112 if (!this.data) return;
94113 const x = e.clientX - this.canvas.getBoundingClientRect().left;
95- this.drag = { x, t0: this.t0, t1: this.t1, moved: false };
114+ if (e.shiftKey) {
115+ this.select = { tAnchor: this.xToT(x), moved: false };
116+ } else {
117+ this.drag = { x, t0: this.t0, t1: this.t1, moved: false };
118+ }
96119 this.canvas.setPointerCapture(e.pointerId);
97120 }
98121
@@ -100,6 +123,17 @@ export class Explorer {
100123 if (!this.data) return;
101124 const rect = this.canvas.getBoundingClientRect();
102125 const x = e.clientX - rect.left;
126+ if (this.select) {
127+ const t = this.xToT(x);
128+ if (Math.abs(this.tToX(this.select.tAnchor) - x) > 3) this.select.moved = true;
129+ this.selection = {
130+ t0: Math.max(0, Math.min(this.select.tAnchor, t)),
131+ t1: Math.min(this.duration(), Math.max(this.select.tAnchor, t)),
132+ };
133+ this.onSelectionChange?.();
134+ this.draw();
135+ return;
136+ }
103137 if (this.drag) {
104138 const w = rect.width - LABEL_W;
105139 const dt = ((this.drag.x - x) / Math.max(w, 1)) * (this.drag.t1 - this.drag.t0);
@@ -115,10 +149,18 @@ export class Explorer {
115149 }
116150
117151 private pointerUp(e: PointerEvent): void {
152+ if (this.canvas.hasPointerCapture(e.pointerId)) this.canvas.releasePointerCapture(e.pointerId);
153+ if (this.select) {
154+ // a shift-click that never became a drag clears the selection
155+ if (!this.select.moved) this.selection = null;
156+ this.select = null;
157+ this.onSelectionChange?.();
158+ this.draw();
159+ return;
160+ }
118161 if (!this.drag) return;
119162 const moved = this.drag.moved;
120163 this.drag = null;
121- if (this.canvas.hasPointerCapture(e.pointerId)) this.canvas.releasePointerCapture(e.pointerId);
122164 if (!moved) {
123165 const x = e.clientX - this.canvas.getBoundingClientRect().left;
124166 if (x > LABEL_W) this.onAudition?.(this.xToT(x));
@@ -269,6 +311,30 @@ export class Explorer {
269311 }
270312 }
271313
314+ // selected interval
315+ if (this.selection) {
316+ const x0 = Math.max(LABEL_W, this.tToX(this.selection.t0));
317+ const x1 = Math.min(w, this.tToX(this.selection.t1));
318+ if (x1 > x0) {
319+ g.fillStyle = 'rgba(120, 170, 255, 0.13)';
320+ g.fillRect(x0, 0, x1 - x0, axisTop);
321+ g.strokeStyle = 'rgba(120, 170, 255, 0.55)';
322+ g.lineWidth = 1;
323+ g.strokeRect(x0 + 0.5, 0.5, x1 - x0 - 1, axisTop - 1);
324+ }
325+ }
326+
327+ // playhead
328+ if (this.playT !== null && this.playT >= this.t0 && this.playT <= this.t1) {
329+ const x = this.tToX(this.playT);
330+ g.strokeStyle = 'rgba(61, 187, 110, 0.9)';
331+ g.lineWidth = 1.5;
332+ g.beginPath();
333+ g.moveTo(x, 0);
334+ g.lineTo(x, axisTop);
335+ g.stroke();
336+ }
337+
272338 // time axis
273339 g.fillStyle = 'rgba(255,255,255,0.5)';
274340 g.font = '10px system-ui';
↑↓ move↵openescclose