/ proof-of-concept / audio-spike-sorting
Sign in
proof-of-concept / audio-spike-sorting
audio-spike-sorting / src / main.ts
815 lines · 29.2 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';
15import { exportProject, importProject } from './export.ts';
16import {
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 },
343 { key: 'trainFraction', label: 'training fraction (0-1)', min: 0.05, max: 0.95, step: 0.05 },
344 { key: 'runs', label: 'runs', min: 1, max: 50, step: 1 },
345 { key: 'seed', label: 'base seed', min: 0, max: 1e9, step: 1 },
346 { key: 'toleranceMs', label: 'match tolerance (ms)', min: 1, max: 200, step: 1 },
347 { key: 'preMs', label: 'snippet pre-onset (ms)', min: 0, max: 100, step: 5 },
348 { key: 'postMs', label: 'snippet post-onset (ms)', min: 20, max: 1000, step: 10 },
349];
351function renderConfig(): void {
352 const grid = $('config-grid');
353 grid.innerHTML = '';
354 for (const f of CONFIG_FIELDS) {
355 const label = document.createElement('label');
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 357 const span = document.createElement('span');
358 span.textContent = f.label;
359 const input = document.createElement('input');
360 input.type = 'number';
361 input.min = String(f.min); input.max = String(f.max); input.step = String(f.step);
362 input.value = String(doc.config[f.key]);
363 input.onchange = () => {
364 const v = Number(input.value);
365 if (Number.isFinite(v)) {
366 (doc.config[f.key] as number) = Math.min(f.max, Math.max(f.min, v));
367 input.value = String(doc.config[f.key]);
368 save();
369 if (f.key === 'preMs' || f.key === 'postMs') renderTypes();
370 }
371 };
372 label.append(span, input);
373 grid.appendChild(label);
374 }
377// ---- algorithms ----------------------------------------------------------------
379function algoById(id: string): AlgorithmDef | undefined { return doc.algorithms.find((a) => a.id === id); }
381function renderAlgos(): void {
382 const list = $('algo-list');
383 list.innerHTML = '';
384 doc.algorithms.forEach((algo) => {
385 const row = document.createElement('div');
386 row.className = 'algo-row';
388 const enable = document.createElement('input');
389 enable.type = 'checkbox';
390 enable.checked = algo.enabled;
391 enable.onchange = () => { algo.enabled = enable.checked; save(); };
392 row.appendChild(enable);
394 const name = document.createElement('strong');
395 name.textContent = algo.name;
396 row.appendChild(name);
398 if (algo.builtin) {
399 const tag = document.createElement('span');
400 tag.className = 'tag';
401 tag.textContent = 'built-in';
402 row.appendChild(tag);
403 }
405 const spacer = document.createElement('span');
406 spacer.className = 'spacer';
407 row.appendChild(spacer);
409 const editBtn = document.createElement('button');
410 editBtn.textContent = 'edit';
411 editBtn.onclick = () => openAlgoEditor(algo.id);
412 row.appendChild(editBtn);
414 list.appendChild(row);
415 });
418function openAlgoEditor(id: string): void {
419 const algo = algoById(id);
420 if (!algo) return;
421 editingAlgoId = id;
422 $('algo-editor').hidden = false;
423 const nameInput = $('algo-name') as HTMLInputElement;
424 nameInput.value = algo.name;
425 nameInput.disabled = !!algo.builtin;
426 ($('algo-source') as HTMLTextAreaElement).value = algo.source;
427 ($('algo-reset') as HTMLButtonElement).hidden = !algo.builtin;
428 ($('algo-remove') as HTMLButtonElement).hidden = !!algo.builtin;
429 $('algo-editor').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
432($('algo-name') as HTMLInputElement).onchange = () => {
433 const algo = editingAlgoId ? algoById(editingAlgoId) : null;
434 if (!algo || algo.builtin) return;
435 algo.name = ($('algo-name') as HTMLInputElement).value.trim() || algo.name;
436 save();
437 renderAlgos();
438};
440($('algo-source') as HTMLTextAreaElement).onchange = () => {
441 const algo = editingAlgoId ? algoById(editingAlgoId) : null;
442 if (!algo) return;
443 algo.source = ($('algo-source') as HTMLTextAreaElement).value;
444 save();
445};
447$('algo-reset').onclick = () => {
448 const algo = editingAlgoId ? algoById(editingAlgoId) : null;
449 const builtin = BUILTIN_ALGORITHMS.find((b) => b.id === algo?.id);
450 if (!algo || !builtin) return;
451 algo.source = builtin.source;
452 ($('algo-source') as HTMLTextAreaElement).value = algo.source;
453 save();
454};
456$('algo-remove').onclick = () => {
457 const algo = editingAlgoId ? algoById(editingAlgoId) : null;
458 if (!algo || algo.builtin) return;
459 if (!confirm(`Delete algorithm "${algo.name}"?`)) return;
460 doc.algorithms = doc.algorithms.filter((a) => a.id !== algo.id);
461 editingAlgoId = null;
462 $('algo-editor').hidden = true;
463 save();
464 renderAlgos();
465};
467$('algo-close').onclick = () => {
468 editingAlgoId = null;
469 $('algo-editor').hidden = true;
470};
472$('add-algo').onclick = () => {
473 const algo: AlgorithmDef = {
474 id: uid(),
475 name: `my_sorter_${doc.algorithms.length}`,
476 source: NEW_ALGORITHM_TEMPLATE,
477 enabled: true,
478 };
479 doc.algorithms.push(algo);
480 save();
481 renderAlgos();
482 openAlgoEditor(algo.id);
483};
485// ---- benchmark -----------------------------------------------------------------
487$('run-bench').onclick = async () => {
488 const enabled = doc.algorithms.filter((a) => a.enabled);
489 if (!enabled.length) { status('enable at least one algorithm'); return; }
490 const lib = extractLibrary(doc.types, doc.config.preMs, doc.config.postMs, getAudio);
491 const perType = doc.types.map((t, i) => lib.snippets.filter((s) => s.typeIndex === i).length);
492 if (!doc.types.length || perType.some((n) => n < 2)) {
493 status('every event type needs at least 2 usable snippets (one to train, one to place)');
494 return;
495 }
497 ($('run-bench') as HTMLButtonElement).disabled = true;
498 $('cancel-bench').hidden = false;
499 const progress = $('bench-progress') as HTMLProgressElement;
500 progress.hidden = false;
502 try {
503 const out = await runner.run(
504 lib, doc.config, doc.config.runs, doc.config.toleranceMs, enabled,
505 (p) => {
506 progress.max = p.total;
507 progress.value = p.done;
508 $('bench-label').textContent = `run ${p.runIndex + 1}/${p.runCount}: ${p.algorithmName}`;
509 },
510 );
511 if (!runner.cancelled) {
512 bench = {
513 when: Date.now(),
514 names: doc.types.map((t) => t.name),
515 colors: doc.types.map((t) => t.color),
516 algos: enabled.map((a) => ({ id: a.id, name: a.name })),
517 config: { ...doc.config },
518 runs: out.runs,
519 results: out.results,
520 };
521 await putBenchmark(bench);
522 renderResults();
523 populateExplorerControls();
524 status(`benchmark complete: ${out.results.length} algorithm runs`);
525 } else {
526 status('benchmark cancelled');
527 }
528 } catch (err) {
529 status(`benchmark failed: ${(err as Error).message}`);
530 } finally {
531 ($('run-bench') as HTMLButtonElement).disabled = false;
532 $('cancel-bench').hidden = true;
533 progress.hidden = true;
534 $('bench-label').textContent = '';
535 }
536};
538$('cancel-bench').onclick = () => runner.cancel();
540// ---- results -------------------------------------------------------------------
542function mean(v: number[]): number { return v.length ? v.reduce((a, b) => a + b, 0) / v.length : 0; }
543function std(v: number[]): number {
544 if (v.length < 2) return 0;
545 const m = mean(v);
546 return Math.sqrt(v.reduce((a, b) => a + (b - m) * (b - m), 0) / (v.length - 1));
549function renderResults(): void {
550 const host = $('results');
551 host.innerHTML = '';
552 if (!bench) return;
553 const b = bench;
555 const table = document.createElement('table');
556 table.className = 'results';
557 table.innerHTML = `<tr>
558 <th>algorithm</th><th>accuracy</th><th>correct</th><th>misclassified</th>
559 <th>missed</th><th>false pos.</th><th>truth</th><th>time/run</th></tr>`;
561 for (const algo of b.algos) {
562 const rs = b.results.filter((r) => r.algorithmId === algo.id);
563 const failed = rs.filter((r) => r.error);
564 const accs = rs.map((r) => r.score.accuracy);
565 const sum = (f: (r: RunResult) => number) => rs.reduce((a, r) => a + f(r), 0);
566 const tr = document.createElement('tr');
567 const acc = `${mean(accs).toFixed(3)} ± ${std(accs).toFixed(3)}`;
568 tr.innerHTML = `<td>${algo.name}${failed.length ? ` <span class="err">(${failed.length} failed)</span>` : ''}</td>
569 <td>${acc}</td>
570 <td>${sum((r) => r.score.correct)}</td>
571 <td>${sum((r) => r.score.misclassified)}</td>
572 <td>${sum((r) => r.score.missed)}</td>
573 <td>${sum((r) => r.score.falsePositives)}</td>
574 <td>${sum((r) => r.score.nTruth)}</td>
575 <td>${Math.round(mean(rs.map((r) => r.elapsedMs)))} ms</td>`;
576 table.appendChild(tr);
577 }
578 host.appendChild(table);
8a76af3Report drill-down: per-event-type accuracy and error counts per algorithmJeremy Magland 580 // per-algorithm drill-down: per-type breakdown and confusion matrix,
581 // both summed over runs (per-type accuracy also as mean +- std over runs)
583 const rs = b.results.filter((r) => r.algorithmId === algo.id && !r.error);
584 if (!rs.length) {
585 const err = b.results.find((r) => r.algorithmId === algo.id && r.error);
586 if (err) {
587 const p = document.createElement('p');
588 p.className = 'err';
589 p.textContent = `${algo.name}: ${err.error}`;
590 host.appendChild(p);
591 }
592 continue;
593 }
594 const n = b.names.length;
595 const det = document.createElement('details');
596 const summary = document.createElement('summary');
8a76af3Report drill-down: per-event-type accuracy and error counts per algorithmJeremy Magland 597 summary.textContent = `${algo.name}: per-type breakdown and confusion matrix (all runs)`;
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 598 summary.style.cssText = 'cursor:pointer;font-size:0.85rem;margin-top:0.5rem';
599 det.appendChild(summary);
601 // per-type table
602 const swatch = (k: number) =>
603 `<span style="display:inline-block;width:0.65em;height:0.65em;border-radius:2px;background:${b.colors[k]};margin-right:0.4em"></span>`;
604 const typeAcc = (pt: { correct: number; misclassified: number; missed: number; falsePositives: number }) => {
605 const d = pt.correct + pt.misclassified + pt.missed + pt.falsePositives;
606 return d > 0 ? pt.correct / d : 1;
607 };
608 const pt = document.createElement('table');
609 pt.className = 'results';
610 pt.style.marginTop = '0.4rem';
611 pt.innerHTML = `<tr>
612 <th>event type</th><th>accuracy</th><th>correct</th><th>misclassified</th>
613 <th>missed</th><th>false pos.</th><th>truth</th></tr>`;
614 for (let k = 0; k < n; k++) {
615 const perRun = rs.map((r) => r.score.perType[k]);
616 const accs = perRun.map(typeAcc);
617 const sum = (f: (p: typeof perRun[number]) => number) => perRun.reduce((a, p) => a + f(p), 0);
618 const tr = document.createElement('tr');
619 tr.innerHTML = `<td>${swatch(k)}${b.names[k]}</td>
620 <td>${mean(accs).toFixed(3)} ± ${std(accs).toFixed(3)}</td>
621 <td>${sum((p) => p.correct)}</td>
622 <td>${sum((p) => p.misclassified)}</td>
623 <td>${sum((p) => p.missed)}</td>
624 <td>${sum((p) => p.falsePositives)}</td>
625 <td>${sum((p) => p.nTruth)}</td>`;
626 pt.appendChild(tr);
627 }
628 det.appendChild(pt);
630 // confusion matrix
631 const conf = Array.from({ length: n + 1 }, () => new Array<number>(n + 1).fill(0));
632 for (const r of rs) {
633 for (let i = 0; i <= n; i++) for (let j = 0; j <= n; j++) conf[i][j] += r.score.confusion[i][j];
634 }
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 635 const t = document.createElement('table');
636 t.className = 'confusion';
637 const head = ['true \\ detected', ...b.names, 'missed'];
638 t.innerHTML = `<tr>${head.map((h) => `<th>${h}</th>`).join('')}</tr>`;
639 for (let i = 0; i < n; i++) {
640 const tr = document.createElement('tr');
641 tr.innerHTML = `<th>${b.names[i]}</th>` + conf[i].map((v, j) => {
642 const cls = j === i ? 'diag' : v > 0 ? 'bad' : '';
643 return `<td class="${cls}">${v}</td>`;
644 }).join('');
645 t.appendChild(tr);
646 }
647 const fpRow = document.createElement('tr');
648 fpRow.innerHTML = `<th>false pos.</th>` + conf[n].slice(0, n).map((v) => `<td class="${v > 0 ? 'bad' : ''}">${v}</td>`).join('') + '<td></td>';
649 t.appendChild(fpRow);
650 det.appendChild(t);
651 host.appendChild(det);
652 }
654 const failures = b.results.filter((r) => r.error);
655 if (failures.length) {
656 const p = document.createElement('p');
657 p.className = 'err';
658 p.textContent = failures.map((r) => {
659 const name = b.algos.find((a) => a.id === r.algorithmId)?.name ?? r.algorithmId;
660 return `${name} (run ${r.runIndex + 1}): ${r.error}`;
661 }).join('\n');
662 host.appendChild(p);
663 }
666// ---- explorer -------------------------------------------------------------------
668const explorer = new Explorer($('explorer') as HTMLCanvasElement);
669explorer.onHover = (text) => { $('explore-hover').textContent = text; };
670explorer.onAudition = (t) => {
671 const run = currentExplorerRun();
672 if (!run) return;
673 const s0 = Math.round((t - 0.05) * FS);
674 playSlice(run.x, FS, s0, s0 + Math.round(0.4 * FS));
675};
677function currentExplorerRun() {
678 if (!bench) return null;
679 const ri = Number(($('explore-run') as HTMLSelectElement).value);
680 return bench.runs[ri] ?? null;
d6b5949Explorer playback: play the run or a shift-drag selected interval, with a tracking playheadJeremy Magland 683// ---- run playback ----------------------------------------------------------
3b66b1dPlay recorded clips in the clip editor, via a Player shared with the explorerJeremy Magland 685const runPlayer = new Player(audio);
686runPlayer.onTick = (t) => explorer.setPlayhead(t);
687runPlayer.onStateChange = () => updatePlayButton();
689function updatePlayButton(): void {
690 const btn = $('explore-play') as HTMLButtonElement;
691 btn.disabled = !currentExplorerRun();
694 } else {
695 btn.innerHTML = explorer.getSelection() ? '&#9654; Play selection' : '&#9654; Play';
696 }
3b66b1dPlay recorded clips in the clip editor, via a Player shared with the explorerJeremy Magland 699$('explore-play').onclick = () => {
700 if (runPlayer.playing) { runPlayer.stop(); return; }
701 const run = currentExplorerRun();
702 if (!run) return;
703 const sel = explorer.getSelection();
704 runPlayer.start(run.x, FS, sel ? sel.t0 : 0, sel ? sel.t1 : run.x.length / FS);
705};
706explorer.onSelectionChange = () => { if (!runPlayer.playing) updatePlayButton(); };
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 708function populateExplorerControls(): void {
709 const runSel = $('explore-run') as HTMLSelectElement;
710 const algoSel = $('explore-algo') as HTMLSelectElement;
711 runSel.innerHTML = '';
712 algoSel.innerHTML = '';
713 if (!bench) { updateExplorer(); return; }
714 bench.runs.forEach((r, i) => {
715 const o = document.createElement('option');
716 o.value = String(i);
717 o.textContent = `run ${i + 1} (seed ${r.seed})`;
718 runSel.appendChild(o);
719 });
720 for (const a of bench.algos) {
721 const o = document.createElement('option');
722 o.value = a.id;
723 o.textContent = a.name;
724 algoSel.appendChild(o);
725 }
726 updateExplorer();
729function updateExplorer(): void {
d6b5949Explorer playback: play the run or a shift-drag selected interval, with a tracking playheadJeremy Magland 731 if (!bench) { explorer.set(null); updatePlayButton(); return; }
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 732 const ri = Number(($('explore-run') as HTMLSelectElement).value || '0');
733 const algoId = ($('explore-algo') as HTMLSelectElement).value;
734 const run = bench.runs[ri];
735 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 736 if (!run || !result) { explorer.set(null); updatePlayButton(); return; }
738 x: run.x,
739 fs: FS,
740 names: bench.names,
741 colors: bench.colors,
742 events: result.score.events,
743 });
747($('explore-run') as HTMLSelectElement).onchange = updateExplorer;
748($('explore-algo') as HTMLSelectElement).onchange = updateExplorer;
750// ---- export / import / reset -----------------------------------------------------
752$('export').onclick = () => {
753 const json = exportProject(doc.types, doc.config, doc.algorithms, getAudio);
754 const blob = new Blob([json], { type: 'application/json' });
755 const a = document.createElement('a');
756 a.href = URL.createObjectURL(blob);
757 a.download = 'audio-spike-sorting-project.json';
758 a.click();
759 URL.revokeObjectURL(a.href);
760};
762$('import').onclick = () => ($('import-file') as HTMLInputElement).click();
763($('import-file') as HTMLInputElement).onchange = async (e) => {
764 const file = (e.target as HTMLInputElement).files?.[0];
765 if (!file) return;
766 try {
767 const imported = importProject(await file.text());
768 if (!confirm('Importing replaces the current project. Continue?')) return;
769 for (const t of doc.types) for (const c of t.clips) { await deleteClipAudio(c.id); }
770 clipAudio.clear();
771 doc.types = imported.types;
772 doc.config = { ...DEFAULT_CONFIG, ...imported.config };
773 doc.algorithms = imported.algorithms;
774 for (const b of BUILTIN_ALGORITHMS) {
775 if (!doc.algorithms.some((a) => a.id === b.id)) doc.algorithms.push({ ...b });
776 }
777 for (const a of imported.clipAudio) {
778 clipAudio.set(a.id, { samples: a.samples, sr: a.sr });
779 await putClipAudio(a.id, a.samples, a.sr);
780 }
781 save();
782 closeClipEditor();
783 renderTypes(); renderConfig(); renderAlgos();
784 status(`imported ${imported.types.length} event types and ${imported.algorithms.length} algorithms`);
785 } catch (err) {
786 status(`import failed: ${(err as Error).message}`);
787 } finally {
788 ($('import-file') as HTMLInputElement).value = '';
789 }
790};
792$('reset-all').onclick = async () => {
793 if (!confirm('Forget all event types, recordings, algorithms, and results?')) return;
794 await clearAll();
795 location.reload();
796};
798// ---- startup ---------------------------------------------------------------------
800async function startup(): Promise<void> {
801 for (const t of doc.types) {
802 for (const c of t.clips) {
803 const a = await getClipAudio(c.id);
804 if (a) clipAudio.set(c.id, a);
805 }
806 }
807 bench = await getBenchmark();
808 renderTypes();
809 renderConfig();
810 renderAlgos();
811 renderResults();
812 populateExplorerControls();
815void startup();
↑↓ move↵openescclose