/** * The capture review: one recorded clip drawn as a waveform with a marker * per detected onset. Markers audition on click and can be excluded (drawn * hollow) before the hits are committed to a voice's training set, so a * cough or a spurious detection is visible and removable rather than * silently poisoning the label. */ import type { Clip } from '../audio/recorder.ts'; export interface Marker { t: number; excluded: boolean; } const BG = '#16181d'; const WAVE = '#5b8dbf'; export class WaveView { private canvas: HTMLCanvasElement; private clip: Clip | null = null; private markers: Marker[] = []; private color = '#e4573d'; selected = -1; onMarkerClick: ((index: number) => void) | null = null; constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; canvas.addEventListener('pointerdown', (e) => { if (!this.clip) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const dur = this.clip.samples.length / this.clip.sr; let best = -1, bestDx = 8; for (let i = 0; i < this.markers.length; i++) { const mx = (this.markers[i].t / dur) * rect.width; const dx = Math.abs(mx - x); if (dx < bestDx) { best = i; bestDx = dx; } } if (best >= 0) this.onMarkerClick?.(best); }); new ResizeObserver(() => this.draw()).observe(canvas); } set(clip: Clip | null, markers: Marker[], color: string): void { this.clip = clip; this.markers = markers; this.color = color; this.selected = -1; this.draw(); } draw(): void { const c = this.canvas; const dpr = devicePixelRatio || 1; const w = c.clientWidth, h = c.clientHeight; 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.clip) return; const { samples, sr } = this.clip; const dur = samples.length / sr; const mid = h / 2; g.strokeStyle = WAVE; g.lineWidth = 1; g.beginPath(); for (let px = 0; px < w; px++) { const i0 = Math.floor((px / w) * samples.length); const i1 = Math.floor(((px + 1) / w) * samples.length); let lo = 0, hi = 0; for (let i = i0; i < i1; i++) { if (samples[i] < lo) lo = samples[i]; if (samples[i] > hi) hi = samples[i]; } g.moveTo(px + 0.5, mid - hi * mid * 0.92 - 0.5); g.lineTo(px + 0.5, mid - lo * mid * 0.92 + 0.5); } g.stroke(); for (let i = 0; i < this.markers.length; i++) { const m = this.markers[i]; const x = (m.t / dur) * w; g.strokeStyle = this.color; g.lineWidth = i === this.selected ? 2.5 : 1.2; g.globalAlpha = m.excluded ? 0.35 : 1; g.beginPath(); g.moveTo(x, 2); g.lineTo(x, h - 2); g.stroke(); g.beginPath(); g.arc(x, 8, 4.5, 0, 2 * Math.PI); if (m.excluded) g.stroke(); else { g.fillStyle = this.color; g.fill(); } g.globalAlpha = 1; } } }