/ proof-of-concept / audio-spike-sorting
Sign in
proof-of-concept / audio-spike-sorting
audio-spike-sorting / src / ui / explorer.ts
290 lines · 10.2 KBBlameHistoryRaw
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
9 * around that time.
10 */
11import type { ScoredEvent } from '../bench/types.ts';
13export interface ExplorerData {
14 x: Float32Array;
15 fs: number;
16 names: string[];
17 colors: string[];
18 events: ScoredEvent[];
21const BG = '#16181d';
22const WAVE = '#5b8dbf';
23const LANE_H = 18;
24const WAVE_H = 110;
25const AXIS_H = 22;
26const LABEL_W = 64;
28export class Explorer {
29 private canvas: HTMLCanvasElement;
30 private data: ExplorerData | null = null;
31 private t0 = 0;
32 private t1 = 1;
33 private drag: { x: number; t0: number; t1: number; moved: boolean } | null = null;
34 onAudition: ((t: number) => void) | null = null;
35 onHover: ((text: string) => void) | null = null;
37 constructor(canvas: HTMLCanvasElement) {
38 this.canvas = canvas;
39 canvas.addEventListener('wheel', (e) => this.wheel(e), { passive: false });
40 canvas.addEventListener('pointerdown', (e) => this.pointerDown(e));
41 canvas.addEventListener('pointermove', (e) => this.pointerMove(e));
42 canvas.addEventListener('pointerup', (e) => this.pointerUp(e));
43 new ResizeObserver(() => this.draw()).observe(canvas);
44 }
46 set(data: ExplorerData | null): void {
47 this.data = data;
48 if (data) { this.t0 = 0; this.t1 = data.x.length / data.fs; }
49 this.canvas.style.height = `${this.height()}px`;
50 this.draw();
51 }
53 private height(): number {
54 const n = this.data ? this.data.names.length : 0;
55 return n * LANE_H * 2 + WAVE_H + AXIS_H + 8;
56 }
58 private duration(): number {
59 return this.data ? this.data.x.length / this.data.fs : 1;
60 }
62 private xToT(px: number): number {
63 const w = this.canvas.getBoundingClientRect().width - LABEL_W;
64 return this.t0 + ((px - LABEL_W) / Math.max(w, 1)) * (this.t1 - this.t0);
65 }
67 private tToX(t: number): number {
68 const w = this.canvas.getBoundingClientRect().width - LABEL_W;
69 return LABEL_W + ((t - this.t0) / (this.t1 - this.t0)) * w;
70 }
72 private wheel(e: WheelEvent): void {
73 if (!this.data) return;
74 e.preventDefault();
75 const t = this.xToT(e.clientX - this.canvas.getBoundingClientRect().left);
76 const f = Math.exp(e.deltaY * 0.002);
77 const span = Math.min(this.duration(), Math.max(0.02, (this.t1 - this.t0) * f));
78 const frac = (t - this.t0) / (this.t1 - this.t0);
79 this.t0 = t - frac * span;
80 this.t1 = this.t0 + span;
81 this.clampView();
82 this.draw();
83 }
85 private clampView(): void {
86 const dur = this.duration();
87 const span = this.t1 - this.t0;
88 if (this.t0 < 0) { this.t0 = 0; this.t1 = span; }
89 if (this.t1 > dur) { this.t1 = dur; this.t0 = Math.max(0, dur - span); }
90 }
92 private pointerDown(e: PointerEvent): void {
93 if (!this.data) return;
94 const x = e.clientX - this.canvas.getBoundingClientRect().left;
95 this.drag = { x, t0: this.t0, t1: this.t1, moved: false };
96 this.canvas.setPointerCapture(e.pointerId);
97 }
99 private pointerMove(e: PointerEvent): void {
100 if (!this.data) return;
101 const rect = this.canvas.getBoundingClientRect();
102 const x = e.clientX - rect.left;
103 if (this.drag) {
104 const w = rect.width - LABEL_W;
105 const dt = ((this.drag.x - x) / Math.max(w, 1)) * (this.drag.t1 - this.drag.t0);
106 if (Math.abs(this.drag.x - x) > 3) this.drag.moved = true;
107 this.t0 = this.drag.t0 + dt;
108 this.t1 = this.drag.t1 + dt;
109 this.clampView();
110 this.draw();
111 return;
112 }
113 const hit = this.eventAt(x, e.clientY - rect.top);
114 this.onHover?.(hit ? this.describe(hit) : '');
115 }
117 private pointerUp(e: PointerEvent): void {
118 if (!this.drag) return;
119 const moved = this.drag.moved;
120 this.drag = null;
121 if (this.canvas.hasPointerCapture(e.pointerId)) this.canvas.releasePointerCapture(e.pointerId);
122 if (!moved) {
123 const x = e.clientX - this.canvas.getBoundingClientRect().left;
124 if (x > LABEL_W) this.onAudition?.(this.xToT(x));
125 }
126 }
128 private eventAt(px: number, _py: number): ScoredEvent | null {
129 if (!this.data) return null;
130 const tol = ((this.t1 - this.t0) / (this.canvas.getBoundingClientRect().width - LABEL_W)) * 6;
131 let best: ScoredEvent | null = null;
132 let bestDt = tol;
133 for (const ev of this.data.events) {
134 const t = ev.truth?.t ?? ev.det?.t;
135 if (t === undefined) continue;
136 const dt = Math.abs(t - this.xToT(px));
137 if (dt < bestDt) { best = ev; bestDt = dt; }
138 }
139 return best;
140 }
142 private describe(ev: ScoredEvent): string {
143 const d = this.data!;
144 const name = (i: number | undefined) => (i !== undefined && i >= 0 && i < d.names.length ? d.names[i] : '?');
145 const at = (t: number | undefined) => (t !== undefined ? `${t.toFixed(3)} s` : '?');
146 switch (ev.kind) {
147 case 'correct':
148 return `correct: ${name(ev.truth?.typeIndex)} at ${at(ev.truth?.t)} (detected ${at(ev.det?.t)})`;
149 case 'misclassified':
150 return `misclassified: ${name(ev.truth?.typeIndex)} at ${at(ev.truth?.t)} detected as ${name(ev.det?.typeIndex)}`;
151 case 'missed':
152 return `missed: ${name(ev.truth?.typeIndex)} at ${at(ev.truth?.t)} (gain ${ev.truth ? (20 * Math.log10(ev.truth.gain)).toFixed(1) : '?'} dB)`;
153 case 'falsePositive':
154 return `false positive: reported ${name(ev.det?.typeIndex)} at ${at(ev.det?.t)}`;
155 }
156 }
158 draw(): void {
159 const c = this.canvas;
160 const dpr = devicePixelRatio || 1;
161 const w = c.clientWidth, h = this.height();
162 if (w === 0) return;
163 if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) {
164 c.width = Math.round(w * dpr);
165 c.height = Math.round(h * dpr);
166 }
167 const g = c.getContext('2d')!;
168 g.setTransform(dpr, 0, 0, dpr, 0, 0);
169 g.fillStyle = BG;
170 g.fillRect(0, 0, w, h);
171 if (!this.data) {
172 g.fillStyle = 'rgba(255,255,255,0.4)';
173 g.font = '13px system-ui';
174 g.fillText('run a benchmark, then pick a run and an algorithm', 12, 24);
175 return;
176 }
177 const d = this.data;
178 const nTypes = d.names.length;
179 const truthTop = 0;
180 const waveTop = nTypes * LANE_H;
181 const detTop = waveTop + WAVE_H;
182 const axisTop = detTop + nTypes * LANE_H;
184 // lane labels and separators
185 g.font = '10px system-ui';
186 for (let k = 0; k < nTypes; k++) {
187 g.fillStyle = 'rgba(255,255,255,0.06)';
188 if (k % 2 === 0) {
189 g.fillRect(0, truthTop + k * LANE_H, w, LANE_H);
190 g.fillRect(0, detTop + k * LANE_H, w, LANE_H);
191 }
192 g.fillStyle = d.colors[k];
193 g.fillText(d.names[k].slice(0, 9), 4, truthTop + k * LANE_H + 12);
194 g.fillText(d.names[k].slice(0, 9), 4, detTop + k * LANE_H + 12);
195 }
196 g.fillStyle = 'rgba(255,255,255,0.35)';
197 g.textAlign = 'right';
198 g.fillText('ground truth', w - 6, truthTop + 12);
199 g.fillText('detections', w - 6, detTop + 12);
200 g.textAlign = 'left';
202 // waveform over the visible window
203 const s0 = Math.max(0, Math.floor(this.t0 * d.fs));
204 const s1 = Math.min(d.x.length, Math.ceil(this.t1 * d.fs));
205 const plotW = w - LABEL_W;
206 const mid = waveTop + WAVE_H / 2;
207 let peak = 1e-6;
208 for (let i = s0; i < s1; i++) { const a = Math.abs(d.x[i]); if (a > peak) peak = a; }
209 const yScale = (WAVE_H / 2 - 4) / peak;
210 g.strokeStyle = WAVE;
211 g.lineWidth = 1;
212 g.beginPath();
213 for (let px = 0; px < plotW; px++) {
214 const i0 = s0 + Math.floor((px / plotW) * (s1 - s0));
215 const i1 = s0 + Math.floor(((px + 1) / plotW) * (s1 - s0));
216 let lo = 0, hi = 0;
217 for (let i = i0; i < Math.max(i1, i0 + 1); i++) {
218 if (d.x[i] < lo) lo = d.x[i];
219 if (d.x[i] > hi) hi = d.x[i];
220 }
221 g.moveTo(LABEL_W + px + 0.5, mid - hi * yScale - 0.5);
222 g.lineTo(LABEL_W + px + 0.5, mid - lo * yScale + 0.5);
223 }
224 g.stroke();
226 // events
227 const laneY = (top: number, k: number) => top + k * LANE_H + LANE_H / 2;
228 for (const ev of d.events) {
229 // truth side
230 if (ev.truth) {
231 const x = this.tToX(ev.truth.t);
232 if (x >= LABEL_W - 8 && x <= w + 8) {
233 const y = laneY(truthTop, ev.truth.typeIndex);
234 if (ev.kind === 'missed') {
235 g.strokeStyle = '#e4573d';
236 g.lineWidth = 1.6;
237 g.beginPath(); g.arc(x, y, 4.5, 0, 2 * Math.PI); g.stroke();
238 } else {
239 g.fillStyle = d.colors[ev.truth.typeIndex];
240 g.globalAlpha = ev.kind === 'misclassified' ? 0.9 : 0.9;
241 g.beginPath(); g.arc(x, y, 4, 0, 2 * Math.PI); g.fill();
242 g.globalAlpha = 1;
243 }
244 }
245 }
246 // detection side
247 if (ev.det) {
248 const x = this.tToX(ev.det.t);
249 if (x < LABEL_W - 8 || x > w + 8) continue;
250 const k = ev.det.typeIndex;
251 if (k < 0 || k >= nTypes) continue;
252 const y = laneY(detTop, k);
253 if (ev.kind === 'falsePositive') {
254 g.strokeStyle = '#e4573d';
255 g.lineWidth = 1.8;
256 g.beginPath();
257 g.moveTo(x - 4, y - 4); g.lineTo(x + 4, y + 4);
258 g.moveTo(x - 4, y + 4); g.lineTo(x + 4, y - 4);
259 g.stroke();
260 } else {
261 g.fillStyle = d.colors[k];
262 g.beginPath(); g.arc(x, y, 4, 0, 2 * Math.PI); g.fill();
263 if (ev.kind === 'misclassified') {
264 g.strokeStyle = '#e4573d';
265 g.lineWidth = 1.8;
266 g.beginPath(); g.arc(x, y, 6, 0, 2 * Math.PI); g.stroke();
267 }
268 }
269 }
270 }
272 // time axis
273 g.fillStyle = 'rgba(255,255,255,0.5)';
274 g.font = '10px system-ui';
275 const span = this.t1 - this.t0;
276 const step = niceStep(span / Math.max(2, plotW / 90));
277 const dec = Math.max(0, -Math.floor(Math.log10(step) + 1e-9));
278 for (let t = Math.ceil(this.t0 / step) * step; t <= this.t1; t += step) {
279 const x = this.tToX(t);
280 g.fillRect(x, axisTop, 1, 4);
281 g.fillText(`${t.toFixed(dec)} s`, x + 3, axisTop + 14);
282 }
283 }
286function niceStep(raw: number): number {
287 const p = Math.pow(10, Math.floor(Math.log10(raw)));
288 for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p;
289 return 10 * p;
↑↓ move↵openescclose