/ proof-of-concept / audio-spike-sorting
Sign in
proof-of-concept / audio-spike-sorting
Play recorded clips in the clip editor, via a Player shared with the explorer
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 3b66b1dc9101 parent d6b5949 Browse files
4 changed files+105−47
index.htmlmodified+1−0View file
@@ -97,6 +97,7 @@
9797 </div>
9898 <canvas id="clip-wave" class="wave"></canvas>
9999 <div class="bar">
100+ <button id="clip-play">&#9654; Play</button>
100101 <label>sensitivity <input id="clip-sens" type="range" min="0" max="1" step="0.01" value="0.6" /></label>
101102 <button id="clip-exclude" disabled>Exclude</button>
102103 <span id="clip-count" class="hint"></span>
src/audio/player.tsadded+58−0View file
@@ -0,0 +1,58 @@
1+/**
2+ * One-shot playback of a sample range with a playhead callback, shared by
3+ * the explorer (runs) and the clip editor (recorded clips). Only one
4+ * range plays per Player; starting again stops what was playing.
5+ */
6+export class Player {
7+ private ctx: () => AudioContext;
8+ private cur: { src: AudioBufferSourceNode; ctxT0: number; startT: number; endT: number; raf: number } | null = null;
9+ /** Playhead position in seconds while playing, then null once stopped. */
10+ onTick: ((t: number | null) => void) | null = null;
11+ /** Fired on every start and stop, for button labels. */
12+ onStateChange: (() => void) | null = null;
13+
14+ constructor(ctx: () => AudioContext) {
15+ this.ctx = ctx;
16+ }
17+
18+ get playing(): boolean {
19+ return this.cur !== null;
20+ }
21+
22+ stop(): void {
23+ if (!this.cur) return;
24+ cancelAnimationFrame(this.cur.raf);
25+ try { this.cur.src.stop(); } catch { /* already ended */ }
26+ this.cur = null;
27+ this.onTick?.(null);
28+ this.onStateChange?.();
29+ }
30+
31+ /** Play samples[startT*sr .. endT*sr); times are in seconds. */
32+ start(samples: Float32Array, sr: number, startT: number, endT: number): void {
33+ this.stop();
34+ const s0 = Math.max(0, Math.round(startT * sr));
35+ const s1 = Math.min(samples.length, Math.round(endT * sr));
36+ if (s1 - s0 < 1) return;
37+ const ctx = this.ctx();
38+ const buf = ctx.createBuffer(1, s1 - s0, sr);
39+ buf.getChannelData(0).set(samples.subarray(s0, s1));
40+ const src = ctx.createBufferSource();
41+ src.buffer = buf;
42+ src.connect(ctx.destination);
43+ src.onended = () => { if (this.cur?.src === src) this.stop(); };
44+ const begin = () => {
45+ src.start();
46+ this.cur = { src, ctxT0: ctx.currentTime, startT, endT, raf: 0 };
47+ const tick = () => {
48+ if (this.cur?.src !== src) return;
49+ this.onTick?.(Math.min(this.cur.endT, this.cur.startT + (ctx.currentTime - this.cur.ctxT0)));
50+ this.cur.raf = requestAnimationFrame(tick);
51+ };
52+ this.cur.raf = requestAnimationFrame(tick);
53+ this.onStateChange?.();
54+ };
55+ if (ctx.state === 'suspended') void ctx.resume().then(begin);
56+ else begin();
57+ }
58+}
src/main.tsmodified+28−47View file
@@ -6,6 +6,7 @@
66 * one algorithm's detections, with every error visible and auditionable).
77 */
88 import { Recorder, type Clip } from './audio/recorder.ts';
9+import { Player } from './audio/player.ts';
910 import { detectOnsets } from './analysis/onsets.ts';
1011 import { BUILTIN_ALGORITHMS, NEW_ALGORITHM_TEMPLATE } from './algorithms/index.ts';
1112 import { extractLibrary, snippetCounts } from './bench/snippets.ts';
@@ -202,6 +203,18 @@ async function stopRecording(): Promise<void> {
202203
203204 const waveView = new WaveView($('clip-wave') as HTMLCanvasElement);
204205
206+const clipPlayer = new Player(audio);
207+clipPlayer.onTick = (t) => waveView.setPlayhead(t);
208+clipPlayer.onStateChange = () => {
209+ $('clip-play').innerHTML = clipPlayer.playing ? '&#9632; Stop' : '&#9654; Play';
210+};
211+$('clip-play').onclick = () => {
212+ if (clipPlayer.playing) { clipPlayer.stop(); return; }
213+ const cur = currentClip();
214+ if (!cur) return;
215+ clipPlayer.start(cur.a.samples, cur.a.sr, 0, cur.a.samples.length / cur.a.sr);
216+};
217+
205218 function currentClip() {
206219 if (!edit) return null;
207220 const type = typeById(edit.typeId);
@@ -216,6 +229,7 @@ function markersOf(meta: { onsets: number[]; excluded: number[] }): Marker[] {
216229 }
217230
218231 function openClipEditor(typeId: string, clipId: string): void {
232+ clipPlayer.stop();
219233 edit = { typeId, clipId };
220234 const cur = currentClip();
221235 if (!cur) { closeClipEditor(); return; }
@@ -236,6 +250,7 @@ function openClipEditor(typeId: string, clipId: string): void {
236250 }
237251
238252 function closeClipEditor(): void {
253+ clipPlayer.stop();
239254 edit = null;
240255 $('clip-editor').hidden = true;
241256 }
@@ -630,62 +645,28 @@ function currentExplorerRun() {
630645
631646 // ---- run playback ----------------------------------------------------------
632647
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-}
648+const runPlayer = new Player(audio);
649+runPlayer.onTick = (t) => explorer.setPlayhead(t);
650+runPlayer.onStateChange = () => updatePlayButton();
676651
677652 function updatePlayButton(): void {
678653 const btn = $('explore-play') as HTMLButtonElement;
679654 btn.disabled = !currentExplorerRun();
680- if (playing) {
655+ if (runPlayer.playing) {
681656 btn.innerHTML = '&#9632; Stop';
682657 } else {
683658 btn.innerHTML = explorer.getSelection() ? '&#9654; Play selection' : '&#9654; Play';
684659 }
685660 }
686661
687-$('explore-play').onclick = () => (playing ? stopPlayback() : startPlayback());
688-explorer.onSelectionChange = () => { if (!playing) updatePlayButton(); };
662+$('explore-play').onclick = () => {
663+ if (runPlayer.playing) { runPlayer.stop(); return; }
664+ const run = currentExplorerRun();
665+ if (!run) return;
666+ const sel = explorer.getSelection();
667+ runPlayer.start(run.x, FS, sel ? sel.t0 : 0, sel ? sel.t1 : run.x.length / FS);
668+};
669+explorer.onSelectionChange = () => { if (!runPlayer.playing) updatePlayButton(); };
689670
690671 function populateExplorerControls(): void {
691672 const runSel = $('explore-run') as HTMLSelectElement;
@@ -709,7 +690,7 @@ function populateExplorerControls(): void {
709690 }
710691
711692 function updateExplorer(): void {
712- stopPlayback();
693+ runPlayer.stop();
713694 if (!bench) { explorer.set(null); updatePlayButton(); return; }
714695 const ri = Number(($('explore-run') as HTMLSelectElement).value || '0');
715696 const algoId = ($('explore-algo') as HTMLSelectElement).value;
src/ui/waveform.tsmodified+18−0View file
@@ -22,6 +22,7 @@ export class WaveView {
2222 private markers: Marker[] = [];
2323 private color = '#e4573d';
2424 private band: { x0: number; x1: number; base: Set<number>; moved: boolean; additive: boolean } | null = null;
25+ private playT: number | null = null;
2526 selection = new Set<number>();
2627 onAudition: ((index: number) => void) | null = null;
2728 onSelectionChange: (() => void) | null = null;
@@ -48,6 +49,12 @@ export class WaveView {
4849 this.draw();
4950 }
5051
52+ /** Playback cursor position in seconds; null hides it. */
53+ setPlayhead(t: number | null): void {
54+ this.playT = t;
55+ this.draw();
56+ }
57+
5158 private markerX(i: number, w: number): number {
5259 const dur = this.clip ? this.clip.samples.length / this.clip.sr : 1;
5360 return (this.markers[i].t / dur) * w;
@@ -170,6 +177,17 @@ export class WaveView {
170177 g.globalAlpha = 1;
171178 }
172179
180+ if (this.playT !== null && this.clip) {
181+ const dur = this.clip.samples.length / this.clip.sr;
182+ const x = (this.playT / dur) * w;
183+ g.strokeStyle = 'rgba(61, 187, 110, 0.9)';
184+ g.lineWidth = 1.5;
185+ g.beginPath();
186+ g.moveTo(x, 0);
187+ g.lineTo(x, h);
188+ g.stroke();
189+ }
190+
173191 if (this.band) {
174192 const lo = Math.min(this.band.x0, this.band.x1);
175193 g.fillStyle = 'rgba(120, 170, 255, 0.15)';
↑↓ move↵openescclose