/** * The dataset explorer: one synthesized recording with ground truth above * the waveform and one algorithm's detections below it. Every event's * fate is visible at a glance: a correct detection is a filled marker in * its type's color, a misclassification is filled in the predicted color * with a red ring, a false positive is a red cross, and a missed truth * event is a hollow red-edged circle in the truth lanes. Wheel zooms * around the cursor, dragging pans, and a click auditions the audio * around that time. Shift-drag selects an interval (shown highlighted) for * the owner's playback control; a playhead line tracks playback. */ import type { ScoredEvent } from '../bench/types.ts'; export interface ExplorerData { x: Float32Array; fs: number; names: string[]; colors: string[]; events: ScoredEvent[]; /** Shown in the detections band caption. */ algoName?: string; } const BG = '#16181d'; const WAVE = '#5b8dbf'; const LANE_H = 18; const BAND_HEAD = 15; const WAVE_H = 110; const AXIS_H = 22; const LABEL_W = 64; export class Explorer { private canvas: HTMLCanvasElement; private data: ExplorerData | null = null; private t0 = 0; private t1 = 1; private drag: { x: number; t0: number; t1: number; moved: boolean } | null = null; private select: { tAnchor: number; moved: boolean } | null = null; private selection: { t0: number; t1: number } | null = null; private playT: number | null = null; onAudition: ((t: number) => void) | null = null; onHover: ((text: string) => void) | null = null; onSelectionChange: (() => void) | null = null; constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; canvas.addEventListener('wheel', (e) => this.wheel(e), { passive: false }); canvas.addEventListener('pointerdown', (e) => this.pointerDown(e)); canvas.addEventListener('pointermove', (e) => this.pointerMove(e)); canvas.addEventListener('pointerup', (e) => this.pointerUp(e)); new ResizeObserver(() => this.draw()).observe(canvas); } set(data: ExplorerData | null): void { this.data = data; if (data) { this.t0 = 0; this.t1 = data.x.length / data.fs; } this.selection = null; this.playT = null; this.onSelectionChange?.(); this.canvas.style.height = `${this.height()}px`; this.draw(); } /** The selected interval in seconds, or null. */ getSelection(): { t0: number; t1: number } | null { return this.selection; } /** Playback cursor position in seconds; null hides it. */ setPlayhead(t: number | null): void { this.playT = t; this.draw(); } private height(): number { const n = this.data ? this.data.names.length : 0; return 2 * BAND_HEAD + n * LANE_H * 2 + WAVE_H + AXIS_H + 8; } private duration(): number { return this.data ? this.data.x.length / this.data.fs : 1; } private xToT(px: number): number { const w = this.canvas.getBoundingClientRect().width - LABEL_W; return this.t0 + ((px - LABEL_W) / Math.max(w, 1)) * (this.t1 - this.t0); } private tToX(t: number): number { const w = this.canvas.getBoundingClientRect().width - LABEL_W; return LABEL_W + ((t - this.t0) / (this.t1 - this.t0)) * w; } private wheel(e: WheelEvent): void { if (!this.data) return; e.preventDefault(); const t = this.xToT(e.clientX - this.canvas.getBoundingClientRect().left); const f = Math.exp(e.deltaY * 0.002); const span = Math.min(this.duration(), Math.max(0.02, (this.t1 - this.t0) * f)); const frac = (t - this.t0) / (this.t1 - this.t0); this.t0 = t - frac * span; this.t1 = this.t0 + span; this.clampView(); this.draw(); } private clampView(): void { const dur = this.duration(); const span = this.t1 - this.t0; if (this.t0 < 0) { this.t0 = 0; this.t1 = span; } if (this.t1 > dur) { this.t1 = dur; this.t0 = Math.max(0, dur - span); } } private pointerDown(e: PointerEvent): void { if (!this.data) return; const x = e.clientX - this.canvas.getBoundingClientRect().left; if (e.shiftKey) { this.select = { tAnchor: this.xToT(x), moved: false }; } else { this.drag = { x, t0: this.t0, t1: this.t1, moved: false }; } this.canvas.setPointerCapture(e.pointerId); } private pointerMove(e: PointerEvent): void { if (!this.data) return; const rect = this.canvas.getBoundingClientRect(); const x = e.clientX - rect.left; if (this.select) { const t = this.xToT(x); if (Math.abs(this.tToX(this.select.tAnchor) - x) > 3) this.select.moved = true; this.selection = { t0: Math.max(0, Math.min(this.select.tAnchor, t)), t1: Math.min(this.duration(), Math.max(this.select.tAnchor, t)), }; this.onSelectionChange?.(); this.draw(); return; } if (this.drag) { const w = rect.width - LABEL_W; const dt = ((this.drag.x - x) / Math.max(w, 1)) * (this.drag.t1 - this.drag.t0); if (Math.abs(this.drag.x - x) > 3) this.drag.moved = true; this.t0 = this.drag.t0 + dt; this.t1 = this.drag.t1 + dt; this.clampView(); this.draw(); return; } const hit = this.eventAt(x, e.clientY - rect.top); this.onHover?.(hit ? this.describe(hit) : ''); } private pointerUp(e: PointerEvent): void { if (this.canvas.hasPointerCapture(e.pointerId)) this.canvas.releasePointerCapture(e.pointerId); if (this.select) { // a shift-click that never became a drag clears the selection if (!this.select.moved) this.selection = null; this.select = null; this.onSelectionChange?.(); this.draw(); return; } if (!this.drag) return; const moved = this.drag.moved; this.drag = null; if (!moved) { const x = e.clientX - this.canvas.getBoundingClientRect().left; if (x > LABEL_W) this.onAudition?.(this.xToT(x)); } } private eventAt(px: number, _py: number): ScoredEvent | null { if (!this.data) return null; const tol = ((this.t1 - this.t0) / (this.canvas.getBoundingClientRect().width - LABEL_W)) * 6; let best: ScoredEvent | null = null; let bestDt = tol; for (const ev of this.data.events) { const t = ev.truth?.t ?? ev.det?.t; if (t === undefined) continue; const dt = Math.abs(t - this.xToT(px)); if (dt < bestDt) { best = ev; bestDt = dt; } } return best; } private describe(ev: ScoredEvent): string { const d = this.data!; const name = (i: number | undefined) => (i !== undefined && i >= 0 && i < d.names.length ? d.names[i] : '?'); const at = (t: number | undefined) => (t !== undefined ? `${t.toFixed(3)} s` : '?'); switch (ev.kind) { case 'correct': return `correct: ${name(ev.truth?.typeIndex)} at ${at(ev.truth?.t)} (detected ${at(ev.det?.t)})`; case 'misclassified': return `misclassified: ${name(ev.truth?.typeIndex)} at ${at(ev.truth?.t)} detected as ${name(ev.det?.typeIndex)}`; case 'missed': return `missed: ${name(ev.truth?.typeIndex)} at ${at(ev.truth?.t)} (gain ${ev.truth ? (20 * Math.log10(ev.truth.gain)).toFixed(1) : '?'} dB)`; case 'falsePositive': return `false positive: reported ${name(ev.det?.typeIndex)} at ${at(ev.det?.t)}`; } } draw(): void { const c = this.canvas; const dpr = devicePixelRatio || 1; const w = c.clientWidth, h = this.height(); if (w === 0) return; if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) { c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); } const g = c.getContext('2d')!; g.setTransform(dpr, 0, 0, dpr, 0, 0); g.fillStyle = BG; g.fillRect(0, 0, w, h); if (!this.data) { g.fillStyle = 'rgba(255,255,255,0.4)'; g.font = '13px system-ui'; g.fillText('run a benchmark, then pick a run and an algorithm', 12, 24); return; } const d = this.data; const nTypes = d.names.length; const truthTop = BAND_HEAD; const waveTop = truthTop + nTypes * LANE_H; const detTop = waveTop + WAVE_H + BAND_HEAD; const axisTop = detTop + nTypes * LANE_H; // band captions: what is above the waveform and what is below it g.fillStyle = 'rgba(255,255,255,0.08)'; g.fillRect(0, 0, w, BAND_HEAD); g.fillRect(0, waveTop + WAVE_H, w, BAND_HEAD); g.fillStyle = 'rgba(255,255,255,0.65)'; g.font = 'bold 10px system-ui'; g.fillText('GROUND TRUTH: events placed by the synthesizer', 4, 11); g.fillText( `DETECTED${d.algoName ? ` BY ${d.algoName}` : ''}: events the algorithm reported`, 4, waveTop + WAVE_H + 11, ); // lane labels and separators g.font = '10px system-ui'; for (let k = 0; k < nTypes; k++) { g.fillStyle = 'rgba(255,255,255,0.06)'; if (k % 2 === 0) { g.fillRect(0, truthTop + k * LANE_H, w, LANE_H); g.fillRect(0, detTop + k * LANE_H, w, LANE_H); } g.fillStyle = d.colors[k]; g.fillText(d.names[k].slice(0, 9), 4, truthTop + k * LANE_H + 12); g.fillText(d.names[k].slice(0, 9), 4, detTop + k * LANE_H + 12); } // waveform over the visible window const s0 = Math.max(0, Math.floor(this.t0 * d.fs)); const s1 = Math.min(d.x.length, Math.ceil(this.t1 * d.fs)); const plotW = w - LABEL_W; const mid = waveTop + WAVE_H / 2; let peak = 1e-6; for (let i = s0; i < s1; i++) { const a = Math.abs(d.x[i]); if (a > peak) peak = a; } const yScale = (WAVE_H / 2 - 4) / peak; g.strokeStyle = WAVE; g.lineWidth = 1; g.beginPath(); for (let px = 0; px < plotW; px++) { const i0 = s0 + Math.floor((px / plotW) * (s1 - s0)); const i1 = s0 + Math.floor(((px + 1) / plotW) * (s1 - s0)); let lo = 0, hi = 0; for (let i = i0; i < Math.max(i1, i0 + 1); i++) { if (d.x[i] < lo) lo = d.x[i]; if (d.x[i] > hi) hi = d.x[i]; } g.moveTo(LABEL_W + px + 0.5, mid - hi * yScale - 0.5); g.lineTo(LABEL_W + px + 0.5, mid - lo * yScale + 0.5); } g.stroke(); // events const laneY = (top: number, k: number) => top + k * LANE_H + LANE_H / 2; for (const ev of d.events) { // truth side if (ev.truth) { const x = this.tToX(ev.truth.t); if (x >= LABEL_W - 8 && x <= w + 8) { const y = laneY(truthTop, ev.truth.typeIndex); if (ev.kind === 'missed') { g.strokeStyle = '#e4573d'; g.lineWidth = 1.6; g.beginPath(); g.arc(x, y, 4.5, 0, 2 * Math.PI); g.stroke(); } else { g.fillStyle = d.colors[ev.truth.typeIndex]; g.globalAlpha = ev.kind === 'misclassified' ? 0.9 : 0.9; g.beginPath(); g.arc(x, y, 4, 0, 2 * Math.PI); g.fill(); g.globalAlpha = 1; } } } // detection side if (ev.det) { const x = this.tToX(ev.det.t); if (x < LABEL_W - 8 || x > w + 8) continue; const k = ev.det.typeIndex; if (k < 0 || k >= nTypes) continue; const y = laneY(detTop, k); if (ev.kind === 'falsePositive') { g.strokeStyle = '#e4573d'; g.lineWidth = 1.8; g.beginPath(); g.moveTo(x - 4, y - 4); g.lineTo(x + 4, y + 4); g.moveTo(x - 4, y + 4); g.lineTo(x + 4, y - 4); g.stroke(); } else { g.fillStyle = d.colors[k]; g.beginPath(); g.arc(x, y, 4, 0, 2 * Math.PI); g.fill(); if (ev.kind === 'misclassified') { g.strokeStyle = '#e4573d'; g.lineWidth = 1.8; g.beginPath(); g.arc(x, y, 6, 0, 2 * Math.PI); g.stroke(); } } } } // selected interval if (this.selection) { const x0 = Math.max(LABEL_W, this.tToX(this.selection.t0)); const x1 = Math.min(w, this.tToX(this.selection.t1)); if (x1 > x0) { g.fillStyle = 'rgba(120, 170, 255, 0.13)'; g.fillRect(x0, 0, x1 - x0, axisTop); g.strokeStyle = 'rgba(120, 170, 255, 0.55)'; g.lineWidth = 1; g.strokeRect(x0 + 0.5, 0.5, x1 - x0 - 1, axisTop - 1); } } // playhead if (this.playT !== null && this.playT >= this.t0 && this.playT <= this.t1) { const x = this.tToX(this.playT); g.strokeStyle = 'rgba(61, 187, 110, 0.9)'; g.lineWidth = 1.5; g.beginPath(); g.moveTo(x, 0); g.lineTo(x, axisTop); g.stroke(); } // time axis g.fillStyle = 'rgba(255,255,255,0.5)'; g.font = '10px system-ui'; const span = this.t1 - this.t0; const step = niceStep(span / Math.max(2, plotW / 90)); const dec = Math.max(0, -Math.floor(Math.log10(step) + 1e-9)); for (let t = Math.ceil(this.t0 / step) * step; t <= this.t1; t += step) { const x = this.tToX(t); g.fillRect(x, axisTop, 1, 4); g.fillText(`${t.toFixed(dec)} s`, x + 3, axisTop + 14); } } } function niceStep(raw: number): number { const p = Math.pow(10, Math.floor(Math.log10(raw))); for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p; return 10 * p; }