/ proof-of-concept / audio-spike-sorting
Sign in
proof-of-concept / audio-spike-sorting
audio-spike-sorting / src / ui / waveform.ts
181 lines · 5.9 KBCodeBlameHistory
2 * The training-clip editor's view: one clip as a waveform with a marker
3 * per detected onset. Click selects one marker and auditions it;
4 * shift- or ctrl-click toggles a marker in the selection; dragging over
5 * empty space sweeps a rubber-band selection. Excluded markers draw
6 * hollow. Exclusion itself is the owner's business (a button or the X
7 * key); this view only manages what is selected.
8 */
9import type { Clip } from '../audio/recorder.ts';
11export interface Marker {
12 t: number;
13 excluded: boolean;
16const BG = '#16181d';
17const WAVE = '#5b8dbf';
19export class WaveView {
20 private canvas: HTMLCanvasElement;
21 private clip: Clip | null = null;
22 private markers: Marker[] = [];
23 private color = '#e4573d';
24 private band: { x0: number; x1: number; base: Set<number>; moved: boolean; additive: boolean } | null = null;
25 selection = new Set<number>();
26 onAudition: ((index: number) => void) | null = null;
27 onSelectionChange: (() => void) | null = null;
29 constructor(canvas: HTMLCanvasElement) {
30 this.canvas = canvas;
31 canvas.addEventListener('pointerdown', (e) => this.pointerDown(e));
32 canvas.addEventListener('pointermove', (e) => this.pointerMove(e));
33 canvas.addEventListener('pointerup', (e) => this.pointerUp(e));
34 new ResizeObserver(() => this.draw()).observe(canvas);
35 }
37 set(clip: Clip | null, markers: Marker[], color: string): void {
38 this.clip = clip;
39 this.markers = markers;
40 this.color = color;
41 this.selection.clear();
42 this.draw();
43 }
45 /** Update markers without dropping the selection (indices unchanged). */
46 updateMarkers(markers: Marker[]): void {
47 this.markers = markers;
48 this.draw();
49 }
51 private markerX(i: number, w: number): number {
52 const dur = this.clip ? this.clip.samples.length / this.clip.sr : 1;
53 return (this.markers[i].t / dur) * w;
54 }
56 private markerAt(x: number): number {
57 const w = this.canvas.getBoundingClientRect().width;
58 let best = -1, bestDx = 8;
59 for (let i = 0; i < this.markers.length; i++) {
60 const dx = Math.abs(this.markerX(i, w) - x);
61 if (dx < bestDx) { best = i; bestDx = dx; }
62 }
63 return best;
64 }
66 private pointerDown(e: PointerEvent): void {
67 if (!this.clip) return;
68 const x = e.clientX - this.canvas.getBoundingClientRect().left;
69 const additive = e.shiftKey || e.ctrlKey || e.metaKey;
70 const i = this.markerAt(x);
71 if (i >= 0) {
72 if (additive) {
73 if (this.selection.has(i)) this.selection.delete(i);
74 else this.selection.add(i);
75 } else {
76 this.selection = new Set([i]);
77 this.onAudition?.(i);
78 }
79 this.onSelectionChange?.();
80 this.draw();
81 } else {
82 this.band = { x0: x, x1: x, base: additive ? new Set(this.selection) : new Set(), moved: false, additive };
83 this.canvas.setPointerCapture(e.pointerId);
84 }
85 }
87 private pointerMove(e: PointerEvent): void {
88 if (!this.band) return;
89 const x = e.clientX - this.canvas.getBoundingClientRect().left;
90 this.band.x1 = x;
91 if (Math.abs(x - this.band.x0) > 3) this.band.moved = true;
92 const lo = Math.min(this.band.x0, this.band.x1);
93 const hi = Math.max(this.band.x0, this.band.x1);
94 const w = this.canvas.getBoundingClientRect().width;
95 this.selection = new Set(this.band.base);
96 for (let i = 0; i < this.markers.length; i++) {
97 const mx = this.markerX(i, w);
98 if (mx >= lo && mx <= hi) this.selection.add(i);
99 }
100 this.onSelectionChange?.();
101 this.draw();
102 }
104 private pointerUp(e: PointerEvent): void {
105 if (!this.band) return;
106 if (!this.band.moved && !this.band.additive && this.selection.size) {
107 this.selection.clear(); // a plain click on empty space clears
108 this.onSelectionChange?.();
109 }
110 this.band = null;
111 if (this.canvas.hasPointerCapture(e.pointerId)) this.canvas.releasePointerCapture(e.pointerId);
112 this.draw();
113 }
115 draw(): void {
116 const c = this.canvas;
117 const dpr = devicePixelRatio || 1;
118 const w = c.clientWidth, h = c.clientHeight;
119 if (w === 0) return;
120 if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) {
121 c.width = Math.round(w * dpr);
122 c.height = Math.round(h * dpr);
123 }
124 const g = c.getContext('2d')!;
125 g.setTransform(dpr, 0, 0, dpr, 0, 0);
126 g.fillStyle = BG;
127 g.fillRect(0, 0, w, h);
128 if (!this.clip) return;
130 const { samples } = this.clip;
131 const mid = h / 2;
132 g.strokeStyle = WAVE;
133 g.lineWidth = 1;
134 g.beginPath();
135 for (let px = 0; px < w; px++) {
136 const i0 = Math.floor((px / w) * samples.length);
137 const i1 = Math.floor(((px + 1) / w) * samples.length);
138 let lo = 0, hi = 0;
139 for (let i = i0; i < i1; i++) {
140 if (samples[i] < lo) lo = samples[i];
141 if (samples[i] > hi) hi = samples[i];
142 }
143 g.moveTo(px + 0.5, mid - hi * mid * 0.92 - 0.5);
144 g.lineTo(px + 0.5, mid - lo * mid * 0.92 + 0.5);
145 }
146 g.stroke();
148 for (let i = 0; i < this.markers.length; i++) {
149 const m = this.markers[i];
150 const x = this.markerX(i, w);
151 const sel = this.selection.has(i);
152 g.strokeStyle = this.color;
153 g.lineWidth = sel ? 2.5 : 1.2;
154 g.globalAlpha = m.excluded ? 0.35 : 1;
155 g.beginPath();
156 g.moveTo(x, 2);
157 g.lineTo(x, h - 2);
158 g.stroke();
159 g.beginPath();
160 g.arc(x, 8, 4.5, 0, 2 * Math.PI);
161 if (m.excluded) g.stroke();
162 else { g.fillStyle = this.color; g.fill(); }
163 if (sel) {
164 g.strokeStyle = '#fff';
165 g.lineWidth = 1.5;
166 g.beginPath();
167 g.arc(x, 8, 6.5, 0, 2 * Math.PI);
168 g.stroke();
169 }
170 g.globalAlpha = 1;
171 }
173 if (this.band) {
174 const lo = Math.min(this.band.x0, this.band.x1);
175 g.fillStyle = 'rgba(120, 170, 255, 0.15)';
176 g.strokeStyle = 'rgba(120, 170, 255, 0.5)';
177 g.fillRect(lo, 0, Math.abs(this.band.x1 - this.band.x0), h);
178 g.strokeRect(lo + 0.5, 0.5, Math.abs(this.band.x1 - this.band.x0) - 1, h - 1);
179 }
180 }
↑↓ move↵openescclose