/** * The training-clip editor's view: one clip as a waveform with a marker * per detected onset. Click selects one marker and auditions it; * shift- or ctrl-click toggles a marker in the selection; dragging over * empty space sweeps a rubber-band selection. Excluded markers draw * hollow. Exclusion itself is the owner's business (a button or the X * key); this view only manages what is selected. */ 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'; private band: { x0: number; x1: number; base: Set; moved: boolean; additive: boolean } | null = null; selection = new Set(); onAudition: ((index: number) => void) | null = null; onSelectionChange: (() => void) | null = null; constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; 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(clip: Clip | null, markers: Marker[], color: string): void { this.clip = clip; this.markers = markers; this.color = color; this.selection.clear(); this.draw(); } /** Update markers without dropping the selection (indices unchanged). */ updateMarkers(markers: Marker[]): void { this.markers = markers; this.draw(); } private markerX(i: number, w: number): number { const dur = this.clip ? this.clip.samples.length / this.clip.sr : 1; return (this.markers[i].t / dur) * w; } private markerAt(x: number): number { const w = this.canvas.getBoundingClientRect().width; let best = -1, bestDx = 8; for (let i = 0; i < this.markers.length; i++) { const dx = Math.abs(this.markerX(i, w) - x); if (dx < bestDx) { best = i; bestDx = dx; } } return best; } private pointerDown(e: PointerEvent): void { if (!this.clip) return; const x = e.clientX - this.canvas.getBoundingClientRect().left; const additive = e.shiftKey || e.ctrlKey || e.metaKey; const i = this.markerAt(x); if (i >= 0) { if (additive) { if (this.selection.has(i)) this.selection.delete(i); else this.selection.add(i); } else { this.selection = new Set([i]); this.onAudition?.(i); } this.onSelectionChange?.(); this.draw(); } else { this.band = { x0: x, x1: x, base: additive ? new Set(this.selection) : new Set(), moved: false, additive }; this.canvas.setPointerCapture(e.pointerId); } } private pointerMove(e: PointerEvent): void { if (!this.band) return; const x = e.clientX - this.canvas.getBoundingClientRect().left; this.band.x1 = x; if (Math.abs(x - this.band.x0) > 3) this.band.moved = true; const lo = Math.min(this.band.x0, this.band.x1); const hi = Math.max(this.band.x0, this.band.x1); const w = this.canvas.getBoundingClientRect().width; this.selection = new Set(this.band.base); for (let i = 0; i < this.markers.length; i++) { const mx = this.markerX(i, w); if (mx >= lo && mx <= hi) this.selection.add(i); } this.onSelectionChange?.(); this.draw(); } private pointerUp(e: PointerEvent): void { if (!this.band) return; if (!this.band.moved && !this.band.additive && this.selection.size) { this.selection.clear(); // a plain click on empty space clears this.onSelectionChange?.(); } this.band = null; if (this.canvas.hasPointerCapture(e.pointerId)) this.canvas.releasePointerCapture(e.pointerId); 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 } = this.clip; 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 = this.markerX(i, w); const sel = this.selection.has(i); g.strokeStyle = this.color; g.lineWidth = sel ? 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(); } if (sel) { g.strokeStyle = '#fff'; g.lineWidth = 1.5; g.beginPath(); g.arc(x, 8, 6.5, 0, 2 * Math.PI); g.stroke(); } g.globalAlpha = 1; } if (this.band) { const lo = Math.min(this.band.x0, this.band.x1); g.fillStyle = 'rgba(120, 170, 255, 0.15)'; g.strokeStyle = 'rgba(120, 170, 255, 0.5)'; g.fillRect(lo, 0, Math.abs(this.band.x1 - this.band.x0), h); g.strokeRect(lo + 0.5, 0.5, Math.abs(this.band.x1 - this.band.x0) - 1, h - 1); } } }