21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 1/**
2 * The dataset explorer: one synthesized recording with ground truth above
3 * the waveform and one algorithm's detections below it. Every event's
4 * fate is visible at a glance: a correct detection is a filled marker in
5 * its type's color, a misclassification is filled in the predicted color
6 * with a red ring, a false positive is a red cross, and a missed truth
7 * event is a hollow red-edged circle in the truth lanes. Wheel zooms
8 * around the cursor, dragging pans, and a click auditions the audio
d6b5949Explorer playback: play the run or a shift-drag selected interval, with a tracking playheadJeremy Magland 9 * around that time. Shift-drag selects an interval (shown highlighted) for
10 * the owner's playback control; a playhead line tracks playback.
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 11 */
12import type { ScoredEvent } from '../bench/types.ts';
14export interface ExplorerData {
15 x: Float32Array;
16 fs: number;
17 names: string[];
18 colors: string[];
19 events: ScoredEvent[];
7abfa1cExplorer: caption the ground-truth and detection bands so their roles are explicitJeremy Magland 20 /** Shown in the detections band caption. */
21 algoName?: string;
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 22}
24const BG = '#16181d';
25const WAVE = '#5b8dbf';
26const LANE_H = 18;
7abfa1cExplorer: caption the ground-truth and detection bands so their roles are explicitJeremy Magland 27const BAND_HEAD = 15;
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 28const WAVE_H = 110;
29const AXIS_H = 22;
30const LABEL_W = 64;
32export class Explorer {
33 private canvas: HTMLCanvasElement;
34 private data: ExplorerData | null = null;
35 private t0 = 0;
36 private t1 = 1;
37 private drag: { x: number; t0: number; t1: number; moved: boolean } | null = null;
d6b5949Explorer playback: play the run or a shift-drag selected interval, with a tracking playheadJeremy Magland 38 private select: { tAnchor: number; moved: boolean } | null = null;
39 private selection: { t0: number; t1: number } | null = null;
40 private playT: number | null = null;
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 41 onAudition: ((t: number) => void) | null = null;
42 onHover: ((text: string) => void) | null = null;
d6b5949Explorer playback: play the run or a shift-drag selected interval, with a tracking playheadJeremy Magland 43 onSelectionChange: (() => void) | null = null;
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 44
45 constructor(canvas: HTMLCanvasElement) {
46 this.canvas = canvas;
47 canvas.addEventListener('wheel', (e) => this.wheel(e), { passive: false });
48 canvas.addEventListener('pointerdown', (e) => this.pointerDown(e));
49 canvas.addEventListener('pointermove', (e) => this.pointerMove(e));
50 canvas.addEventListener('pointerup', (e) => this.pointerUp(e));
51 new ResizeObserver(() => this.draw()).observe(canvas);
52 }
54 set(data: ExplorerData | null): void {
55 this.data = data;
56 if (data) { this.t0 = 0; this.t1 = data.x.length / data.fs; }
d6b5949Explorer playback: play the run or a shift-drag selected interval, with a tracking playheadJeremy Magland 57 this.selection = null;
58 this.playT = null;
59 this.onSelectionChange?.();
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 60 this.canvas.style.height = `${this.height()}px`;
61 this.draw();
62 }
d6b5949Explorer playback: play the run or a shift-drag selected interval, with a tracking playheadJeremy Magland 64 /** The selected interval in seconds, or null. */
65 getSelection(): { t0: number; t1: number } | null {
66 return this.selection;
67 }
69 /** Playback cursor position in seconds; null hides it. */
70 setPlayhead(t: number | null): void {
71 this.playT = t;
72 this.draw();
73 }
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 75 private height(): number {
76 const n = this.data ? this.data.names.length : 0;
7abfa1cExplorer: caption the ground-truth and detection bands so their roles are explicitJeremy Magland 77 return 2 * BAND_HEAD + n * LANE_H * 2 + WAVE_H + AXIS_H + 8;
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 78 }
80 private duration(): number {
81 return this.data ? this.data.x.length / this.data.fs : 1;
82 }
84 private xToT(px: number): number {
85 const w = this.canvas.getBoundingClientRect().width - LABEL_W;
86 return this.t0 + ((px - LABEL_W) / Math.max(w, 1)) * (this.t1 - this.t0);
87 }
89 private tToX(t: number): number {
90 const w = this.canvas.getBoundingClientRect().width - LABEL_W;
91 return LABEL_W + ((t - this.t0) / (this.t1 - this.t0)) * w;
92 }
94 private wheel(e: WheelEvent): void {
95 if (!this.data) return;
96 e.preventDefault();
97 const t = this.xToT(e.clientX - this.canvas.getBoundingClientRect().left);
98 const f = Math.exp(e.deltaY * 0.002);
99 const span = Math.min(this.duration(), Math.max(0.02, (this.t1 - this.t0) * f));
100 const frac = (t - this.t0) / (this.t1 - this.t0);
101 this.t0 = t - frac * span;
102 this.t1 = this.t0 + span;
103 this.clampView();
104 this.draw();
105 }
107 private clampView(): void {
108 const dur = this.duration();
109 const span = this.t1 - this.t0;
110 if (this.t0 < 0) { this.t0 = 0; this.t1 = span; }
111 if (this.t1 > dur) { this.t1 = dur; this.t0 = Math.max(0, dur - span); }
112 }
114 private pointerDown(e: PointerEvent): void {
115 if (!this.data) return;
116 const x = e.clientX - this.canvas.getBoundingClientRect().left;
d6b5949Explorer playback: play the run or a shift-drag selected interval, with a tracking playheadJeremy Magland 117 if (e.shiftKey) {
118 this.select = { tAnchor: this.xToT(x), moved: false };
119 } else {
120 this.drag = { x, t0: this.t0, t1: this.t1, moved: false };
121 }
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 122 this.canvas.setPointerCapture(e.pointerId);
123 }
125 private pointerMove(e: PointerEvent): void {
126 if (!this.data) return;
127 const rect = this.canvas.getBoundingClientRect();
128 const x = e.clientX - rect.left;
d6b5949Explorer playback: play the run or a shift-drag selected interval, with a tracking playheadJeremy Magland 129 if (this.select) {
130 const t = this.xToT(x);
131 if (Math.abs(this.tToX(this.select.tAnchor) - x) > 3) this.select.moved = true;
132 this.selection = {
133 t0: Math.max(0, Math.min(this.select.tAnchor, t)),
134 t1: Math.min(this.duration(), Math.max(this.select.tAnchor, t)),
135 };
136 this.onSelectionChange?.();
137 this.draw();
138 return;
139 }
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 140 if (this.drag) {
141 const w = rect.width - LABEL_W;
142 const dt = ((this.drag.x - x) / Math.max(w, 1)) * (this.drag.t1 - this.drag.t0);
143 if (Math.abs(this.drag.x - x) > 3) this.drag.moved = true;
144 this.t0 = this.drag.t0 + dt;
145 this.t1 = this.drag.t1 + dt;
146 this.clampView();
147 this.draw();
148 return;
149 }
150 const hit = this.eventAt(x, e.clientY - rect.top);
151 this.onHover?.(hit ? this.describe(hit) : '');
152 }
154 private pointerUp(e: PointerEvent): void {
d6b5949Explorer playback: play the run or a shift-drag selected interval, with a tracking playheadJeremy Magland 155 if (this.canvas.hasPointerCapture(e.pointerId)) this.canvas.releasePointerCapture(e.pointerId);
156 if (this.select) {
157 // a shift-click that never became a drag clears the selection
158 if (!this.select.moved) this.selection = null;
159 this.select = null;
160 this.onSelectionChange?.();
161 this.draw();
162 return;
163 }
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 164 if (!this.drag) return;
165 const moved = this.drag.moved;
166 this.drag = null;
167 if (!moved) {
168 const x = e.clientX - this.canvas.getBoundingClientRect().left;
169 if (x > LABEL_W) this.onAudition?.(this.xToT(x));
170 }
171 }
173 private eventAt(px: number, _py: number): ScoredEvent | null {
174 if (!this.data) return null;
175 const tol = ((this.t1 - this.t0) / (this.canvas.getBoundingClientRect().width - LABEL_W)) * 6;
176 let best: ScoredEvent | null = null;
177 let bestDt = tol;
178 for (const ev of this.data.events) {
179 const t = ev.truth?.t ?? ev.det?.t;
180 if (t === undefined) continue;
181 const dt = Math.abs(t - this.xToT(px));
182 if (dt < bestDt) { best = ev; bestDt = dt; }
183 }
184 return best;
185 }
187 private describe(ev: ScoredEvent): string {
188 const d = this.data!;
189 const name = (i: number | undefined) => (i !== undefined && i >= 0 && i < d.names.length ? d.names[i] : '?');
190 const at = (t: number | undefined) => (t !== undefined ? `${t.toFixed(3)} s` : '?');
191 switch (ev.kind) {
192 case 'correct':
193 return `correct: ${name(ev.truth?.typeIndex)} at ${at(ev.truth?.t)} (detected ${at(ev.det?.t)})`;
194 case 'misclassified':
195 return `misclassified: ${name(ev.truth?.typeIndex)} at ${at(ev.truth?.t)} detected as ${name(ev.det?.typeIndex)}`;
196 case 'missed':
197 return `missed: ${name(ev.truth?.typeIndex)} at ${at(ev.truth?.t)} (gain ${ev.truth ? (20 * Math.log10(ev.truth.gain)).toFixed(1) : '?'} dB)`;
198 case 'falsePositive':
199 return `false positive: reported ${name(ev.det?.typeIndex)} at ${at(ev.det?.t)}`;
200 }
201 }
203 draw(): void {
204 const c = this.canvas;
205 const dpr = devicePixelRatio || 1;
206 const w = c.clientWidth, h = this.height();
207 if (w === 0) return;
208 if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) {
209 c.width = Math.round(w * dpr);
210 c.height = Math.round(h * dpr);
211 }
212 const g = c.getContext('2d')!;
213 g.setTransform(dpr, 0, 0, dpr, 0, 0);
214 g.fillStyle = BG;
215 g.fillRect(0, 0, w, h);
216 if (!this.data) {
217 g.fillStyle = 'rgba(255,255,255,0.4)';
218 g.font = '13px system-ui';
219 g.fillText('run a benchmark, then pick a run and an algorithm', 12, 24);
220 return;
221 }
222 const d = this.data;
223 const nTypes = d.names.length;
7abfa1cExplorer: caption the ground-truth and detection bands so their roles are explicitJeremy Magland 224 const truthTop = BAND_HEAD;
225 const waveTop = truthTop + nTypes * LANE_H;
226 const detTop = waveTop + WAVE_H + BAND_HEAD;
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 227 const axisTop = detTop + nTypes * LANE_H;
7abfa1cExplorer: caption the ground-truth and detection bands so their roles are explicitJeremy Magland 229 // band captions: what is above the waveform and what is below it
230 g.fillStyle = 'rgba(255,255,255,0.08)';
231 g.fillRect(0, 0, w, BAND_HEAD);
232 g.fillRect(0, waveTop + WAVE_H, w, BAND_HEAD);
233 g.fillStyle = 'rgba(255,255,255,0.65)';
234 g.font = 'bold 10px system-ui';
235 g.fillText('GROUND TRUTH: events placed by the synthesizer', 4, 11);
236 g.fillText(
237 `DETECTED${d.algoName ? ` BY ${d.algoName}` : ''}: events the algorithm reported`,
238 4, waveTop + WAVE_H + 11,
239 );
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 241 // lane labels and separators
242 g.font = '10px system-ui';
243 for (let k = 0; k < nTypes; k++) {
244 g.fillStyle = 'rgba(255,255,255,0.06)';
245 if (k % 2 === 0) {
246 g.fillRect(0, truthTop + k * LANE_H, w, LANE_H);
247 g.fillRect(0, detTop + k * LANE_H, w, LANE_H);
248 }
249 g.fillStyle = d.colors[k];
250 g.fillText(d.names[k].slice(0, 9), 4, truthTop + k * LANE_H + 12);
251 g.fillText(d.names[k].slice(0, 9), 4, detTop + k * LANE_H + 12);
252 }
254 // waveform over the visible window
255 const s0 = Math.max(0, Math.floor(this.t0 * d.fs));
256 const s1 = Math.min(d.x.length, Math.ceil(this.t1 * d.fs));
257 const plotW = w - LABEL_W;
258 const mid = waveTop + WAVE_H / 2;
259 let peak = 1e-6;
260 for (let i = s0; i < s1; i++) { const a = Math.abs(d.x[i]); if (a > peak) peak = a; }
261 const yScale = (WAVE_H / 2 - 4) / peak;
262 g.strokeStyle = WAVE;
263 g.lineWidth = 1;
264 g.beginPath();
265 for (let px = 0; px < plotW; px++) {
266 const i0 = s0 + Math.floor((px / plotW) * (s1 - s0));
267 const i1 = s0 + Math.floor(((px + 1) / plotW) * (s1 - s0));
268 let lo = 0, hi = 0;
269 for (let i = i0; i < Math.max(i1, i0 + 1); i++) {
270 if (d.x[i] < lo) lo = d.x[i];
271 if (d.x[i] > hi) hi = d.x[i];
272 }
273 g.moveTo(LABEL_W + px + 0.5, mid - hi * yScale - 0.5);
274 g.lineTo(LABEL_W + px + 0.5, mid - lo * yScale + 0.5);
275 }
276 g.stroke();
278 // events
279 const laneY = (top: number, k: number) => top + k * LANE_H + LANE_H / 2;
280 for (const ev of d.events) {
281 // truth side
282 if (ev.truth) {
283 const x = this.tToX(ev.truth.t);
284 if (x >= LABEL_W - 8 && x <= w + 8) {
285 const y = laneY(truthTop, ev.truth.typeIndex);
286 if (ev.kind === 'missed') {
287 g.strokeStyle = '#e4573d';
288 g.lineWidth = 1.6;
289 g.beginPath(); g.arc(x, y, 4.5, 0, 2 * Math.PI); g.stroke();
290 } else {
291 g.fillStyle = d.colors[ev.truth.typeIndex];
292 g.globalAlpha = ev.kind === 'misclassified' ? 0.9 : 0.9;
293 g.beginPath(); g.arc(x, y, 4, 0, 2 * Math.PI); g.fill();
294 g.globalAlpha = 1;
295 }
296 }
297 }
298 // detection side
299 if (ev.det) {
300 const x = this.tToX(ev.det.t);
301 if (x < LABEL_W - 8 || x > w + 8) continue;
302 const k = ev.det.typeIndex;
303 if (k < 0 || k >= nTypes) continue;
304 const y = laneY(detTop, k);
305 if (ev.kind === 'falsePositive') {
306 g.strokeStyle = '#e4573d';
307 g.lineWidth = 1.8;
308 g.beginPath();
309 g.moveTo(x - 4, y - 4); g.lineTo(x + 4, y + 4);
310 g.moveTo(x - 4, y + 4); g.lineTo(x + 4, y - 4);
311 g.stroke();
312 } else {
313 g.fillStyle = d.colors[k];
314 g.beginPath(); g.arc(x, y, 4, 0, 2 * Math.PI); g.fill();
315 if (ev.kind === 'misclassified') {
316 g.strokeStyle = '#e4573d';
317 g.lineWidth = 1.8;
318 g.beginPath(); g.arc(x, y, 6, 0, 2 * Math.PI); g.stroke();
319 }
320 }
321 }
322 }
d6b5949Explorer playback: play the run or a shift-drag selected interval, with a tracking playheadJeremy Magland 324 // selected interval
325 if (this.selection) {
326 const x0 = Math.max(LABEL_W, this.tToX(this.selection.t0));
327 const x1 = Math.min(w, this.tToX(this.selection.t1));
328 if (x1 > x0) {
329 g.fillStyle = 'rgba(120, 170, 255, 0.13)';
330 g.fillRect(x0, 0, x1 - x0, axisTop);
331 g.strokeStyle = 'rgba(120, 170, 255, 0.55)';
332 g.lineWidth = 1;
333 g.strokeRect(x0 + 0.5, 0.5, x1 - x0 - 1, axisTop - 1);
334 }
335 }
337 // playhead
338 if (this.playT !== null && this.playT >= this.t0 && this.playT <= this.t1) {
339 const x = this.tToX(this.playT);
340 g.strokeStyle = 'rgba(61, 187, 110, 0.9)';
341 g.lineWidth = 1.5;
342 g.beginPath();
343 g.moveTo(x, 0);
344 g.lineTo(x, axisTop);
345 g.stroke();
346 }
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 348 // time axis
349 g.fillStyle = 'rgba(255,255,255,0.5)';
350 g.font = '10px system-ui';
351 const span = this.t1 - this.t0;
352 const step = niceStep(span / Math.max(2, plotW / 90));
353 const dec = Math.max(0, -Math.floor(Math.log10(step) + 1e-9));
354 for (let t = Math.ceil(this.t0 / step) * step; t <= this.t1; t += step) {
355 const x = this.tToX(t);
356 g.fillRect(x, axisTop, 1, 4);
357 g.fillText(`${t.toFixed(dec)} s`, x + 3, axisTop + 14);
358 }
359 }
360}
362function niceStep(raw: number): number {
363 const p = Math.pow(10, Math.floor(Math.log10(raw)));
364 for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p;
365 return 10 * p;
366}