/ proof-of-concept / audio-spike-sorting
Sign in
proof-of-concept / audio-spike-sorting
audio-spike-sorting / src / main.ts
778 lines · 27.5 KBBlameHistoryRaw
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 './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);
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};
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;
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 {
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();
252function closeClipEditor(): void {
253 clipPlayer.stop();
254 edit = null;
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;
334const CONFIG_FIELDS: ConfigField[] = [
335 { key: 'durationS', label: 'dataset duration (s)', min: 5, max: 600, step: 1 },
336 { key: 'eventsPerType', label: 'events per type', min: 1, max: 2000, step: 1 },
337 { key: 'gainMinDb', label: 'gain range floor (dB)', min: -40, max: 0, step: 1 },
338 { key: 'overlap', label: 'overlap fraction (0-1)', min: 0, max: 1, step: 0.05 },
339 { key: 'trainFraction', label: 'training fraction (0-1)', min: 0.05, max: 0.95, step: 0.05 },
340 { key: 'runs', label: 'runs', min: 1, max: 50, step: 1 },
341 { key: 'seed', label: 'base seed', min: 0, max: 1e9, step: 1 },
342 { key: 'toleranceMs', label: 'match tolerance (ms)', min: 1, max: 200, step: 1 },
343 { key: 'preMs', label: 'snippet pre-onset (ms)', min: 0, max: 100, step: 5 },
344 { key: 'postMs', label: 'snippet post-onset (ms)', min: 20, max: 1000, step: 10 },
345];
347function renderConfig(): void {
348 const grid = $('config-grid');
349 grid.innerHTML = '';
350 for (const f of CONFIG_FIELDS) {
351 const label = document.createElement('label');
352 const span = document.createElement('span');
353 span.textContent = f.label;
354 const input = document.createElement('input');
355 input.type = 'number';
356 input.min = String(f.min); input.max = String(f.max); input.step = String(f.step);
357 input.value = String(doc.config[f.key]);
358 input.onchange = () => {
359 const v = Number(input.value);
360 if (Number.isFinite(v)) {
361 (doc.config[f.key] as number) = Math.min(f.max, Math.max(f.min, v));
362 input.value = String(doc.config[f.key]);
363 save();
364 if (f.key === 'preMs' || f.key === 'postMs') renderTypes();
365 }
366 };
367 label.append(span, input);
368 grid.appendChild(label);
369 }
372// ---- algorithms ----------------------------------------------------------------
374function algoById(id: string): AlgorithmDef | undefined { return doc.algorithms.find((a) => a.id === id); }
376function renderAlgos(): void {
377 const list = $('algo-list');
378 list.innerHTML = '';
379 doc.algorithms.forEach((algo) => {
380 const row = document.createElement('div');
381 row.className = 'algo-row';
383 const enable = document.createElement('input');
384 enable.type = 'checkbox';
385 enable.checked = algo.enabled;
386 enable.onchange = () => { algo.enabled = enable.checked; save(); };
387 row.appendChild(enable);
389 const name = document.createElement('strong');
390 name.textContent = algo.name;
391 row.appendChild(name);
393 if (algo.builtin) {
394 const tag = document.createElement('span');
395 tag.className = 'tag';
396 tag.textContent = 'built-in';
397 row.appendChild(tag);
398 }
400 const spacer = document.createElement('span');
401 spacer.className = 'spacer';
402 row.appendChild(spacer);
404 const editBtn = document.createElement('button');
405 editBtn.textContent = 'edit';
406 editBtn.onclick = () => openAlgoEditor(algo.id);
407 row.appendChild(editBtn);
409 list.appendChild(row);
410 });
413function openAlgoEditor(id: string): void {
414 const algo = algoById(id);
415 if (!algo) return;
416 editingAlgoId = id;
417 $('algo-editor').hidden = false;
418 const nameInput = $('algo-name') as HTMLInputElement;
419 nameInput.value = algo.name;
420 nameInput.disabled = !!algo.builtin;
421 ($('algo-source') as HTMLTextAreaElement).value = algo.source;
422 ($('algo-reset') as HTMLButtonElement).hidden = !algo.builtin;
423 ($('algo-remove') as HTMLButtonElement).hidden = !!algo.builtin;
424 $('algo-editor').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
427($('algo-name') as HTMLInputElement).onchange = () => {
428 const algo = editingAlgoId ? algoById(editingAlgoId) : null;
429 if (!algo || algo.builtin) return;
430 algo.name = ($('algo-name') as HTMLInputElement).value.trim() || algo.name;
431 save();
432 renderAlgos();
433};
435($('algo-source') as HTMLTextAreaElement).onchange = () => {
436 const algo = editingAlgoId ? algoById(editingAlgoId) : null;
437 if (!algo) return;
438 algo.source = ($('algo-source') as HTMLTextAreaElement).value;
439 save();
440};
442$('algo-reset').onclick = () => {
443 const algo = editingAlgoId ? algoById(editingAlgoId) : null;
444 const builtin = BUILTIN_ALGORITHMS.find((b) => b.id === algo?.id);
445 if (!algo || !builtin) return;
446 algo.source = builtin.source;
447 ($('algo-source') as HTMLTextAreaElement).value = algo.source;
448 save();
449};
451$('algo-remove').onclick = () => {
452 const algo = editingAlgoId ? algoById(editingAlgoId) : null;
453 if (!algo || algo.builtin) return;
454 if (!confirm(`Delete algorithm "${algo.name}"?`)) return;
455 doc.algorithms = doc.algorithms.filter((a) => a.id !== algo.id);
456 editingAlgoId = null;
457 $('algo-editor').hidden = true;
458 save();
459 renderAlgos();
460};
462$('algo-close').onclick = () => {
463 editingAlgoId = null;
464 $('algo-editor').hidden = true;
465};
467$('add-algo').onclick = () => {
468 const algo: AlgorithmDef = {
469 id: uid(),
470 name: `my_sorter_${doc.algorithms.length}`,
471 source: NEW_ALGORITHM_TEMPLATE,
472 enabled: true,
473 };
474 doc.algorithms.push(algo);
475 save();
476 renderAlgos();
477 openAlgoEditor(algo.id);
478};
480// ---- benchmark -----------------------------------------------------------------
482$('run-bench').onclick = async () => {
483 const enabled = doc.algorithms.filter((a) => a.enabled);
484 if (!enabled.length) { status('enable at least one algorithm'); return; }
485 const lib = extractLibrary(doc.types, doc.config.preMs, doc.config.postMs, getAudio);
486 const perType = doc.types.map((t, i) => lib.snippets.filter((s) => s.typeIndex === i).length);
487 if (!doc.types.length || perType.some((n) => n < 2)) {
488 status('every event type needs at least 2 usable snippets (one to train, one to place)');
489 return;
490 }
492 ($('run-bench') as HTMLButtonElement).disabled = true;
493 $('cancel-bench').hidden = false;
494 const progress = $('bench-progress') as HTMLProgressElement;
495 progress.hidden = false;
497 try {
498 const out = await runner.run(
499 lib, doc.config, doc.config.runs, doc.config.toleranceMs, enabled,
500 (p) => {
501 progress.max = p.total;
502 progress.value = p.done;
503 $('bench-label').textContent = `run ${p.runIndex + 1}/${p.runCount}: ${p.algorithmName}`;
504 },
505 );
506 if (!runner.cancelled) {
507 bench = {
508 when: Date.now(),
509 names: doc.types.map((t) => t.name),
510 colors: doc.types.map((t) => t.color),
511 algos: enabled.map((a) => ({ id: a.id, name: a.name })),
512 config: { ...doc.config },
513 runs: out.runs,
514 results: out.results,
515 };
516 await putBenchmark(bench);
517 renderResults();
518 populateExplorerControls();
519 status(`benchmark complete: ${out.results.length} algorithm runs`);
520 } else {
521 status('benchmark cancelled');
522 }
523 } catch (err) {
524 status(`benchmark failed: ${(err as Error).message}`);
525 } finally {
526 ($('run-bench') as HTMLButtonElement).disabled = false;
527 $('cancel-bench').hidden = true;
528 progress.hidden = true;
529 $('bench-label').textContent = '';
530 }
531};
533$('cancel-bench').onclick = () => runner.cancel();
535// ---- results -------------------------------------------------------------------
537function mean(v: number[]): number { return v.length ? v.reduce((a, b) => a + b, 0) / v.length : 0; }
538function std(v: number[]): number {
539 if (v.length < 2) return 0;
540 const m = mean(v);
541 return Math.sqrt(v.reduce((a, b) => a + (b - m) * (b - m), 0) / (v.length - 1));
544function renderResults(): void {
545 const host = $('results');
546 host.innerHTML = '';
547 if (!bench) return;
548 const b = bench;
550 const table = document.createElement('table');
551 table.className = 'results';
552 table.innerHTML = `<tr>
553 <th>algorithm</th><th>accuracy</th><th>correct</th><th>misclassified</th>
554 <th>missed</th><th>false pos.</th><th>truth</th><th>time/run</th></tr>`;
556 for (const algo of b.algos) {
557 const rs = b.results.filter((r) => r.algorithmId === algo.id);
558 const failed = rs.filter((r) => r.error);
559 const accs = rs.map((r) => r.score.accuracy);
560 const sum = (f: (r: RunResult) => number) => rs.reduce((a, r) => a + f(r), 0);
561 const tr = document.createElement('tr');
562 const acc = `${mean(accs).toFixed(3)} ± ${std(accs).toFixed(3)}`;
563 tr.innerHTML = `<td>${algo.name}${failed.length ? ` <span class="err">(${failed.length} failed)</span>` : ''}</td>
564 <td>${acc}</td>
565 <td>${sum((r) => r.score.correct)}</td>
566 <td>${sum((r) => r.score.misclassified)}</td>
567 <td>${sum((r) => r.score.missed)}</td>
568 <td>${sum((r) => r.score.falsePositives)}</td>
569 <td>${sum((r) => r.score.nTruth)}</td>
570 <td>${Math.round(mean(rs.map((r) => r.elapsedMs)))} ms</td>`;
571 table.appendChild(tr);
572 }
573 host.appendChild(table);
575 // one confusion matrix per algorithm, summed over runs
576 for (const algo of b.algos) {
577 const rs = b.results.filter((r) => r.algorithmId === algo.id && !r.error);
578 if (!rs.length) {
579 const err = b.results.find((r) => r.algorithmId === algo.id && r.error);
580 if (err) {
581 const p = document.createElement('p');
582 p.className = 'err';
583 p.textContent = `${algo.name}: ${err.error}`;
584 host.appendChild(p);
585 }
586 continue;
587 }
588 const n = b.names.length;
589 const conf = Array.from({ length: n + 1 }, () => new Array<number>(n + 1).fill(0));
590 for (const r of rs) {
591 for (let i = 0; i <= n; i++) for (let j = 0; j <= n; j++) conf[i][j] += r.score.confusion[i][j];
592 }
593 const det = document.createElement('details');
594 const summary = document.createElement('summary');
595 summary.textContent = `${algo.name}: confusion matrix (all runs)`;
596 summary.style.cssText = 'cursor:pointer;font-size:0.85rem;margin-top:0.5rem';
597 det.appendChild(summary);
598 const t = document.createElement('table');
599 t.className = 'confusion';
600 const head = ['true \\ detected', ...b.names, 'missed'];
601 t.innerHTML = `<tr>${head.map((h) => `<th>${h}</th>`).join('')}</tr>`;
602 for (let i = 0; i < n; i++) {
603 const tr = document.createElement('tr');
604 tr.innerHTML = `<th>${b.names[i]}</th>` + conf[i].map((v, j) => {
605 const cls = j === i ? 'diag' : v > 0 ? 'bad' : '';
606 return `<td class="${cls}">${v}</td>`;
607 }).join('');
608 t.appendChild(tr);
609 }
610 const fpRow = document.createElement('tr');
611 fpRow.innerHTML = `<th>false pos.</th>` + conf[n].slice(0, n).map((v) => `<td class="${v > 0 ? 'bad' : ''}">${v}</td>`).join('') + '<td></td>';
612 t.appendChild(fpRow);
613 det.appendChild(t);
614 host.appendChild(det);
615 }
617 const failures = b.results.filter((r) => r.error);
618 if (failures.length) {
619 const p = document.createElement('p');
620 p.className = 'err';
621 p.textContent = failures.map((r) => {
622 const name = b.algos.find((a) => a.id === r.algorithmId)?.name ?? r.algorithmId;
623 return `${name} (run ${r.runIndex + 1}): ${r.error}`;
624 }).join('\n');
625 host.appendChild(p);
626 }
629// ---- explorer -------------------------------------------------------------------
631const explorer = new Explorer($('explorer') as HTMLCanvasElement);
632explorer.onHover = (text) => { $('explore-hover').textContent = text; };
633explorer.onAudition = (t) => {
634 const run = currentExplorerRun();
635 if (!run) return;
636 const s0 = Math.round((t - 0.05) * FS);
637 playSlice(run.x, FS, s0, s0 + Math.round(0.4 * FS));
638};
640function currentExplorerRun() {
641 if (!bench) return null;
642 const ri = Number(($('explore-run') as HTMLSelectElement).value);
643 return bench.runs[ri] ?? null;
646// ---- run playback ----------------------------------------------------------
648const runPlayer = new Player(audio);
649runPlayer.onTick = (t) => explorer.setPlayhead(t);
650runPlayer.onStateChange = () => updatePlayButton();
652function updatePlayButton(): void {
653 const btn = $('explore-play') as HTMLButtonElement;
654 btn.disabled = !currentExplorerRun();
655 if (runPlayer.playing) {
656 btn.innerHTML = '&#9632; Stop';
657 } else {
658 btn.innerHTML = explorer.getSelection() ? '&#9654; Play selection' : '&#9654; Play';
659 }
662$('explore-play').onclick = () => {
663 if (runPlayer.playing) { runPlayer.stop(); return; }
664 const run = currentExplorerRun();
665 if (!run) return;
666 const sel = explorer.getSelection();
667 runPlayer.start(run.x, FS, sel ? sel.t0 : 0, sel ? sel.t1 : run.x.length / FS);
668};
669explorer.onSelectionChange = () => { if (!runPlayer.playing) updatePlayButton(); };
671function populateExplorerControls(): void {
672 const runSel = $('explore-run') as HTMLSelectElement;
673 const algoSel = $('explore-algo') as HTMLSelectElement;
674 runSel.innerHTML = '';
675 algoSel.innerHTML = '';
676 if (!bench) { updateExplorer(); return; }
677 bench.runs.forEach((r, i) => {
678 const o = document.createElement('option');
679 o.value = String(i);
680 o.textContent = `run ${i + 1} (seed ${r.seed})`;
681 runSel.appendChild(o);
682 });
683 for (const a of bench.algos) {
684 const o = document.createElement('option');
685 o.value = a.id;
686 o.textContent = a.name;
687 algoSel.appendChild(o);
688 }
689 updateExplorer();
692function updateExplorer(): void {
693 runPlayer.stop();
694 if (!bench) { explorer.set(null); updatePlayButton(); return; }
695 const ri = Number(($('explore-run') as HTMLSelectElement).value || '0');
696 const algoId = ($('explore-algo') as HTMLSelectElement).value;
697 const run = bench.runs[ri];
698 const result = bench.results.find((r) => r.runIndex === ri && r.algorithmId === algoId);
699 if (!run || !result) { explorer.set(null); updatePlayButton(); return; }
700 explorer.set({
701 x: run.x,
702 fs: FS,
703 names: bench.names,
704 colors: bench.colors,
705 events: result.score.events,
706 });
707 updatePlayButton();
710($('explore-run') as HTMLSelectElement).onchange = updateExplorer;
711($('explore-algo') as HTMLSelectElement).onchange = updateExplorer;
713// ---- export / import / reset -----------------------------------------------------
715$('export').onclick = () => {
716 const json = exportProject(doc.types, doc.config, doc.algorithms, getAudio);
717 const blob = new Blob([json], { type: 'application/json' });
718 const a = document.createElement('a');
719 a.href = URL.createObjectURL(blob);
720 a.download = 'audio-spike-sorting-project.json';
721 a.click();
722 URL.revokeObjectURL(a.href);
723};
725$('import').onclick = () => ($('import-file') as HTMLInputElement).click();
726($('import-file') as HTMLInputElement).onchange = async (e) => {
727 const file = (e.target as HTMLInputElement).files?.[0];
728 if (!file) return;
729 try {
730 const imported = importProject(await file.text());
731 if (!confirm('Importing replaces the current project. Continue?')) return;
732 for (const t of doc.types) for (const c of t.clips) { await deleteClipAudio(c.id); }
733 clipAudio.clear();
734 doc.types = imported.types;
735 doc.config = { ...DEFAULT_CONFIG, ...imported.config };
736 doc.algorithms = imported.algorithms;
737 for (const b of BUILTIN_ALGORITHMS) {
738 if (!doc.algorithms.some((a) => a.id === b.id)) doc.algorithms.push({ ...b });
739 }
740 for (const a of imported.clipAudio) {
741 clipAudio.set(a.id, { samples: a.samples, sr: a.sr });
742 await putClipAudio(a.id, a.samples, a.sr);
743 }
744 save();
745 closeClipEditor();
746 renderTypes(); renderConfig(); renderAlgos();
747 status(`imported ${imported.types.length} event types and ${imported.algorithms.length} algorithms`);
748 } catch (err) {
749 status(`import failed: ${(err as Error).message}`);
750 } finally {
751 ($('import-file') as HTMLInputElement).value = '';
752 }
753};
755$('reset-all').onclick = async () => {
756 if (!confirm('Forget all event types, recordings, algorithms, and results?')) return;
757 await clearAll();
758 location.reload();
759};
761// ---- startup ---------------------------------------------------------------------
763async function startup(): Promise<void> {
764 for (const t of doc.types) {
765 for (const c of t.clips) {
766 const a = await getClipAudio(c.id);
767 if (a) clipAudio.set(c.id, a);
768 }
769 }
770 bench = await getBenchmark();
771 renderTypes();
772 renderConfig();
773 renderAlgos();
774 renderResults();
775 populateExplorerControls();
778void startup();
↑↓ move↵openescclose