/ proof-of-concept / audio-spike-sorting
Sign in
proof-of-concept / audio-spike-sorting
audio-spike-sorting / src / ui / waveform.ts
199 lines · 6.3 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;
3b66b1dPlay recorded clips in the clip editor, via a Player shared with the explorerJeremy Magland 25 private playT: number | null = null;
27 onAudition: ((index: number) => void) | null = null;
28 onSelectionChange: (() => void) | null = null;
30 constructor(canvas: HTMLCanvasElement) {
31 this.canvas = canvas;
32 canvas.addEventListener('pointerdown', (e) => this.pointerDown(e));
33 canvas.addEventListener('pointermove', (e) => this.pointerMove(e));
34 canvas.addEventListener('pointerup', (e) => this.pointerUp(e));
35 new ResizeObserver(() => this.draw()).observe(canvas);
36 }
38 set(clip: Clip | null, markers: Marker[], color: string): void {
39 this.clip = clip;
40 this.markers = markers;
41 this.color = color;
42 this.selection.clear();
43 this.draw();
44 }
46 /** Update markers without dropping the selection (indices unchanged). */
47 updateMarkers(markers: Marker[]): void {
48 this.markers = markers;
49 this.draw();
50 }
3b66b1dPlay recorded clips in the clip editor, via a Player shared with the explorerJeremy Magland 52 /** Playback cursor position in seconds; null hides it. */
53 setPlayhead(t: number | null): void {
54 this.playT = t;
55 this.draw();
56 }
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 58 private markerX(i: number, w: number): number {
59 const dur = this.clip ? this.clip.samples.length / this.clip.sr : 1;
60 return (this.markers[i].t / dur) * w;
61 }
63 private markerAt(x: number): number {
64 const w = this.canvas.getBoundingClientRect().width;
65 let best = -1, bestDx = 8;
66 for (let i = 0; i < this.markers.length; i++) {
67 const dx = Math.abs(this.markerX(i, w) - x);
68 if (dx < bestDx) { best = i; bestDx = dx; }
69 }
70 return best;
71 }
73 private pointerDown(e: PointerEvent): void {
74 if (!this.clip) return;
75 const x = e.clientX - this.canvas.getBoundingClientRect().left;
76 const additive = e.shiftKey || e.ctrlKey || e.metaKey;
77 const i = this.markerAt(x);
78 if (i >= 0) {
79 if (additive) {
80 if (this.selection.has(i)) this.selection.delete(i);
81 else this.selection.add(i);
82 } else {
83 this.selection = new Set([i]);
84 this.onAudition?.(i);
85 }
86 this.onSelectionChange?.();
87 this.draw();
88 } else {
89 this.band = { x0: x, x1: x, base: additive ? new Set(this.selection) : new Set(), moved: false, additive };
90 this.canvas.setPointerCapture(e.pointerId);
91 }
92 }
94 private pointerMove(e: PointerEvent): void {
95 if (!this.band) return;
96 const x = e.clientX - this.canvas.getBoundingClientRect().left;
97 this.band.x1 = x;
98 if (Math.abs(x - this.band.x0) > 3) this.band.moved = true;
99 const lo = Math.min(this.band.x0, this.band.x1);
100 const hi = Math.max(this.band.x0, this.band.x1);
101 const w = this.canvas.getBoundingClientRect().width;
102 this.selection = new Set(this.band.base);
103 for (let i = 0; i < this.markers.length; i++) {
104 const mx = this.markerX(i, w);
105 if (mx >= lo && mx <= hi) this.selection.add(i);
106 }
107 this.onSelectionChange?.();
108 this.draw();
109 }
111 private pointerUp(e: PointerEvent): void {
112 if (!this.band) return;
113 if (!this.band.moved && !this.band.additive && this.selection.size) {
114 this.selection.clear(); // a plain click on empty space clears
115 this.onSelectionChange?.();
116 }
117 this.band = null;
118 if (this.canvas.hasPointerCapture(e.pointerId)) this.canvas.releasePointerCapture(e.pointerId);
119 this.draw();
120 }
122 draw(): void {
123 const c = this.canvas;
124 const dpr = devicePixelRatio || 1;
125 const w = c.clientWidth, h = c.clientHeight;
126 if (w === 0) return;
127 if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) {
128 c.width = Math.round(w * dpr);
129 c.height = Math.round(h * dpr);
130 }
131 const g = c.getContext('2d')!;
132 g.setTransform(dpr, 0, 0, dpr, 0, 0);
133 g.fillStyle = BG;
134 g.fillRect(0, 0, w, h);
135 if (!this.clip) return;
137 const { samples } = this.clip;
138 const mid = h / 2;
139 g.strokeStyle = WAVE;
140 g.lineWidth = 1;
141 g.beginPath();
142 for (let px = 0; px < w; px++) {
143 const i0 = Math.floor((px / w) * samples.length);
144 const i1 = Math.floor(((px + 1) / w) * samples.length);
145 let lo = 0, hi = 0;
146 for (let i = i0; i < i1; i++) {
147 if (samples[i] < lo) lo = samples[i];
148 if (samples[i] > hi) hi = samples[i];
149 }
150 g.moveTo(px + 0.5, mid - hi * mid * 0.92 - 0.5);
151 g.lineTo(px + 0.5, mid - lo * mid * 0.92 + 0.5);
152 }
153 g.stroke();
155 for (let i = 0; i < this.markers.length; i++) {
156 const m = this.markers[i];
157 const x = this.markerX(i, w);
158 const sel = this.selection.has(i);
159 g.strokeStyle = this.color;
160 g.lineWidth = sel ? 2.5 : 1.2;
161 g.globalAlpha = m.excluded ? 0.35 : 1;
162 g.beginPath();
163 g.moveTo(x, 2);
164 g.lineTo(x, h - 2);
165 g.stroke();
166 g.beginPath();
167 g.arc(x, 8, 4.5, 0, 2 * Math.PI);
168 if (m.excluded) g.stroke();
169 else { g.fillStyle = this.color; g.fill(); }
170 if (sel) {
171 g.strokeStyle = '#fff';
172 g.lineWidth = 1.5;
173 g.beginPath();
174 g.arc(x, 8, 6.5, 0, 2 * Math.PI);
175 g.stroke();
176 }
177 g.globalAlpha = 1;
178 }
3b66b1dPlay recorded clips in the clip editor, via a Player shared with the explorerJeremy Magland 180 if (this.playT !== null && this.clip) {
181 const dur = this.clip.samples.length / this.clip.sr;
182 const x = (this.playT / dur) * w;
183 g.strokeStyle = 'rgba(61, 187, 110, 0.9)';
184 g.lineWidth = 1.5;
185 g.beginPath();
186 g.moveTo(x, 0);
187 g.lineTo(x, h);
188 g.stroke();
189 }
192 const lo = Math.min(this.band.x0, this.band.x1);
193 g.fillStyle = 'rgba(120, 170, 255, 0.15)';
194 g.strokeStyle = 'rgba(120, 170, 255, 0.5)';
195 g.fillRect(lo, 0, Math.abs(this.band.x1 - this.band.x0), h);
196 g.strokeRect(lo + 0.5, 0.5, Math.abs(this.band.x1 - this.band.x0) - 1, h - 1);
197 }
198 }
↑↓ move↵openescclose