/ proof-of-concept / audio-spike-sorting
Sign in
proof-of-concept / audio-spike-sorting
audio-spike-sorting / src / ui / explorer.ts
356 lines · 12.4 KBCodeBlameHistory
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.
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[];
22const BG = '#16181d';
23const WAVE = '#5b8dbf';
24const LANE_H = 18;
25const WAVE_H = 110;
26const AXIS_H = 22;
27const LABEL_W = 64;
29export class Explorer {
30 private canvas: HTMLCanvasElement;
31 private data: ExplorerData | null = null;
32 private t0 = 0;
33 private t1 = 1;
34 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 35 private select: { tAnchor: number; moved: boolean } | null = null;
36 private selection: { t0: number; t1: number } | null = null;
37 private playT: number | null = null;
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 38 onAudition: ((t: number) => void) | null = null;
39 onHover: ((text: string) => void) | null = null;
d6b5949Explorer playback: play the run or a shift-drag selected interval, with a tracking playheadJeremy Magland 40 onSelectionChange: (() => void) | null = null;
42 constructor(canvas: HTMLCanvasElement) {
43 this.canvas = canvas;
44 canvas.addEventListener('wheel', (e) => this.wheel(e), { passive: false });
45 canvas.addEventListener('pointerdown', (e) => this.pointerDown(e));
46 canvas.addEventListener('pointermove', (e) => this.pointerMove(e));
47 canvas.addEventListener('pointerup', (e) => this.pointerUp(e));
48 new ResizeObserver(() => this.draw()).observe(canvas);
49 }
51 set(data: ExplorerData | null): void {
52 this.data = data;
53 if (data) { this.t0 = 0; this.t1 = data.x.length / data.fs; }
55 this.playT = null;
56 this.onSelectionChange?.();
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 57 this.canvas.style.height = `${this.height()}px`;
58 this.draw();
59 }
d6b5949Explorer playback: play the run or a shift-drag selected interval, with a tracking playheadJeremy Magland 61 /** The selected interval in seconds, or null. */
62 getSelection(): { t0: number; t1: number } | null {
63 return this.selection;
64 }
66 /** Playback cursor position in seconds; null hides it. */
67 setPlayhead(t: number | null): void {
68 this.playT = t;
69 this.draw();
70 }
73 const n = this.data ? this.data.names.length : 0;
74 return n * LANE_H * 2 + WAVE_H + AXIS_H + 8;
75 }
77 private duration(): number {
78 return this.data ? this.data.x.length / this.data.fs : 1;
79 }
81 private xToT(px: number): number {
82 const w = this.canvas.getBoundingClientRect().width - LABEL_W;
83 return this.t0 + ((px - LABEL_W) / Math.max(w, 1)) * (this.t1 - this.t0);
84 }
86 private tToX(t: number): number {
87 const w = this.canvas.getBoundingClientRect().width - LABEL_W;
88 return LABEL_W + ((t - this.t0) / (this.t1 - this.t0)) * w;
89 }
91 private wheel(e: WheelEvent): void {
92 if (!this.data) return;
93 e.preventDefault();
94 const t = this.xToT(e.clientX - this.canvas.getBoundingClientRect().left);
95 const f = Math.exp(e.deltaY * 0.002);
96 const span = Math.min(this.duration(), Math.max(0.02, (this.t1 - this.t0) * f));
97 const frac = (t - this.t0) / (this.t1 - this.t0);
98 this.t0 = t - frac * span;
99 this.t1 = this.t0 + span;
100 this.clampView();
101 this.draw();
102 }
104 private clampView(): void {
105 const dur = this.duration();
106 const span = this.t1 - this.t0;
107 if (this.t0 < 0) { this.t0 = 0; this.t1 = span; }
108 if (this.t1 > dur) { this.t1 = dur; this.t0 = Math.max(0, dur - span); }
109 }
111 private pointerDown(e: PointerEvent): void {
112 if (!this.data) return;
113 const x = e.clientX - this.canvas.getBoundingClientRect().left;
115 this.select = { tAnchor: this.xToT(x), moved: false };
116 } else {
117 this.drag = { x, t0: this.t0, t1: this.t1, moved: false };
118 }
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 119 this.canvas.setPointerCapture(e.pointerId);
120 }
122 private pointerMove(e: PointerEvent): void {
123 if (!this.data) return;
124 const rect = this.canvas.getBoundingClientRect();
125 const x = e.clientX - rect.left;
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 }
138 const w = rect.width - LABEL_W;
139 const dt = ((this.drag.x - x) / Math.max(w, 1)) * (this.drag.t1 - this.drag.t0);
140 if (Math.abs(this.drag.x - x) > 3) this.drag.moved = true;
141 this.t0 = this.drag.t0 + dt;
142 this.t1 = this.drag.t1 + dt;
143 this.clampView();
144 this.draw();
145 return;
146 }
147 const hit = this.eventAt(x, e.clientY - rect.top);
148 this.onHover?.(hit ? this.describe(hit) : '');
149 }
151 private pointerUp(e: PointerEvent): void {
d6b5949Explorer playback: play the run or a shift-drag selected interval, with a tracking playheadJeremy Magland 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 }
162 const moved = this.drag.moved;
163 this.drag = null;
164 if (!moved) {
165 const x = e.clientX - this.canvas.getBoundingClientRect().left;
166 if (x > LABEL_W) this.onAudition?.(this.xToT(x));
167 }
168 }
170 private eventAt(px: number, _py: number): ScoredEvent | null {
171 if (!this.data) return null;
172 const tol = ((this.t1 - this.t0) / (this.canvas.getBoundingClientRect().width - LABEL_W)) * 6;
173 let best: ScoredEvent | null = null;
174 let bestDt = tol;
175 for (const ev of this.data.events) {
176 const t = ev.truth?.t ?? ev.det?.t;
177 if (t === undefined) continue;
178 const dt = Math.abs(t - this.xToT(px));
179 if (dt < bestDt) { best = ev; bestDt = dt; }
180 }
181 return best;
182 }
184 private describe(ev: ScoredEvent): string {
185 const d = this.data!;
186 const name = (i: number | undefined) => (i !== undefined && i >= 0 && i < d.names.length ? d.names[i] : '?');
187 const at = (t: number | undefined) => (t !== undefined ? `${t.toFixed(3)} s` : '?');
188 switch (ev.kind) {
189 case 'correct':
190 return `correct: ${name(ev.truth?.typeIndex)} at ${at(ev.truth?.t)} (detected ${at(ev.det?.t)})`;
191 case 'misclassified':
192 return `misclassified: ${name(ev.truth?.typeIndex)} at ${at(ev.truth?.t)} detected as ${name(ev.det?.typeIndex)}`;
193 case 'missed':
194 return `missed: ${name(ev.truth?.typeIndex)} at ${at(ev.truth?.t)} (gain ${ev.truth ? (20 * Math.log10(ev.truth.gain)).toFixed(1) : '?'} dB)`;
195 case 'falsePositive':
196 return `false positive: reported ${name(ev.det?.typeIndex)} at ${at(ev.det?.t)}`;
197 }
198 }
200 draw(): void {
201 const c = this.canvas;
202 const dpr = devicePixelRatio || 1;
203 const w = c.clientWidth, h = this.height();
204 if (w === 0) return;
205 if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) {
206 c.width = Math.round(w * dpr);
207 c.height = Math.round(h * dpr);
208 }
209 const g = c.getContext('2d')!;
210 g.setTransform(dpr, 0, 0, dpr, 0, 0);
211 g.fillStyle = BG;
212 g.fillRect(0, 0, w, h);
213 if (!this.data) {
214 g.fillStyle = 'rgba(255,255,255,0.4)';
215 g.font = '13px system-ui';
216 g.fillText('run a benchmark, then pick a run and an algorithm', 12, 24);
217 return;
218 }
219 const d = this.data;
220 const nTypes = d.names.length;
221 const truthTop = 0;
222 const waveTop = nTypes * LANE_H;
223 const detTop = waveTop + WAVE_H;
224 const axisTop = detTop + nTypes * LANE_H;
226 // lane labels and separators
227 g.font = '10px system-ui';
228 for (let k = 0; k < nTypes; k++) {
229 g.fillStyle = 'rgba(255,255,255,0.06)';
230 if (k % 2 === 0) {
231 g.fillRect(0, truthTop + k * LANE_H, w, LANE_H);
232 g.fillRect(0, detTop + k * LANE_H, w, LANE_H);
233 }
234 g.fillStyle = d.colors[k];
235 g.fillText(d.names[k].slice(0, 9), 4, truthTop + k * LANE_H + 12);
236 g.fillText(d.names[k].slice(0, 9), 4, detTop + k * LANE_H + 12);
237 }
238 g.fillStyle = 'rgba(255,255,255,0.35)';
239 g.textAlign = 'right';
240 g.fillText('ground truth', w - 6, truthTop + 12);
241 g.fillText('detections', w - 6, detTop + 12);
242 g.textAlign = 'left';
244 // waveform over the visible window
245 const s0 = Math.max(0, Math.floor(this.t0 * d.fs));
246 const s1 = Math.min(d.x.length, Math.ceil(this.t1 * d.fs));
247 const plotW = w - LABEL_W;
248 const mid = waveTop + WAVE_H / 2;
249 let peak = 1e-6;
250 for (let i = s0; i < s1; i++) { const a = Math.abs(d.x[i]); if (a > peak) peak = a; }
251 const yScale = (WAVE_H / 2 - 4) / peak;
252 g.strokeStyle = WAVE;
253 g.lineWidth = 1;
254 g.beginPath();
255 for (let px = 0; px < plotW; px++) {
256 const i0 = s0 + Math.floor((px / plotW) * (s1 - s0));
257 const i1 = s0 + Math.floor(((px + 1) / plotW) * (s1 - s0));
258 let lo = 0, hi = 0;
259 for (let i = i0; i < Math.max(i1, i0 + 1); i++) {
260 if (d.x[i] < lo) lo = d.x[i];
261 if (d.x[i] > hi) hi = d.x[i];
262 }
263 g.moveTo(LABEL_W + px + 0.5, mid - hi * yScale - 0.5);
264 g.lineTo(LABEL_W + px + 0.5, mid - lo * yScale + 0.5);
265 }
266 g.stroke();
268 // events
269 const laneY = (top: number, k: number) => top + k * LANE_H + LANE_H / 2;
270 for (const ev of d.events) {
271 // truth side
272 if (ev.truth) {
273 const x = this.tToX(ev.truth.t);
274 if (x >= LABEL_W - 8 && x <= w + 8) {
275 const y = laneY(truthTop, ev.truth.typeIndex);
276 if (ev.kind === 'missed') {
277 g.strokeStyle = '#e4573d';
278 g.lineWidth = 1.6;
279 g.beginPath(); g.arc(x, y, 4.5, 0, 2 * Math.PI); g.stroke();
280 } else {
281 g.fillStyle = d.colors[ev.truth.typeIndex];
282 g.globalAlpha = ev.kind === 'misclassified' ? 0.9 : 0.9;
283 g.beginPath(); g.arc(x, y, 4, 0, 2 * Math.PI); g.fill();
284 g.globalAlpha = 1;
285 }
286 }
287 }
288 // detection side
289 if (ev.det) {
290 const x = this.tToX(ev.det.t);
291 if (x < LABEL_W - 8 || x > w + 8) continue;
292 const k = ev.det.typeIndex;
293 if (k < 0 || k >= nTypes) continue;
294 const y = laneY(detTop, k);
295 if (ev.kind === 'falsePositive') {
296 g.strokeStyle = '#e4573d';
297 g.lineWidth = 1.8;
298 g.beginPath();
299 g.moveTo(x - 4, y - 4); g.lineTo(x + 4, y + 4);
300 g.moveTo(x - 4, y + 4); g.lineTo(x + 4, y - 4);
301 g.stroke();
302 } else {
303 g.fillStyle = d.colors[k];
304 g.beginPath(); g.arc(x, y, 4, 0, 2 * Math.PI); g.fill();
305 if (ev.kind === 'misclassified') {
306 g.strokeStyle = '#e4573d';
307 g.lineWidth = 1.8;
308 g.beginPath(); g.arc(x, y, 6, 0, 2 * Math.PI); g.stroke();
309 }
310 }
311 }
312 }
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 }
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 }
339 g.fillStyle = 'rgba(255,255,255,0.5)';
340 g.font = '10px system-ui';
341 const span = this.t1 - this.t0;
342 const step = niceStep(span / Math.max(2, plotW / 90));
343 const dec = Math.max(0, -Math.floor(Math.log10(step) + 1e-9));
344 for (let t = Math.ceil(this.t0 / step) * step; t <= this.t1; t += step) {
345 const x = this.tToX(t);
346 g.fillRect(x, axisTop, 1, 4);
347 g.fillText(`${t.toFixed(dec)} s`, x + 3, axisTop + 14);
348 }
349 }
352function niceStep(raw: number): number {
353 const p = Math.pow(10, Math.floor(Math.log10(raw)));
354 for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p;
355 return 10 * p;
↑↓ move↵openescclose