/ proof-of-concept / audio-spike-sorting
Sign in
proof-of-concept / audio-spike-sorting
audio-spike-sorting / src / main.ts
864 lines · 31.4 KBCodeBlameHistory
2 * The page: event types (named, trained by recorded example clips whose
3 * detected onsets stay editable), a benchmark configuration, algorithms
4 * (MATLAB-syntax sources run by numbl in a worker), the run loop with
5 * scores and confusion matrices, and the explorer (ground truth against
6 * one algorithm's detections, with every error visible and auditionable).
7 */
8import { Recorder, type Clip } from './audio/recorder.ts';
3b66b1dPlay recorded clips in the clip editor, via a Player shared with the explorerJeremy Magland 9import { Player } from './audio/player.ts';
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 10import { detectOnsets } from './analysis/onsets.ts';
11import { BUILTIN_ALGORITHMS, NEW_ALGORITHM_TEMPLATE } from './algorithms/index.ts';
12import { extractLibrary, snippetCounts } from './bench/snippets.ts';
13import { BenchmarkRunner } from './bench/runner.ts';
14import { FS, type AlgorithmDef, type RunResult } from './bench/types.ts';
17 clearAll, deleteClipAudio, getBenchmark, getClipAudio, loadDoc, putBenchmark, putClipAudio,
18 saveDoc, DEFAULT_CONFIG, type BenchConfig, type EventType, type StoredBenchmark,
19} from './store.ts';
20import { WaveView, type Marker } from './ui/waveform.ts';
21import { Explorer } from './ui/explorer.ts';
23const PALETTE = ['#e4573d', '#3d7de4', '#3dbb6e', '#d9a520', '#9061d9', '#d95fa8', '#4fb8c9', '#96a13a'];
25const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
26const uid = () => Math.random().toString(36).slice(2, 10);
28// ---- state ------------------------------------------------------------------
30const doc = loadDoc();
31// Built-ins are merged in by id, so an edited built-in keeps its edits and
32// a fresh store gets all three.
33for (const b of BUILTIN_ALGORITHMS) {
34 if (!doc.algorithms.some((a) => a.id === b.id)) doc.algorithms.push({ ...b });
36const clipAudio = new Map<string, { samples: Float32Array; sr: number }>();
37let bench: StoredBenchmark | null = null;
39let actx: AudioContext | null = null;
40const audio = (): AudioContext => (actx ??= new AudioContext());
41let recorder: Recorder | null = null;
42let recTypeId: string | null = null;
43let meterTimer = 0;
44let edit: { typeId: string; clipId: string } | null = null;
45let editingAlgoId: string | null = null;
46const runner = new BenchmarkRunner();
48function save(): void { saveDoc(doc); }
49function status(msg: string): void { $('status').textContent = msg; }
50function typeById(id: string): EventType | undefined { return doc.types.find((t) => t.id === id); }
51const getAudio = (clipId: string) => clipAudio.get(clipId) ?? null;
53// ---- audition ----------------------------------------------------------------
55function playSlice(samples: Float32Array, sr: number, s0: number, s1: number): void {
56 const ctx = audio();
57 const n = Math.min(s1, samples.length) - Math.max(s0, 0);
58 if (n <= 0) return;
59 const buf = ctx.createBuffer(1, n, sr);
60 buf.getChannelData(0).set(samples.subarray(Math.max(s0, 0), Math.max(s0, 0) + n));
61 const src = ctx.createBufferSource();
62 src.buffer = buf;
63 src.connect(ctx.destination);
64 src.start();
67// ---- event types ---------------------------------------------------------------
69function renderTypes(): void {
70 const list = $('type-list');
71 list.innerHTML = '';
72 const counts = snippetCounts(doc.types, doc.config.preMs, doc.config.postMs, getAudio);
73 doc.types.forEach((type) => {
74 const row = document.createElement('div');
75 row.className = 'type-row';
77 const swatch = document.createElement('div');
78 swatch.className = 'swatch';
79 swatch.style.background = type.color;
80 row.appendChild(swatch);
82 const name = document.createElement('input');
83 name.className = 'name';
84 name.value = type.name;
85 name.onchange = () => { type.name = name.value.trim() || type.name; save(); };
86 row.appendChild(name);
88 const count = document.createElement('span');
89 count.className = 'count';
90 const nClips = type.clips.length;
91 count.textContent = `${counts.get(type.id) ?? 0} snippets in ${nClips} clip${nClips === 1 ? '' : 's'}`;
92 row.appendChild(count);
94 const rec = document.createElement('button');
95 const recording = recTypeId === type.id;
96 rec.innerHTML = recording ? '<span class="rec">&#9632;</span> stop' : '&#9679; record examples';
97 rec.disabled = recTypeId !== null && !recording;
98 rec.onclick = () => (recording ? stopRecording() : startRecording(type.id));
99 row.appendChild(rec);
101 if (type.clips.length) {
102 const editBtn = document.createElement('button');
103 editBtn.textContent = 'edit clips';
104 editBtn.onclick = () => openClipEditor(type.id, type.clips[type.clips.length - 1].id);
105 row.appendChild(editBtn);
106 }
108 const spacer = document.createElement('span');
109 spacer.className = 'spacer';
110 row.appendChild(spacer);
112 const del = document.createElement('button');
113 del.textContent = 'delete';
114 del.onclick = async () => {
115 if (!confirm(`Delete event type "${type.name}" and its ${type.clips.length} clip(s)?`)) return;
116 for (const c of type.clips) { await deleteClipAudio(c.id); clipAudio.delete(c.id); }
117 doc.types = doc.types.filter((t) => t.id !== type.id);
118 if (edit?.typeId === type.id) closeClipEditor();
119 save();
120 renderTypes();
121 };
122 row.appendChild(del);
124 list.appendChild(row);
125 });
128$('add-type').onclick = () => {
129 const nameInput = $('type-name') as HTMLInputElement;
130 const name = nameInput.value.trim();
131 if (!name) { status('give the event type a name first'); return; }
132 doc.types.push({ id: uid(), name, color: PALETTE[doc.types.length % PALETTE.length], clips: [] });
133 nameInput.value = '';
134 save();
135 renderTypes();
136};
138$('load-demo').onclick = async () => {
139 const { makeDemoData, demoTypes } = await import('./demo.ts');
140 const clips = makeDemoData();
141 const types = demoTypes(clips);
142 for (const c of clips) {
143 clipAudio.set(c.meta.id, { samples: c.samples, sr: FS });
144 await putClipAudio(c.meta.id, c.samples, FS);
145 }
146 doc.types.push(...types);
147 save();
148 renderTypes();
149 status('demo data loaded: three synthetic event types, ready to benchmark');
150};
152// ---- recording ---------------------------------------------------------------
154async function startRecording(typeId: string): Promise<void> {
155 recorder = new Recorder(audio());
156 try {
157 await recorder.start();
158 } catch (err) {
159 status(`microphone: ${(err as Error).message}`);
160 recorder = null;
161 return;
162 }
163 recTypeId = typeId;
164 const meter = $('meter');
165 meter.hidden = false;
166 const bar = meter.firstElementChild as HTMLElement;
167 meterTimer = window.setInterval(() => { bar.style.width = `${(recorder?.level() ?? 0) * 100}%`; }, 60);
168 status(`recording examples of "${typeById(typeId)?.name}"; separate events by at least 200 ms`);
169 renderTypes();
172async function stopRecording(): Promise<void> {
173 if (!recorder || !recTypeId) return;
174 const typeId = recTypeId;
175 recTypeId = null;
176 clearInterval(meterTimer);
177 $('meter').hidden = true;
178 let clip: Clip;
179 try {
180 clip = await recorder.stop(FS);
181 } catch (err) {
182 status(`recording failed: ${(err as Error).message}`);
183 recorder = null;
184 renderTypes();
185 return;
186 }
187 recorder = null;
188 const type = typeById(typeId);
189 if (!type) return;
190 const sens = 0.6;
191 const onsets = detectOnsets(clip.samples, clip.sr, sens);
192 const clipId = uid();
193 clipAudio.set(clipId, { samples: clip.samples, sr: clip.sr });
194 await putClipAudio(clipId, clip.samples, clip.sr);
195 type.clips.push({ id: clipId, sens, onsets, excluded: [] });
196 save();
197 renderTypes();
198 openClipEditor(typeId, clipId);
199 status(`${onsets.length} events detected; audition and exclude bad ones, then Done`);
202// ---- clip editor ---------------------------------------------------------------
204const waveView = new WaveView($('clip-wave') as HTMLCanvasElement);
3b66b1dPlay recorded clips in the clip editor, via a Player shared with the explorerJeremy Magland 206const clipPlayer = new Player(audio);
207clipPlayer.onTick = (t) => waveView.setPlayhead(t);
208clipPlayer.onStateChange = () => {
209 $('clip-play').innerHTML = clipPlayer.playing ? '&#9632; Stop' : '&#9654; Play';
210};
211$('clip-play').onclick = () => {
212 if (clipPlayer.playing) { clipPlayer.stop(); return; }
213 const cur = currentClip();
214 if (!cur) return;
215 clipPlayer.start(cur.a.samples, cur.a.sr, 0, cur.a.samples.length / cur.a.sr);
216};
219 if (!edit) return null;
220 const type = typeById(edit.typeId);
221 const meta = type?.clips.find((c) => c.id === edit!.clipId);
222 const a = meta ? clipAudio.get(meta.id) : null;
223 return type && meta && a ? { type, meta, a } : null;
226function markersOf(meta: { onsets: number[]; excluded: number[] }): Marker[] {
227 const ex = new Set(meta.excluded);
228 return meta.onsets.map((t, i) => ({ t, excluded: ex.has(i) }));
231function openClipEditor(typeId: string, clipId: string): void {
234 const cur = currentClip();
235 if (!cur) { closeClipEditor(); return; }
236 $('clip-editor').hidden = false;
237 $('clip-title').textContent = cur.type.name;
238 const sel = $('clip-select') as HTMLSelectElement;
239 sel.innerHTML = '';
240 cur.type.clips.forEach((c, i) => {
241 const o = document.createElement('option');
242 o.value = c.id;
243 o.textContent = `clip ${i + 1} (${c.onsets.length} events)`;
244 o.selected = c.id === clipId;
245 sel.appendChild(o);
246 });
247 ($('clip-sens') as HTMLInputElement).value = String(cur.meta.sens);
248 waveView.set({ samples: cur.a.samples, sr: cur.a.sr }, markersOf(cur.meta), cur.type.color);
249 updateClipCount();
252function closeClipEditor(): void {
255 $('clip-editor').hidden = true;
258function updateClipCount(): void {
259 const cur = currentClip();
260 if (!cur) return;
261 const kept = cur.meta.onsets.length - cur.meta.excluded.length;
262 $('clip-count').textContent = `${kept} of ${cur.meta.onsets.length} events kept`;
263 ($('clip-exclude') as HTMLButtonElement).disabled = waveView.selection.size === 0;
266waveView.onAudition = (i) => {
267 const cur = currentClip();
268 if (!cur) return;
269 const s0 = Math.max(0, Math.round((cur.meta.onsets[i] - 0.02) * cur.a.sr));
270 playSlice(cur.a.samples, cur.a.sr, s0, s0 + Math.round(0.3 * cur.a.sr));
271};
272waveView.onSelectionChange = () => updateClipCount();
274($('clip-select') as HTMLSelectElement).onchange = (e) => {
275 if (edit) openClipEditor(edit.typeId, (e.target as HTMLSelectElement).value);
276};
278($('clip-sens') as HTMLInputElement).oninput = () => {
279 const cur = currentClip();
280 if (!cur) return;
281 cur.meta.sens = Number(($('clip-sens') as HTMLInputElement).value);
282 cur.meta.onsets = detectOnsets(cur.a.samples, cur.a.sr, cur.meta.sens);
283 cur.meta.excluded = [];
284 save();
285 waveView.set({ samples: cur.a.samples, sr: cur.a.sr }, markersOf(cur.meta), cur.type.color);
286 updateClipCount();
287 renderTypes();
288};
290function toggleExcludeSelection(): void {
291 const cur = currentClip();
292 if (!cur || waveView.selection.size === 0) return;
293 const ex = new Set(cur.meta.excluded);
294 const allExcluded = [...waveView.selection].every((i) => ex.has(i));
295 for (const i of waveView.selection) {
296 if (allExcluded) ex.delete(i); else ex.add(i);
297 }
298 cur.meta.excluded = [...ex].sort((a, b) => a - b);
299 save();
300 waveView.updateMarkers(markersOf(cur.meta));
301 updateClipCount();
302 renderTypes();
305$('clip-exclude').onclick = toggleExcludeSelection;
306document.addEventListener('keydown', (e) => {
307 if (e.key.toLowerCase() === 'x' && edit && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)) {
308 toggleExcludeSelection();
309 }
310});
312$('clip-delete').onclick = async () => {
313 const cur = currentClip();
314 if (!cur) return;
315 if (!confirm('Delete this clip and its events?')) return;
316 await deleteClipAudio(cur.meta.id);
317 clipAudio.delete(cur.meta.id);
318 cur.type.clips = cur.type.clips.filter((c) => c.id !== cur.meta.id);
319 save();
320 renderTypes();
321 if (cur.type.clips.length) openClipEditor(cur.type.id, cur.type.clips[cur.type.clips.length - 1].id);
322 else closeClipEditor();
323};
325$('clip-done').onclick = closeClipEditor;
327// ---- configuration -------------------------------------------------------------
329interface ConfigField {
330 key: keyof BenchConfig;
331 label: string;
332 min: number; max: number; step: number;
335const CONFIG_FIELDS: ConfigField[] = [
336 { key: 'durationS', label: 'dataset duration (s)', min: 5, max: 600, step: 1 },
337 { key: 'eventsPerType', label: 'events per type', min: 1, max: 2000, step: 1 },
339 key: 'gainMinDb', label: 'level variation (dB)', min: -40, max: 0, step: 1,
340 title: 'Each placed event is scaled by a random gain drawn between this many dB below its recorded volume and 0 dB. 0 keeps every event at its original volume.',
341 },
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 342 { key: 'overlap', label: 'overlap fraction (0-1)', min: 0, max: 1, step: 0.05 },
344 key: 'coincidence', label: 'coincident fraction (0-1)', min: 0, max: 1, step: 0.05,
345 title: 'Of the overlapping events, the fraction placed at their host event\u2019s onset rather than at a random offset inside its span, so the two onsets are separated by at most the coincidence window below. 0 leaves every overlapping event at a random offset.',
346 },
347 {
348 key: 'coincidenceMs', label: 'coincidence window (ms)', min: 0, max: 100, step: 1,
349 title: 'Half-width of the coincident placement window: a coincident event starts within this many milliseconds either side of its host. 0 places the two events at exactly the same sample.',
350 },
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 351 { key: 'trainFraction', label: 'training fraction (0-1)', min: 0.05, max: 0.95, step: 0.05 },
352 { key: 'runs', label: 'runs', min: 1, max: 50, step: 1 },
353 { key: 'seed', label: 'base seed', min: 0, max: 1e9, step: 1 },
354 { key: 'toleranceMs', label: 'match tolerance (ms)', min: 1, max: 200, step: 1 },
355 { key: 'preMs', label: 'snippet pre-onset (ms)', min: 0, max: 100, step: 5 },
356 { key: 'postMs', label: 'snippet post-onset (ms)', min: 20, max: 1000, step: 10 },
357];
359function renderConfig(): void {
360 const grid = $('config-grid');
361 grid.innerHTML = '';
362 for (const f of CONFIG_FIELDS) {
363 const label = document.createElement('label');
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 365 const span = document.createElement('span');
366 span.textContent = f.label;
367 const input = document.createElement('input');
368 input.type = 'number';
369 input.min = String(f.min); input.max = String(f.max); input.step = String(f.step);
370 input.value = String(doc.config[f.key]);
371 input.onchange = () => {
372 const v = Number(input.value);
373 if (Number.isFinite(v)) {
374 (doc.config[f.key] as number) = Math.min(f.max, Math.max(f.min, v));
375 input.value = String(doc.config[f.key]);
376 save();
377 if (f.key === 'preMs' || f.key === 'postMs') renderTypes();
378 }
379 };
380 label.append(span, input);
381 grid.appendChild(label);
382 }
385// ---- algorithms ----------------------------------------------------------------
387function algoById(id: string): AlgorithmDef | undefined { return doc.algorithms.find((a) => a.id === id); }
389function renderAlgos(): void {
390 const list = $('algo-list');
391 list.innerHTML = '';
392 doc.algorithms.forEach((algo) => {
393 const row = document.createElement('div');
394 row.className = 'algo-row';
396 const enable = document.createElement('input');
397 enable.type = 'checkbox';
398 enable.checked = algo.enabled;
399 enable.onchange = () => { algo.enabled = enable.checked; save(); };
400 row.appendChild(enable);
402 const name = document.createElement('strong');
403 name.textContent = algo.name;
404 row.appendChild(name);
406 if (algo.builtin) {
407 const tag = document.createElement('span');
408 tag.className = 'tag';
409 tag.textContent = 'built-in';
410 row.appendChild(tag);
411 }
413 const spacer = document.createElement('span');
414 spacer.className = 'spacer';
415 row.appendChild(spacer);
417 const editBtn = document.createElement('button');
418 editBtn.textContent = 'edit';
419 editBtn.onclick = () => openAlgoEditor(algo.id);
420 row.appendChild(editBtn);
422 list.appendChild(row);
423 });
426function openAlgoEditor(id: string): void {
427 const algo = algoById(id);
428 if (!algo) return;
429 editingAlgoId = id;
430 $('algo-editor').hidden = false;
431 const nameInput = $('algo-name') as HTMLInputElement;
432 nameInput.value = algo.name;
433 nameInput.disabled = !!algo.builtin;
434 ($('algo-source') as HTMLTextAreaElement).value = algo.source;
435 ($('algo-reset') as HTMLButtonElement).hidden = !algo.builtin;
436 ($('algo-remove') as HTMLButtonElement).hidden = !!algo.builtin;
437 $('algo-editor').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
440($('algo-name') as HTMLInputElement).onchange = () => {
441 const algo = editingAlgoId ? algoById(editingAlgoId) : null;
442 if (!algo || algo.builtin) return;
443 algo.name = ($('algo-name') as HTMLInputElement).value.trim() || algo.name;
444 save();
445 renderAlgos();
446};
448($('algo-source') as HTMLTextAreaElement).onchange = () => {
449 const algo = editingAlgoId ? algoById(editingAlgoId) : null;
450 if (!algo) return;
451 algo.source = ($('algo-source') as HTMLTextAreaElement).value;
452 save();
453};
455$('algo-reset').onclick = () => {
456 const algo = editingAlgoId ? algoById(editingAlgoId) : null;
457 const builtin = BUILTIN_ALGORITHMS.find((b) => b.id === algo?.id);
458 if (!algo || !builtin) return;
459 algo.source = builtin.source;
460 ($('algo-source') as HTMLTextAreaElement).value = algo.source;
461 save();
462};
464$('algo-remove').onclick = () => {
465 const algo = editingAlgoId ? algoById(editingAlgoId) : null;
466 if (!algo || algo.builtin) return;
467 if (!confirm(`Delete algorithm "${algo.name}"?`)) return;
468 doc.algorithms = doc.algorithms.filter((a) => a.id !== algo.id);
469 editingAlgoId = null;
470 $('algo-editor').hidden = true;
471 save();
472 renderAlgos();
473};
475$('algo-close').onclick = () => {
476 editingAlgoId = null;
477 $('algo-editor').hidden = true;
478};
480$('add-algo').onclick = () => {
481 const algo: AlgorithmDef = {
482 id: uid(),
483 name: `my_sorter_${doc.algorithms.length}`,
484 source: NEW_ALGORITHM_TEMPLATE,
485 enabled: true,
486 };
487 doc.algorithms.push(algo);
488 save();
489 renderAlgos();
490 openAlgoEditor(algo.id);
491};
493// ---- benchmark -----------------------------------------------------------------
495$('run-bench').onclick = async () => {
496 const enabled = doc.algorithms.filter((a) => a.enabled);
497 if (!enabled.length) { status('enable at least one algorithm'); return; }
498 const lib = extractLibrary(doc.types, doc.config.preMs, doc.config.postMs, getAudio);
499 const perType = doc.types.map((t, i) => lib.snippets.filter((s) => s.typeIndex === i).length);
500 if (!doc.types.length || perType.some((n) => n < 2)) {
501 status('every event type needs at least 2 usable snippets (one to train, one to place)');
502 return;
503 }
505 ($('run-bench') as HTMLButtonElement).disabled = true;
506 $('cancel-bench').hidden = false;
507 const progress = $('bench-progress') as HTMLProgressElement;
508 progress.hidden = false;
510 try {
511 const out = await runner.run(
512 lib, doc.config, doc.config.runs, doc.config.toleranceMs, enabled,
513 (p) => {
514 progress.max = p.total;
515 progress.value = p.done;
516 $('bench-label').textContent = `run ${p.runIndex + 1}/${p.runCount}: ${p.algorithmName}`;
517 },
518 );
519 if (!runner.cancelled) {
520 bench = {
521 when: Date.now(),
522 names: doc.types.map((t) => t.name),
523 colors: doc.types.map((t) => t.color),
524 algos: enabled.map((a) => ({ id: a.id, name: a.name })),
525 config: { ...doc.config },
526 runs: out.runs,
527 results: out.results,
528 };
529 await putBenchmark(bench);
530 renderResults();
531 populateExplorerControls();
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 533 status(`benchmark complete: ${out.results.length} algorithm runs`);
534 } else {
535 status('benchmark cancelled');
536 }
537 } catch (err) {
538 status(`benchmark failed: ${(err as Error).message}`);
539 } finally {
540 ($('run-bench') as HTMLButtonElement).disabled = false;
541 $('cancel-bench').hidden = true;
542 progress.hidden = true;
543 $('bench-label').textContent = '';
544 }
545};
547$('cancel-bench').onclick = () => runner.cancel();
551function updateDownloadButton(): void {
552 ($('download-runs') as HTMLButtonElement).disabled =
553 !bench || !bench.runs.length || !bench.runs.every((r) => r.training);
556$('download-runs').onclick = async () => {
557 if (!bench) return;
558 if (!bench.runs.every((r) => r.training)) {
559 status('this benchmark predates the download feature; run it again to enable the export');
560 return;
561 }
562 status('building the dataset archive...');
563 const { buildOfflineFiles } = await import('./export/offline.ts');
564 const { zipStore } = await import('./export/zip.ts');
565 const files = buildOfflineFiles({
566 names: bench.names,
567 fs: FS,
568 pre: Math.round((bench.config.preMs / 1000) * FS),
569 post: Math.round((bench.config.postMs / 1000) * FS),
570 toleranceMs: bench.config.toleranceMs,
571 config: bench.config,
572 algorithms: bench.algos
573 .map((a) => doc.algorithms.find((d) => d.id === a.id))
574 .filter((a): a is AlgorithmDef => !!a)
575 .map((a) => ({ source: a.source })),
576 runs: bench.runs.map((r) => ({ x: r.x, truth: r.truth, training: r.training! })),
577 });
578 const blob = new Blob([zipStore(files) as BlobPart], { type: 'application/zip' });
579 const a = document.createElement('a');
580 a.href = URL.createObjectURL(blob);
581 a.download = 'audio-spike-sorting-runs.zip';
582 a.click();
583 URL.revokeObjectURL(a.href);
584 status(`dataset archive: ${bench.runs.length} runs, ${(blob.size / 1e6).toFixed(1)} MB`);
585};
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 587// ---- results -------------------------------------------------------------------
589function mean(v: number[]): number { return v.length ? v.reduce((a, b) => a + b, 0) / v.length : 0; }
590function std(v: number[]): number {
591 if (v.length < 2) return 0;
592 const m = mean(v);
593 return Math.sqrt(v.reduce((a, b) => a + (b - m) * (b - m), 0) / (v.length - 1));
596function renderResults(): void {
597 const host = $('results');
598 host.innerHTML = '';
599 if (!bench) return;
600 const b = bench;
602 const table = document.createElement('table');
603 table.className = 'results';
604 table.innerHTML = `<tr>
605 <th>algorithm</th><th>accuracy</th><th>correct</th><th>misclassified</th>
606 <th>missed</th><th>false pos.</th><th>truth</th><th>time/run</th></tr>`;
608 for (const algo of b.algos) {
609 const rs = b.results.filter((r) => r.algorithmId === algo.id);
610 const failed = rs.filter((r) => r.error);
611 const accs = rs.map((r) => r.score.accuracy);
612 const sum = (f: (r: RunResult) => number) => rs.reduce((a, r) => a + f(r), 0);
613 const tr = document.createElement('tr');
614 const acc = `${mean(accs).toFixed(3)} ± ${std(accs).toFixed(3)}`;
615 tr.innerHTML = `<td>${algo.name}${failed.length ? ` <span class="err">(${failed.length} failed)</span>` : ''}</td>
616 <td>${acc}</td>
617 <td>${sum((r) => r.score.correct)}</td>
618 <td>${sum((r) => r.score.misclassified)}</td>
619 <td>${sum((r) => r.score.missed)}</td>
620 <td>${sum((r) => r.score.falsePositives)}</td>
621 <td>${sum((r) => r.score.nTruth)}</td>
622 <td>${Math.round(mean(rs.map((r) => r.elapsedMs)))} ms</td>`;
623 table.appendChild(tr);
624 }
625 host.appendChild(table);
8a76af3Report drill-down: per-event-type accuracy and error counts per algorithmJeremy Magland 627 // per-algorithm drill-down: per-type breakdown and confusion matrix,
628 // both summed over runs (per-type accuracy also as mean +- std over runs)
630 const rs = b.results.filter((r) => r.algorithmId === algo.id && !r.error);
631 if (!rs.length) {
632 const err = b.results.find((r) => r.algorithmId === algo.id && r.error);
633 if (err) {
634 const p = document.createElement('p');
635 p.className = 'err';
636 p.textContent = `${algo.name}: ${err.error}`;
637 host.appendChild(p);
638 }
639 continue;
640 }
641 const n = b.names.length;
642 const det = document.createElement('details');
643 const summary = document.createElement('summary');
8a76af3Report drill-down: per-event-type accuracy and error counts per algorithmJeremy Magland 644 summary.textContent = `${algo.name}: per-type breakdown and confusion matrix (all runs)`;
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 645 summary.style.cssText = 'cursor:pointer;font-size:0.85rem;margin-top:0.5rem';
646 det.appendChild(summary);
648 // per-type table
649 const swatch = (k: number) =>
650 `<span style="display:inline-block;width:0.65em;height:0.65em;border-radius:2px;background:${b.colors[k]};margin-right:0.4em"></span>`;
651 const typeAcc = (pt: { correct: number; misclassified: number; missed: number; falsePositives: number }) => {
652 const d = pt.correct + pt.misclassified + pt.missed + pt.falsePositives;
653 return d > 0 ? pt.correct / d : 1;
654 };
655 const pt = document.createElement('table');
656 pt.className = 'results';
657 pt.style.marginTop = '0.4rem';
658 pt.innerHTML = `<tr>
659 <th>event type</th><th>accuracy</th><th>correct</th><th>misclassified</th>
660 <th>missed</th><th>false pos.</th><th>truth</th></tr>`;
661 for (let k = 0; k < n; k++) {
662 const perRun = rs.map((r) => r.score.perType[k]);
663 const accs = perRun.map(typeAcc);
664 const sum = (f: (p: typeof perRun[number]) => number) => perRun.reduce((a, p) => a + f(p), 0);
665 const tr = document.createElement('tr');
666 tr.innerHTML = `<td>${swatch(k)}${b.names[k]}</td>
667 <td>${mean(accs).toFixed(3)} ± ${std(accs).toFixed(3)}</td>
668 <td>${sum((p) => p.correct)}</td>
669 <td>${sum((p) => p.misclassified)}</td>
670 <td>${sum((p) => p.missed)}</td>
671 <td>${sum((p) => p.falsePositives)}</td>
672 <td>${sum((p) => p.nTruth)}</td>`;
673 pt.appendChild(tr);
674 }
675 det.appendChild(pt);
677 // confusion matrix
678 const conf = Array.from({ length: n + 1 }, () => new Array<number>(n + 1).fill(0));
679 for (const r of rs) {
680 for (let i = 0; i <= n; i++) for (let j = 0; j <= n; j++) conf[i][j] += r.score.confusion[i][j];
681 }
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 682 const t = document.createElement('table');
683 t.className = 'confusion';
684 const head = ['true \\ detected', ...b.names, 'missed'];
685 t.innerHTML = `<tr>${head.map((h) => `<th>${h}</th>`).join('')}</tr>`;
686 for (let i = 0; i < n; i++) {
687 const tr = document.createElement('tr');
688 tr.innerHTML = `<th>${b.names[i]}</th>` + conf[i].map((v, j) => {
689 const cls = j === i ? 'diag' : v > 0 ? 'bad' : '';
690 return `<td class="${cls}">${v}</td>`;
691 }).join('');
692 t.appendChild(tr);
693 }
694 const fpRow = document.createElement('tr');
695 fpRow.innerHTML = `<th>false pos.</th>` + conf[n].slice(0, n).map((v) => `<td class="${v > 0 ? 'bad' : ''}">${v}</td>`).join('') + '<td></td>';
696 t.appendChild(fpRow);
697 det.appendChild(t);
698 host.appendChild(det);
699 }
701 const failures = b.results.filter((r) => r.error);
702 if (failures.length) {
703 const p = document.createElement('p');
704 p.className = 'err';
705 p.textContent = failures.map((r) => {
706 const name = b.algos.find((a) => a.id === r.algorithmId)?.name ?? r.algorithmId;
707 return `${name} (run ${r.runIndex + 1}): ${r.error}`;
708 }).join('\n');
709 host.appendChild(p);
710 }
713// ---- explorer -------------------------------------------------------------------
715const explorer = new Explorer($('explorer') as HTMLCanvasElement);
716explorer.onHover = (text) => { $('explore-hover').textContent = text; };
717explorer.onAudition = (t) => {
718 const run = currentExplorerRun();
719 if (!run) return;
720 const s0 = Math.round((t - 0.05) * FS);
721 playSlice(run.x, FS, s0, s0 + Math.round(0.4 * FS));
722};
724function currentExplorerRun() {
725 if (!bench) return null;
726 const ri = Number(($('explore-run') as HTMLSelectElement).value);
727 return bench.runs[ri] ?? null;
d6b5949Explorer playback: play the run or a shift-drag selected interval, with a tracking playheadJeremy Magland 730// ---- run playback ----------------------------------------------------------
3b66b1dPlay recorded clips in the clip editor, via a Player shared with the explorerJeremy Magland 732const runPlayer = new Player(audio);
733runPlayer.onTick = (t) => explorer.setPlayhead(t);
734runPlayer.onStateChange = () => updatePlayButton();
736function updatePlayButton(): void {
737 const btn = $('explore-play') as HTMLButtonElement;
738 btn.disabled = !currentExplorerRun();
741 } else {
742 btn.innerHTML = explorer.getSelection() ? '&#9654; Play selection' : '&#9654; Play';
743 }
3b66b1dPlay recorded clips in the clip editor, via a Player shared with the explorerJeremy Magland 746$('explore-play').onclick = () => {
747 if (runPlayer.playing) { runPlayer.stop(); return; }
748 const run = currentExplorerRun();
749 if (!run) return;
750 const sel = explorer.getSelection();
751 runPlayer.start(run.x, FS, sel ? sel.t0 : 0, sel ? sel.t1 : run.x.length / FS);
752};
753explorer.onSelectionChange = () => { if (!runPlayer.playing) updatePlayButton(); };
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 755function populateExplorerControls(): void {
756 const runSel = $('explore-run') as HTMLSelectElement;
757 const algoSel = $('explore-algo') as HTMLSelectElement;
758 runSel.innerHTML = '';
759 algoSel.innerHTML = '';
760 if (!bench) { updateExplorer(); return; }
761 bench.runs.forEach((r, i) => {
762 const o = document.createElement('option');
763 o.value = String(i);
764 o.textContent = `run ${i + 1} (seed ${r.seed})`;
765 runSel.appendChild(o);
766 });
767 for (const a of bench.algos) {
768 const o = document.createElement('option');
769 o.value = a.id;
770 o.textContent = a.name;
771 algoSel.appendChild(o);
772 }
773 updateExplorer();
776function updateExplorer(): void {
d6b5949Explorer playback: play the run or a shift-drag selected interval, with a tracking playheadJeremy Magland 778 if (!bench) { explorer.set(null); updatePlayButton(); return; }
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 779 const ri = Number(($('explore-run') as HTMLSelectElement).value || '0');
780 const algoId = ($('explore-algo') as HTMLSelectElement).value;
781 const run = bench.runs[ri];
782 const result = bench.results.find((r) => r.runIndex === ri && r.algorithmId === algoId);
d6b5949Explorer playback: play the run or a shift-drag selected interval, with a tracking playheadJeremy Magland 783 if (!run || !result) { explorer.set(null); updatePlayButton(); return; }
785 x: run.x,
786 fs: FS,
787 names: bench.names,
788 colors: bench.colors,
789 events: result.score.events,
7abfa1cExplorer: caption the ground-truth and detection bands so their roles are explicitJeremy Magland 790 algoName: bench.algos.find((a) => a.id === algoId)?.name,
795($('explore-run') as HTMLSelectElement).onchange = updateExplorer;
796($('explore-algo') as HTMLSelectElement).onchange = updateExplorer;
798// ---- export / import / reset -----------------------------------------------------
800$('export').onclick = () => {
801 const json = exportProject(doc.types, doc.config, doc.algorithms, getAudio);
802 const blob = new Blob([json], { type: 'application/json' });
803 const a = document.createElement('a');
804 a.href = URL.createObjectURL(blob);
805 a.download = 'audio-spike-sorting-project.json';
806 a.click();
807 URL.revokeObjectURL(a.href);
808};
810$('import').onclick = () => ($('import-file') as HTMLInputElement).click();
811($('import-file') as HTMLInputElement).onchange = async (e) => {
812 const file = (e.target as HTMLInputElement).files?.[0];
813 if (!file) return;
814 try {
815 const imported = importProject(await file.text());
816 if (!confirm('Importing replaces the current project. Continue?')) return;
817 for (const t of doc.types) for (const c of t.clips) { await deleteClipAudio(c.id); }
818 clipAudio.clear();
819 doc.types = imported.types;
820 doc.config = { ...DEFAULT_CONFIG, ...imported.config };
821 doc.algorithms = imported.algorithms;
822 for (const b of BUILTIN_ALGORITHMS) {
823 if (!doc.algorithms.some((a) => a.id === b.id)) doc.algorithms.push({ ...b });
824 }
825 for (const a of imported.clipAudio) {
826 clipAudio.set(a.id, { samples: a.samples, sr: a.sr });
827 await putClipAudio(a.id, a.samples, a.sr);
828 }
829 save();
830 closeClipEditor();
831 renderTypes(); renderConfig(); renderAlgos();
832 status(`imported ${imported.types.length} event types and ${imported.algorithms.length} algorithms`);
833 } catch (err) {
834 status(`import failed: ${(err as Error).message}`);
835 } finally {
836 ($('import-file') as HTMLInputElement).value = '';
837 }
838};
840$('reset-all').onclick = async () => {
841 if (!confirm('Forget all event types, recordings, algorithms, and results?')) return;
842 await clearAll();
843 location.reload();
844};
846// ---- startup ---------------------------------------------------------------------
848async function startup(): Promise<void> {
849 for (const t of doc.types) {
850 for (const c of t.clips) {
851 const a = await getClipAudio(c.id);
852 if (a) clipAudio.set(c.id, a);
853 }
854 }
855 bench = await getBenchmark();
856 renderTypes();
857 renderConfig();
858 renderAlgos();
859 renderResults();
860 populateExplorerControls();
864void startup();
↑↓ move↵openescclose