1/**
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';
9import { Player } from './audio/player.ts';
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 './project-file.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 });
35}
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();
65}
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">■</span> stop' : '● 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 });
126}
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();
170}
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`);
200}
202// ---- clip editor ---------------------------------------------------------------
204const waveView = new WaveView($('clip-wave') as HTMLCanvasElement);
206const clipPlayer = new Player(audio);
207clipPlayer.onTick = (t) => waveView.setPlayhead(t);
208clipPlayer.onStateChange = () => {
209 $('clip-play').innerHTML = clipPlayer.playing ? '■ Stop' : '▶ 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};
218function currentClip() {
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;
224}
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) }));
229}
231function openClipEditor(typeId: string, clipId: string): void {
232 clipPlayer.stop();
233 edit = { typeId, clipId };
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();
250}
252function closeClipEditor(): void {
253 clipPlayer.stop();
254 edit = null;
255 $('clip-editor').hidden = true;
256}
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;
264}
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();
303}
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;
333 title?: string;
334}
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 },
338 {
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 },
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');
356 if (f.title) label.title = f.title;
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 }
375}
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 });
416}
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' });
430}
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 updateDownloadButton();
525 status(`benchmark complete: ${out.results.length} algorithm runs`);
526 } else {
527 status('benchmark cancelled');
528 }
529 } catch (err) {
530 status(`benchmark failed: ${(err as Error).message}`);
531 } finally {
532 ($('run-bench') as HTMLButtonElement).disabled = false;
533 $('cancel-bench').hidden = true;
534 progress.hidden = true;
535 $('bench-label').textContent = '';
536 }
537};
539$('cancel-bench').onclick = () => runner.cancel();
541// ---- dataset download -------------------------------------------------------------
543function updateDownloadButton(): void {
544 ($('download-runs') as HTMLButtonElement).disabled =
545 !bench || !bench.runs.length || !bench.runs.every((r) => r.training);
546}
548$('download-runs').onclick = async () => {
549 if (!bench) return;
550 if (!bench.runs.every((r) => r.training)) {
551 status('this benchmark predates the download feature; run it again to enable the export');
552 return;
553 }
554 status('building the dataset archive...');
555 const { buildOfflineFiles } = await import('./export/offline.ts');
556 const { zipStore } = await import('./export/zip.ts');
557 const files = buildOfflineFiles({
558 names: bench.names,
559 fs: FS,
560 pre: Math.round((bench.config.preMs / 1000) * FS),
561 post: Math.round((bench.config.postMs / 1000) * FS),
562 toleranceMs: bench.config.toleranceMs,
563 config: bench.config,
564 algorithms: bench.algos
565 .map((a) => doc.algorithms.find((d) => d.id === a.id))
566 .filter((a): a is AlgorithmDef => !!a)
567 .map((a) => ({ source: a.source })),
568 runs: bench.runs.map((r) => ({ x: r.x, truth: r.truth, training: r.training! })),
569 });
570 const blob = new Blob([zipStore(files) as BlobPart], { type: 'application/zip' });
571 const a = document.createElement('a');
572 a.href = URL.createObjectURL(blob);
573 a.download = 'audio-spike-sorting-runs.zip';
574 a.click();
575 URL.revokeObjectURL(a.href);
576 status(`dataset archive: ${bench.runs.length} runs, ${(blob.size / 1e6).toFixed(1)} MB`);
577};
579// ---- results -------------------------------------------------------------------
581function mean(v: number[]): number { return v.length ? v.reduce((a, b) => a + b, 0) / v.length : 0; }
582function std(v: number[]): number {
583 if (v.length < 2) return 0;
584 const m = mean(v);
585 return Math.sqrt(v.reduce((a, b) => a + (b - m) * (b - m), 0) / (v.length - 1));
586}
588function renderResults(): void {
589 const host = $('results');
590 host.innerHTML = '';
591 if (!bench) return;
592 const b = bench;
594 const table = document.createElement('table');
595 table.className = 'results';
596 table.innerHTML = `<tr>
597 <th>algorithm</th><th>accuracy</th><th>correct</th><th>misclassified</th>
598 <th>missed</th><th>false pos.</th><th>truth</th><th>time/run</th></tr>`;
600 for (const algo of b.algos) {
601 const rs = b.results.filter((r) => r.algorithmId === algo.id);
602 const failed = rs.filter((r) => r.error);
603 const accs = rs.map((r) => r.score.accuracy);
604 const sum = (f: (r: RunResult) => number) => rs.reduce((a, r) => a + f(r), 0);
605 const tr = document.createElement('tr');
606 const acc = `${mean(accs).toFixed(3)} ± ${std(accs).toFixed(3)}`;
607 tr.innerHTML = `<td>${algo.name}${failed.length ? ` <span class="err">(${failed.length} failed)</span>` : ''}</td>
608 <td>${acc}</td>
609 <td>${sum((r) => r.score.correct)}</td>
610 <td>${sum((r) => r.score.misclassified)}</td>
611 <td>${sum((r) => r.score.missed)}</td>
612 <td>${sum((r) => r.score.falsePositives)}</td>
613 <td>${sum((r) => r.score.nTruth)}</td>
614 <td>${Math.round(mean(rs.map((r) => r.elapsedMs)))} ms</td>`;
615 table.appendChild(tr);
616 }
617 host.appendChild(table);
619 // per-algorithm drill-down: per-type breakdown and confusion matrix,
620 // both summed over runs (per-type accuracy also as mean +- std over runs)
621 for (const algo of b.algos) {
622 const rs = b.results.filter((r) => r.algorithmId === algo.id && !r.error);
623 if (!rs.length) {
624 const err = b.results.find((r) => r.algorithmId === algo.id && r.error);
625 if (err) {
626 const p = document.createElement('p');
627 p.className = 'err';
628 p.textContent = `${algo.name}: ${err.error}`;
629 host.appendChild(p);
630 }
631 continue;
632 }
633 const n = b.names.length;
634 const det = document.createElement('details');
635 const summary = document.createElement('summary');
636 summary.textContent = `${algo.name}: per-type breakdown and confusion matrix (all runs)`;
637 summary.style.cssText = 'cursor:pointer;font-size:0.85rem;margin-top:0.5rem';
638 det.appendChild(summary);
640 // per-type table
641 const swatch = (k: number) =>
642 `<span style="display:inline-block;width:0.65em;height:0.65em;border-radius:2px;background:${b.colors[k]};margin-right:0.4em"></span>`;
643 const typeAcc = (pt: { correct: number; misclassified: number; missed: number; falsePositives: number }) => {
644 const d = pt.correct + pt.misclassified + pt.missed + pt.falsePositives;
645 return d > 0 ? pt.correct / d : 1;
646 };
647 const pt = document.createElement('table');
648 pt.className = 'results';
649 pt.style.marginTop = '0.4rem';
650 pt.innerHTML = `<tr>
651 <th>event type</th><th>accuracy</th><th>correct</th><th>misclassified</th>
652 <th>missed</th><th>false pos.</th><th>truth</th></tr>`;
653 for (let k = 0; k < n; k++) {
654 const perRun = rs.map((r) => r.score.perType[k]);
655 const accs = perRun.map(typeAcc);
656 const sum = (f: (p: typeof perRun[number]) => number) => perRun.reduce((a, p) => a + f(p), 0);
657 const tr = document.createElement('tr');
658 tr.innerHTML = `<td>${swatch(k)}${b.names[k]}</td>
659 <td>${mean(accs).toFixed(3)} ± ${std(accs).toFixed(3)}</td>
660 <td>${sum((p) => p.correct)}</td>
661 <td>${sum((p) => p.misclassified)}</td>
662 <td>${sum((p) => p.missed)}</td>
663 <td>${sum((p) => p.falsePositives)}</td>
664 <td>${sum((p) => p.nTruth)}</td>`;
665 pt.appendChild(tr);
666 }
667 det.appendChild(pt);
669 // confusion matrix
670 const conf = Array.from({ length: n + 1 }, () => new Array<number>(n + 1).fill(0));
671 for (const r of rs) {
672 for (let i = 0; i <= n; i++) for (let j = 0; j <= n; j++) conf[i][j] += r.score.confusion[i][j];
673 }
674 const t = document.createElement('table');
675 t.className = 'confusion';
676 const head = ['true \\ detected', ...b.names, 'missed'];
677 t.innerHTML = `<tr>${head.map((h) => `<th>${h}</th>`).join('')}</tr>`;
678 for (let i = 0; i < n; i++) {
679 const tr = document.createElement('tr');
680 tr.innerHTML = `<th>${b.names[i]}</th>` + conf[i].map((v, j) => {
681 const cls = j === i ? 'diag' : v > 0 ? 'bad' : '';
682 return `<td class="${cls}">${v}</td>`;
683 }).join('');
684 t.appendChild(tr);
685 }
686 const fpRow = document.createElement('tr');
687 fpRow.innerHTML = `<th>false pos.</th>` + conf[n].slice(0, n).map((v) => `<td class="${v > 0 ? 'bad' : ''}">${v}</td>`).join('') + '<td></td>';
688 t.appendChild(fpRow);
689 det.appendChild(t);
690 host.appendChild(det);
691 }
693 const failures = b.results.filter((r) => r.error);
694 if (failures.length) {
695 const p = document.createElement('p');
696 p.className = 'err';
697 p.textContent = failures.map((r) => {
698 const name = b.algos.find((a) => a.id === r.algorithmId)?.name ?? r.algorithmId;
699 return `${name} (run ${r.runIndex + 1}): ${r.error}`;
700 }).join('\n');
701 host.appendChild(p);
702 }
703}
705// ---- explorer -------------------------------------------------------------------
707const explorer = new Explorer($('explorer') as HTMLCanvasElement);
708explorer.onHover = (text) => { $('explore-hover').textContent = text; };
709explorer.onAudition = (t) => {
710 const run = currentExplorerRun();
711 if (!run) return;
712 const s0 = Math.round((t - 0.05) * FS);
713 playSlice(run.x, FS, s0, s0 + Math.round(0.4 * FS));
714};
716function currentExplorerRun() {
717 if (!bench) return null;
718 const ri = Number(($('explore-run') as HTMLSelectElement).value);
719 return bench.runs[ri] ?? null;
720}
722// ---- run playback ----------------------------------------------------------
724const runPlayer = new Player(audio);
725runPlayer.onTick = (t) => explorer.setPlayhead(t);
726runPlayer.onStateChange = () => updatePlayButton();
728function updatePlayButton(): void {
729 const btn = $('explore-play') as HTMLButtonElement;
730 btn.disabled = !currentExplorerRun();
731 if (runPlayer.playing) {
732 btn.innerHTML = '■ Stop';
733 } else {
734 btn.innerHTML = explorer.getSelection() ? '▶ Play selection' : '▶ Play';
735 }
736}
738$('explore-play').onclick = () => {
739 if (runPlayer.playing) { runPlayer.stop(); return; }
740 const run = currentExplorerRun();
741 if (!run) return;
742 const sel = explorer.getSelection();
743 runPlayer.start(run.x, FS, sel ? sel.t0 : 0, sel ? sel.t1 : run.x.length / FS);
744};
745explorer.onSelectionChange = () => { if (!runPlayer.playing) updatePlayButton(); };
747function populateExplorerControls(): void {
748 const runSel = $('explore-run') as HTMLSelectElement;
749 const algoSel = $('explore-algo') as HTMLSelectElement;
750 runSel.innerHTML = '';
751 algoSel.innerHTML = '';
752 if (!bench) { updateExplorer(); return; }
753 bench.runs.forEach((r, i) => {
754 const o = document.createElement('option');
755 o.value = String(i);
756 o.textContent = `run ${i + 1} (seed ${r.seed})`;
757 runSel.appendChild(o);
758 });
759 for (const a of bench.algos) {
760 const o = document.createElement('option');
761 o.value = a.id;
762 o.textContent = a.name;
763 algoSel.appendChild(o);
764 }
765 updateExplorer();
766}
768function updateExplorer(): void {
769 runPlayer.stop();
770 if (!bench) { explorer.set(null); updatePlayButton(); return; }
771 const ri = Number(($('explore-run') as HTMLSelectElement).value || '0');
772 const algoId = ($('explore-algo') as HTMLSelectElement).value;
773 const run = bench.runs[ri];
774 const result = bench.results.find((r) => r.runIndex === ri && r.algorithmId === algoId);
775 if (!run || !result) { explorer.set(null); updatePlayButton(); return; }
776 explorer.set({
777 x: run.x,
778 fs: FS,
779 names: bench.names,
780 colors: bench.colors,
781 events: result.score.events,
782 algoName: bench.algos.find((a) => a.id === algoId)?.name,
783 });
784 updatePlayButton();
785}
787($('explore-run') as HTMLSelectElement).onchange = updateExplorer;
788($('explore-algo') as HTMLSelectElement).onchange = updateExplorer;
790// ---- export / import / reset -----------------------------------------------------
792$('export').onclick = () => {
793 const json = exportProject(doc.types, doc.config, doc.algorithms, getAudio);
794 const blob = new Blob([json], { type: 'application/json' });
795 const a = document.createElement('a');
796 a.href = URL.createObjectURL(blob);
797 a.download = 'audio-spike-sorting-project.json';
798 a.click();
799 URL.revokeObjectURL(a.href);
800};
802$('import').onclick = () => ($('import-file') as HTMLInputElement).click();
803($('import-file') as HTMLInputElement).onchange = async (e) => {
804 const file = (e.target as HTMLInputElement).files?.[0];
805 if (!file) return;
806 try {
807 const imported = importProject(await file.text());
808 if (!confirm('Importing replaces the current project. Continue?')) return;
809 for (const t of doc.types) for (const c of t.clips) { await deleteClipAudio(c.id); }
810 clipAudio.clear();
811 doc.types = imported.types;
812 doc.config = { ...DEFAULT_CONFIG, ...imported.config };
813 doc.algorithms = imported.algorithms;
814 for (const b of BUILTIN_ALGORITHMS) {
815 if (!doc.algorithms.some((a) => a.id === b.id)) doc.algorithms.push({ ...b });
816 }
817 for (const a of imported.clipAudio) {
818 clipAudio.set(a.id, { samples: a.samples, sr: a.sr });
819 await putClipAudio(a.id, a.samples, a.sr);
820 }
821 save();
822 closeClipEditor();
823 renderTypes(); renderConfig(); renderAlgos();
824 status(`imported ${imported.types.length} event types and ${imported.algorithms.length} algorithms`);
825 } catch (err) {
826 status(`import failed: ${(err as Error).message}`);
827 } finally {
828 ($('import-file') as HTMLInputElement).value = '';
829 }
830};
832$('reset-all').onclick = async () => {
833 if (!confirm('Forget all event types, recordings, algorithms, and results?')) return;
834 await clearAll();
835 location.reload();
836};
838// ---- startup ---------------------------------------------------------------------
840async function startup(): Promise<void> {
841 for (const t of doc.types) {
842 for (const c of t.clips) {
843 const a = await getClipAudio(c.id);
844 if (a) clipAudio.set(c.id, a);
845 }
846 }
847 bench = await getBenchmark();
848 renderTypes();
849 renderConfig();
850 renderAlgos();
851 renderResults();
852 populateExplorerControls();
853 updateDownloadButton();
854}
856void startup();