/ proof-of-concept / instrawave-compose
Sign in
proof-of-concept / instrawave-compose
instrawave-compose / src / ui / waveform.ts
103 lines · 3.1 KBBlameHistoryRaw
1/**
2 * The capture review: one recorded clip drawn as a waveform with a marker
3 * per detected onset. Markers audition on click and can be excluded (drawn
4 * hollow) before the hits are committed to a voice's training set, so a
5 * cough or a spurious detection is visible and removable rather than
6 * silently poisoning the label.
7 */
8import type { Clip } from '../audio/recorder.ts';
10export interface Marker {
11 t: number;
12 excluded: boolean;
15const BG = '#16181d';
16const WAVE = '#5b8dbf';
18export class WaveView {
19 private canvas: HTMLCanvasElement;
20 private clip: Clip | null = null;
21 private markers: Marker[] = [];
22 private color = '#e4573d';
23 selected = -1;
24 onMarkerClick: ((index: number) => void) | null = null;
26 constructor(canvas: HTMLCanvasElement) {
27 this.canvas = canvas;
28 canvas.addEventListener('pointerdown', (e) => {
29 if (!this.clip) return;
30 const rect = canvas.getBoundingClientRect();
31 const x = e.clientX - rect.left;
32 const dur = this.clip.samples.length / this.clip.sr;
33 let best = -1, bestDx = 8;
34 for (let i = 0; i < this.markers.length; i++) {
35 const mx = (this.markers[i].t / dur) * rect.width;
36 const dx = Math.abs(mx - x);
37 if (dx < bestDx) { best = i; bestDx = dx; }
38 }
39 if (best >= 0) this.onMarkerClick?.(best);
40 });
41 new ResizeObserver(() => this.draw()).observe(canvas);
42 }
44 set(clip: Clip | null, markers: Marker[], color: string): void {
45 this.clip = clip;
46 this.markers = markers;
47 this.color = color;
48 this.selected = -1;
49 this.draw();
50 }
52 draw(): void {
53 const c = this.canvas;
54 const dpr = devicePixelRatio || 1;
55 const w = c.clientWidth, h = c.clientHeight;
56 if (w === 0) return;
57 if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) {
58 c.width = Math.round(w * dpr);
59 c.height = Math.round(h * dpr);
60 }
61 const g = c.getContext('2d')!;
62 g.setTransform(dpr, 0, 0, dpr, 0, 0);
63 g.fillStyle = BG;
64 g.fillRect(0, 0, w, h);
65 if (!this.clip) return;
67 const { samples, sr } = this.clip;
68 const dur = samples.length / sr;
69 const mid = h / 2;
70 g.strokeStyle = WAVE;
71 g.lineWidth = 1;
72 g.beginPath();
73 for (let px = 0; px < w; px++) {
74 const i0 = Math.floor((px / w) * samples.length);
75 const i1 = Math.floor(((px + 1) / w) * samples.length);
76 let lo = 0, hi = 0;
77 for (let i = i0; i < i1; i++) {
78 if (samples[i] < lo) lo = samples[i];
79 if (samples[i] > hi) hi = samples[i];
80 }
81 g.moveTo(px + 0.5, mid - hi * mid * 0.92 - 0.5);
82 g.lineTo(px + 0.5, mid - lo * mid * 0.92 + 0.5);
83 }
84 g.stroke();
86 for (let i = 0; i < this.markers.length; i++) {
87 const m = this.markers[i];
88 const x = (m.t / dur) * w;
89 g.strokeStyle = this.color;
90 g.lineWidth = i === this.selected ? 2.5 : 1.2;
91 g.globalAlpha = m.excluded ? 0.35 : 1;
92 g.beginPath();
93 g.moveTo(x, 2);
94 g.lineTo(x, h - 2);
95 g.stroke();
96 g.beginPath();
97 g.arc(x, 8, 4.5, 0, 2 * Math.PI);
98 if (m.excluded) g.stroke();
99 else { g.fillStyle = this.color; g.fill(); }
100 g.globalAlpha = 1;
101 }
102 }
↑↓ move↵openescclose