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 { detectOnsets } from './analysis/onsets.ts';
10import { BUILTIN_ALGORITHMS, NEW_ALGORITHM_TEMPLATE } from './algorithms/index.ts';
11import { extractLibrary, snippetCounts } from './bench/snippets.ts';
12import { BenchmarkRunner } from './bench/runner.ts';
13import { FS, type AlgorithmDef, type RunResult } from './bench/types.ts';
14import { exportProject, importProject } from './export.ts';
15import {
16 clearAll, deleteClipAudio, getBenchmark, getClipAudio, loadDoc, putBenchmark, putClipAudio,
17 saveDoc, DEFAULT_CONFIG, type BenchConfig, type EventType, type StoredBenchmark,
18} from './store.ts';
19import { WaveView, type Marker } from './ui/waveform.ts';
20import { Explorer } from './ui/explorer.ts';
22const PALETTE = ['#e4573d', '#3d7de4', '#3dbb6e', '#d9a520', '#9061d9', '#d95fa8', '#4fb8c9', '#96a13a'];
24const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
25const uid = () => Math.random().toString(36).slice(2, 10);
27// ---- state ------------------------------------------------------------------
29const doc = loadDoc();
30// Built-ins are merged in by id, so an edited built-in keeps its edits and
31// a fresh store gets all three.
32for (const b of BUILTIN_ALGORITHMS) {
33 if (!doc.algorithms.some((a) => a.id === b.id)) doc.algorithms.push({ ...b });
34}
35const clipAudio = new Map<string, { samples: Float32Array; sr: number }>();
36let bench: StoredBenchmark | null = null;
38let actx: AudioContext | null = null;
39const audio = (): AudioContext => (actx ??= new AudioContext());
40let recorder: Recorder | null = null;
41let recTypeId: string | null = null;
42let meterTimer = 0;
43let edit: { typeId: string; clipId: string } | null = null;
44let editingAlgoId: string | null = null;
45const runner = new BenchmarkRunner();
47function save(): void { saveDoc(doc); }
48function status(msg: string): void { $('status').textContent = msg; }
49function typeById(id: string): EventType | undefined { return doc.types.find((t) => t.id === id); }
50const getAudio = (clipId: string) => clipAudio.get(clipId) ?? null;
52// ---- audition ----------------------------------------------------------------
54function playSlice(samples: Float32Array, sr: number, s0: number, s1: number): void {
55 const ctx = audio();
56 const n = Math.min(s1, samples.length) - Math.max(s0, 0);
57 if (n <= 0) return;
58 const buf = ctx.createBuffer(1, n, sr);
59 buf.getChannelData(0).set(samples.subarray(Math.max(s0, 0), Math.max(s0, 0) + n));
60 const src = ctx.createBufferSource();
61 src.buffer = buf;
62 src.connect(ctx.destination);
63 src.start();
64}
66// ---- event types ---------------------------------------------------------------
68function renderTypes(): void {
69 const list = $('type-list');
70 list.innerHTML = '';
71 const counts = snippetCounts(doc.types, doc.config.preMs, doc.config.postMs, getAudio);
72 doc.types.forEach((type) => {
73 const row = document.createElement('div');
74 row.className = 'type-row';
76 const swatch = document.createElement('div');
77 swatch.className = 'swatch';
78 swatch.style.background = type.color;
79 row.appendChild(swatch);
81 const name = document.createElement('input');
82 name.className = 'name';
83 name.value = type.name;
84 name.onchange = () => { type.name = name.value.trim() || type.name; save(); };
85 row.appendChild(name);
87 const count = document.createElement('span');
88 count.className = 'count';
89 const nClips = type.clips.length;
90 count.textContent = `${counts.get(type.id) ?? 0} snippets in ${nClips} clip${nClips === 1 ? '' : 's'}`;
91 row.appendChild(count);
93 const rec = document.createElement('button');
94 const recording = recTypeId === type.id;
95 rec.innerHTML = recording ? '<span class="rec">■</span> stop' : '● record examples';
96 rec.disabled = recTypeId !== null && !recording;
97 rec.onclick = () => (recording ? stopRecording() : startRecording(type.id));
98 row.appendChild(rec);
100 if (type.clips.length) {
101 const editBtn = document.createElement('button');
102 editBtn.textContent = 'edit clips';
103 editBtn.onclick = () => openClipEditor(type.id, type.clips[type.clips.length - 1].id);
104 row.appendChild(editBtn);
105 }
107 const spacer = document.createElement('span');
108 spacer.className = 'spacer';
109 row.appendChild(spacer);
111 const del = document.createElement('button');
112 del.textContent = 'delete';
113 del.onclick = async () => {
114 if (!confirm(`Delete event type "${type.name}" and its ${type.clips.length} clip(s)?`)) return;
115 for (const c of type.clips) { await deleteClipAudio(c.id); clipAudio.delete(c.id); }
116 doc.types = doc.types.filter((t) => t.id !== type.id);
117 if (edit?.typeId === type.id) closeClipEditor();
118 save();
119 renderTypes();
120 };
121 row.appendChild(del);
123 list.appendChild(row);
124 });
125}
127$('add-type').onclick = () => {
128 const nameInput = $('type-name') as HTMLInputElement;
129 const name = nameInput.value.trim();
130 if (!name) { status('give the event type a name first'); return; }
131 doc.types.push({ id: uid(), name, color: PALETTE[doc.types.length % PALETTE.length], clips: [] });
132 nameInput.value = '';
133 save();
134 renderTypes();
135};
137$('load-demo').onclick = async () => {
138 const { makeDemoData, demoTypes } = await import('./demo.ts');
139 const clips = makeDemoData();
140 const types = demoTypes(clips);
141 for (const c of clips) {
142 clipAudio.set(c.meta.id, { samples: c.samples, sr: FS });
143 await putClipAudio(c.meta.id, c.samples, FS);
144 }
145 doc.types.push(...types);
146 save();
147 renderTypes();
148 status('demo data loaded: three synthetic event types, ready to benchmark');
149};
151// ---- recording ---------------------------------------------------------------
153async function startRecording(typeId: string): Promise<void> {
154 recorder = new Recorder(audio());
155 try {
156 await recorder.start();
157 } catch (err) {
158 status(`microphone: ${(err as Error).message}`);
159 recorder = null;
160 return;
161 }
162 recTypeId = typeId;
163 const meter = $('meter');
164 meter.hidden = false;
165 const bar = meter.firstElementChild as HTMLElement;
166 meterTimer = window.setInterval(() => { bar.style.width = `${(recorder?.level() ?? 0) * 100}%`; }, 60);
167 status(`recording examples of "${typeById(typeId)?.name}"; separate events by at least 200 ms`);
168 renderTypes();
169}
171async function stopRecording(): Promise<void> {
172 if (!recorder || !recTypeId) return;
173 const typeId = recTypeId;
174 recTypeId = null;
175 clearInterval(meterTimer);
176 $('meter').hidden = true;
177 let clip: Clip;
178 try {
179 clip = await recorder.stop(FS);
180 } catch (err) {
181 status(`recording failed: ${(err as Error).message}`);
182 recorder = null;
183 renderTypes();
184 return;
185 }
186 recorder = null;
187 const type = typeById(typeId);
188 if (!type) return;
189 const sens = 0.6;
190 const onsets = detectOnsets(clip.samples, clip.sr, sens);
191 const clipId = uid();
192 clipAudio.set(clipId, { samples: clip.samples, sr: clip.sr });
193 await putClipAudio(clipId, clip.samples, clip.sr);
194 type.clips.push({ id: clipId, sens, onsets, excluded: [] });
195 save();
196 renderTypes();
197 openClipEditor(typeId, clipId);
198 status(`${onsets.length} events detected; audition and exclude bad ones, then Done`);
199}
201// ---- clip editor ---------------------------------------------------------------
203const waveView = new WaveView($('clip-wave') as HTMLCanvasElement);
205function currentClip() {
206 if (!edit) return null;
207 const type = typeById(edit.typeId);
208 const meta = type?.clips.find((c) => c.id === edit!.clipId);
209 const a = meta ? clipAudio.get(meta.id) : null;
210 return type && meta && a ? { type, meta, a } : null;
211}
213function markersOf(meta: { onsets: number[]; excluded: number[] }): Marker[] {
214 const ex = new Set(meta.excluded);
215 return meta.onsets.map((t, i) => ({ t, excluded: ex.has(i) }));
216}
218function openClipEditor(typeId: string, clipId: string): void {
219 edit = { typeId, clipId };
220 const cur = currentClip();
221 if (!cur) { closeClipEditor(); return; }
222 $('clip-editor').hidden = false;
223 $('clip-title').textContent = cur.type.name;
224 const sel = $('clip-select') as HTMLSelectElement;
225 sel.innerHTML = '';
226 cur.type.clips.forEach((c, i) => {
227 const o = document.createElement('option');
228 o.value = c.id;
229 o.textContent = `clip ${i + 1} (${c.onsets.length} events)`;
230 o.selected = c.id === clipId;
231 sel.appendChild(o);
232 });
233 ($('clip-sens') as HTMLInputElement).value = String(cur.meta.sens);
234 waveView.set({ samples: cur.a.samples, sr: cur.a.sr }, markersOf(cur.meta), cur.type.color);
235 updateClipCount();
236}
238function closeClipEditor(): void {
239 edit = null;
240 $('clip-editor').hidden = true;
241}
243function updateClipCount(): void {
244 const cur = currentClip();
245 if (!cur) return;
246 const kept = cur.meta.onsets.length - cur.meta.excluded.length;
247 $('clip-count').textContent = `${kept} of ${cur.meta.onsets.length} events kept`;
248 ($('clip-exclude') as HTMLButtonElement).disabled = waveView.selection.size === 0;
249}
251waveView.onAudition = (i) => {
252 const cur = currentClip();
253 if (!cur) return;
254 const s0 = Math.max(0, Math.round((cur.meta.onsets[i] - 0.02) * cur.a.sr));
255 playSlice(cur.a.samples, cur.a.sr, s0, s0 + Math.round(0.3 * cur.a.sr));
256};
257waveView.onSelectionChange = () => updateClipCount();
259($('clip-select') as HTMLSelectElement).onchange = (e) => {
260 if (edit) openClipEditor(edit.typeId, (e.target as HTMLSelectElement).value);
261};
263($('clip-sens') as HTMLInputElement).oninput = () => {
264 const cur = currentClip();
265 if (!cur) return;
266 cur.meta.sens = Number(($('clip-sens') as HTMLInputElement).value);
267 cur.meta.onsets = detectOnsets(cur.a.samples, cur.a.sr, cur.meta.sens);
268 cur.meta.excluded = [];
269 save();
270 waveView.set({ samples: cur.a.samples, sr: cur.a.sr }, markersOf(cur.meta), cur.type.color);
271 updateClipCount();
272 renderTypes();
273};
275function toggleExcludeSelection(): void {
276 const cur = currentClip();
277 if (!cur || waveView.selection.size === 0) return;
278 const ex = new Set(cur.meta.excluded);
279 const allExcluded = [...waveView.selection].every((i) => ex.has(i));
280 for (const i of waveView.selection) {
281 if (allExcluded) ex.delete(i); else ex.add(i);
282 }
283 cur.meta.excluded = [...ex].sort((a, b) => a - b);
284 save();
285 waveView.updateMarkers(markersOf(cur.meta));
286 updateClipCount();
287 renderTypes();
288}
290$('clip-exclude').onclick = toggleExcludeSelection;
291document.addEventListener('keydown', (e) => {
292 if (e.key.toLowerCase() === 'x' && edit && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)) {
293 toggleExcludeSelection();
294 }
295});
297$('clip-delete').onclick = async () => {
298 const cur = currentClip();
299 if (!cur) return;
300 if (!confirm('Delete this clip and its events?')) return;
301 await deleteClipAudio(cur.meta.id);
302 clipAudio.delete(cur.meta.id);
303 cur.type.clips = cur.type.clips.filter((c) => c.id !== cur.meta.id);
304 save();
305 renderTypes();
306 if (cur.type.clips.length) openClipEditor(cur.type.id, cur.type.clips[cur.type.clips.length - 1].id);
307 else closeClipEditor();
308};
310$('clip-done').onclick = closeClipEditor;
312// ---- configuration -------------------------------------------------------------
314interface ConfigField {
315 key: keyof BenchConfig;
316 label: string;
317 min: number; max: number; step: number;
318}
319const CONFIG_FIELDS: ConfigField[] = [
320 { key: 'durationS', label: 'dataset duration (s)', min: 5, max: 600, step: 1 },
321 { key: 'eventsPerType', label: 'events per type', min: 1, max: 2000, step: 1 },
322 { key: 'gainMinDb', label: 'gain range floor (dB)', min: -40, max: 0, step: 1 },
323 { key: 'overlap', label: 'overlap fraction (0-1)', min: 0, max: 1, step: 0.05 },
324 { key: 'trainFraction', label: 'training fraction (0-1)', min: 0.05, max: 0.95, step: 0.05 },
325 { key: 'runs', label: 'runs', min: 1, max: 50, step: 1 },
326 { key: 'seed', label: 'base seed', min: 0, max: 1e9, step: 1 },
327 { key: 'toleranceMs', label: 'match tolerance (ms)', min: 1, max: 200, step: 1 },
328 { key: 'preMs', label: 'snippet pre-onset (ms)', min: 0, max: 100, step: 5 },
329 { key: 'postMs', label: 'snippet post-onset (ms)', min: 20, max: 1000, step: 10 },
330];
332function renderConfig(): void {
333 const grid = $('config-grid');
334 grid.innerHTML = '';
335 for (const f of CONFIG_FIELDS) {
336 const label = document.createElement('label');
337 const span = document.createElement('span');
338 span.textContent = f.label;
339 const input = document.createElement('input');
340 input.type = 'number';
341 input.min = String(f.min); input.max = String(f.max); input.step = String(f.step);
342 input.value = String(doc.config[f.key]);
343 input.onchange = () => {
344 const v = Number(input.value);
345 if (Number.isFinite(v)) {
346 (doc.config[f.key] as number) = Math.min(f.max, Math.max(f.min, v));
347 input.value = String(doc.config[f.key]);
348 save();
349 if (f.key === 'preMs' || f.key === 'postMs') renderTypes();
350 }
351 };
352 label.append(span, input);
353 grid.appendChild(label);
354 }
355}
357// ---- algorithms ----------------------------------------------------------------
359function algoById(id: string): AlgorithmDef | undefined { return doc.algorithms.find((a) => a.id === id); }
361function renderAlgos(): void {
362 const list = $('algo-list');
363 list.innerHTML = '';
364 doc.algorithms.forEach((algo) => {
365 const row = document.createElement('div');
366 row.className = 'algo-row';
368 const enable = document.createElement('input');
369 enable.type = 'checkbox';
370 enable.checked = algo.enabled;
371 enable.onchange = () => { algo.enabled = enable.checked; save(); };
372 row.appendChild(enable);
374 const name = document.createElement('strong');
375 name.textContent = algo.name;
376 row.appendChild(name);
378 if (algo.builtin) {
379 const tag = document.createElement('span');
380 tag.className = 'tag';
381 tag.textContent = 'built-in';
382 row.appendChild(tag);
383 }
385 const spacer = document.createElement('span');
386 spacer.className = 'spacer';
387 row.appendChild(spacer);
389 const editBtn = document.createElement('button');
390 editBtn.textContent = 'edit';
391 editBtn.onclick = () => openAlgoEditor(algo.id);
392 row.appendChild(editBtn);
394 list.appendChild(row);
395 });
396}
398function openAlgoEditor(id: string): void {
399 const algo = algoById(id);
400 if (!algo) return;
401 editingAlgoId = id;
402 $('algo-editor').hidden = false;
403 const nameInput = $('algo-name') as HTMLInputElement;
404 nameInput.value = algo.name;
405 nameInput.disabled = !!algo.builtin;
406 ($('algo-source') as HTMLTextAreaElement).value = algo.source;
407 ($('algo-reset') as HTMLButtonElement).hidden = !algo.builtin;
408 ($('algo-remove') as HTMLButtonElement).hidden = !!algo.builtin;
409 $('algo-editor').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
410}
412($('algo-name') as HTMLInputElement).onchange = () => {
413 const algo = editingAlgoId ? algoById(editingAlgoId) : null;
414 if (!algo || algo.builtin) return;
415 algo.name = ($('algo-name') as HTMLInputElement).value.trim() || algo.name;
416 save();
417 renderAlgos();
418};
420($('algo-source') as HTMLTextAreaElement).onchange = () => {
421 const algo = editingAlgoId ? algoById(editingAlgoId) : null;
422 if (!algo) return;
423 algo.source = ($('algo-source') as HTMLTextAreaElement).value;
424 save();
425};
427$('algo-reset').onclick = () => {
428 const algo = editingAlgoId ? algoById(editingAlgoId) : null;
429 const builtin = BUILTIN_ALGORITHMS.find((b) => b.id === algo?.id);
430 if (!algo || !builtin) return;
431 algo.source = builtin.source;
432 ($('algo-source') as HTMLTextAreaElement).value = algo.source;
433 save();
434};
436$('algo-remove').onclick = () => {
437 const algo = editingAlgoId ? algoById(editingAlgoId) : null;
438 if (!algo || algo.builtin) return;
439 if (!confirm(`Delete algorithm "${algo.name}"?`)) return;
440 doc.algorithms = doc.algorithms.filter((a) => a.id !== algo.id);
441 editingAlgoId = null;
442 $('algo-editor').hidden = true;
443 save();
444 renderAlgos();
445};
447$('algo-close').onclick = () => {
448 editingAlgoId = null;
449 $('algo-editor').hidden = true;
450};
452$('add-algo').onclick = () => {
453 const algo: AlgorithmDef = {
454 id: uid(),
455 name: `my_sorter_${doc.algorithms.length}`,
456 source: NEW_ALGORITHM_TEMPLATE,
457 enabled: true,
458 };
459 doc.algorithms.push(algo);
460 save();
461 renderAlgos();
462 openAlgoEditor(algo.id);
463};
465// ---- benchmark -----------------------------------------------------------------
467$('run-bench').onclick = async () => {
468 const enabled = doc.algorithms.filter((a) => a.enabled);
469 if (!enabled.length) { status('enable at least one algorithm'); return; }
470 const lib = extractLibrary(doc.types, doc.config.preMs, doc.config.postMs, getAudio);
471 const perType = doc.types.map((t, i) => lib.snippets.filter((s) => s.typeIndex === i).length);
472 if (!doc.types.length || perType.some((n) => n < 2)) {
473 status('every event type needs at least 2 usable snippets (one to train, one to place)');
474 return;
475 }
477 ($('run-bench') as HTMLButtonElement).disabled = true;
478 $('cancel-bench').hidden = false;
479 const progress = $('bench-progress') as HTMLProgressElement;
480 progress.hidden = false;
482 try {
483 const out = await runner.run(
484 lib, doc.config, doc.config.runs, doc.config.toleranceMs, enabled,
485 (p) => {
486 progress.max = p.total;
487 progress.value = p.done;
488 $('bench-label').textContent = `run ${p.runIndex + 1}/${p.runCount}: ${p.algorithmName}`;
489 },
490 );
491 if (!runner.cancelled) {
492 bench = {
493 when: Date.now(),
494 names: doc.types.map((t) => t.name),
495 colors: doc.types.map((t) => t.color),
496 algos: enabled.map((a) => ({ id: a.id, name: a.name })),
497 config: { ...doc.config },
498 runs: out.runs,
499 results: out.results,
500 };
501 await putBenchmark(bench);
502 renderResults();
503 populateExplorerControls();
504 status(`benchmark complete: ${out.results.length} algorithm runs`);
505 } else {
506 status('benchmark cancelled');
507 }
508 } catch (err) {
509 status(`benchmark failed: ${(err as Error).message}`);
510 } finally {
511 ($('run-bench') as HTMLButtonElement).disabled = false;
512 $('cancel-bench').hidden = true;
513 progress.hidden = true;
514 $('bench-label').textContent = '';
515 }
516};
518$('cancel-bench').onclick = () => runner.cancel();
520// ---- results -------------------------------------------------------------------
522function mean(v: number[]): number { return v.length ? v.reduce((a, b) => a + b, 0) / v.length : 0; }
523function std(v: number[]): number {
524 if (v.length < 2) return 0;
525 const m = mean(v);
526 return Math.sqrt(v.reduce((a, b) => a + (b - m) * (b - m), 0) / (v.length - 1));
527}
529function renderResults(): void {
530 const host = $('results');
531 host.innerHTML = '';
532 if (!bench) return;
533 const b = bench;
535 const table = document.createElement('table');
536 table.className = 'results';
537 table.innerHTML = `<tr>
538 <th>algorithm</th><th>accuracy</th><th>correct</th><th>misclassified</th>
539 <th>missed</th><th>false pos.</th><th>truth</th><th>time/run</th></tr>`;
541 for (const algo of b.algos) {
542 const rs = b.results.filter((r) => r.algorithmId === algo.id);
543 const failed = rs.filter((r) => r.error);
544 const accs = rs.map((r) => r.score.accuracy);
545 const sum = (f: (r: RunResult) => number) => rs.reduce((a, r) => a + f(r), 0);
546 const tr = document.createElement('tr');
547 const acc = `${mean(accs).toFixed(3)} ± ${std(accs).toFixed(3)}`;
548 tr.innerHTML = `<td>${algo.name}${failed.length ? ` <span class="err">(${failed.length} failed)</span>` : ''}</td>
549 <td>${acc}</td>
550 <td>${sum((r) => r.score.correct)}</td>
551 <td>${sum((r) => r.score.misclassified)}</td>
552 <td>${sum((r) => r.score.missed)}</td>
553 <td>${sum((r) => r.score.falsePositives)}</td>
554 <td>${sum((r) => r.score.nTruth)}</td>
555 <td>${Math.round(mean(rs.map((r) => r.elapsedMs)))} ms</td>`;
556 table.appendChild(tr);
557 }
558 host.appendChild(table);
560 // one confusion matrix per algorithm, summed over runs
561 for (const algo of b.algos) {
562 const rs = b.results.filter((r) => r.algorithmId === algo.id && !r.error);
563 if (!rs.length) {
564 const err = b.results.find((r) => r.algorithmId === algo.id && r.error);
565 if (err) {
566 const p = document.createElement('p');
567 p.className = 'err';
568 p.textContent = `${algo.name}: ${err.error}`;
569 host.appendChild(p);
570 }
571 continue;
572 }
573 const n = b.names.length;
574 const conf = Array.from({ length: n + 1 }, () => new Array<number>(n + 1).fill(0));
575 for (const r of rs) {
576 for (let i = 0; i <= n; i++) for (let j = 0; j <= n; j++) conf[i][j] += r.score.confusion[i][j];
577 }
578 const det = document.createElement('details');
579 const summary = document.createElement('summary');
580 summary.textContent = `${algo.name}: confusion matrix (all runs)`;
581 summary.style.cssText = 'cursor:pointer;font-size:0.85rem;margin-top:0.5rem';
582 det.appendChild(summary);
583 const t = document.createElement('table');
584 t.className = 'confusion';
585 const head = ['true \\ detected', ...b.names, 'missed'];
586 t.innerHTML = `<tr>${head.map((h) => `<th>${h}</th>`).join('')}</tr>`;
587 for (let i = 0; i < n; i++) {
588 const tr = document.createElement('tr');
589 tr.innerHTML = `<th>${b.names[i]}</th>` + conf[i].map((v, j) => {
590 const cls = j === i ? 'diag' : v > 0 ? 'bad' : '';
591 return `<td class="${cls}">${v}</td>`;
592 }).join('');
593 t.appendChild(tr);
594 }
595 const fpRow = document.createElement('tr');
596 fpRow.innerHTML = `<th>false pos.</th>` + conf[n].slice(0, n).map((v) => `<td class="${v > 0 ? 'bad' : ''}">${v}</td>`).join('') + '<td></td>';
597 t.appendChild(fpRow);
598 det.appendChild(t);
599 host.appendChild(det);
600 }
602 const failures = b.results.filter((r) => r.error);
603 if (failures.length) {
604 const p = document.createElement('p');
605 p.className = 'err';
606 p.textContent = failures.map((r) => {
607 const name = b.algos.find((a) => a.id === r.algorithmId)?.name ?? r.algorithmId;
608 return `${name} (run ${r.runIndex + 1}): ${r.error}`;
609 }).join('\n');
610 host.appendChild(p);
611 }
612}
614// ---- explorer -------------------------------------------------------------------
616const explorer = new Explorer($('explorer') as HTMLCanvasElement);
617explorer.onHover = (text) => { $('explore-hover').textContent = text; };
618explorer.onAudition = (t) => {
619 const run = currentExplorerRun();
620 if (!run) return;
621 const s0 = Math.round((t - 0.05) * FS);
622 playSlice(run.x, FS, s0, s0 + Math.round(0.4 * FS));
623};
625function currentExplorerRun() {
626 if (!bench) return null;
627 const ri = Number(($('explore-run') as HTMLSelectElement).value);
628 return bench.runs[ri] ?? null;
629}
631// ---- run playback ----------------------------------------------------------
633let playing: { src: AudioBufferSourceNode; ctxT0: number; startT: number; endT: number; raf: number } | null = null;
635function stopPlayback(): void {
636 if (!playing) return;
637 cancelAnimationFrame(playing.raf);
638 try { playing.src.stop(); } catch { /* already ended */ }
639 playing = null;
640 explorer.setPlayhead(null);
641 updatePlayButton();
642}
644function startPlayback(): void {
645 const run = currentExplorerRun();
646 if (!run) return;
647 const dur = run.x.length / FS;
648 const sel = explorer.getSelection();
649 const startT = sel ? sel.t0 : 0;
650 const endT = sel ? sel.t1 : dur;
651 const s0 = Math.max(0, Math.round(startT * FS));
652 const s1 = Math.min(run.x.length, Math.round(endT * FS));
653 if (s1 - s0 < 1) return;
655 const ctx = audio();
656 const buf = ctx.createBuffer(1, s1 - s0, FS);
657 buf.getChannelData(0).set(run.x.subarray(s0, s1));
658 const src = ctx.createBufferSource();
659 src.buffer = buf;
660 src.connect(ctx.destination);
661 src.onended = () => { if (playing?.src === src) stopPlayback(); };
662 const begin = () => {
663 src.start();
664 playing = { src, ctxT0: ctx.currentTime, startT, endT, raf: 0 };
665 const tick = () => {
666 if (!playing || playing.src !== src) return;
667 explorer.setPlayhead(Math.min(playing.endT, playing.startT + (ctx.currentTime - playing.ctxT0)));
668 playing.raf = requestAnimationFrame(tick);
669 };
670 playing.raf = requestAnimationFrame(tick);
671 updatePlayButton();
672 };
673 if (ctx.state === 'suspended') void ctx.resume().then(begin);
674 else begin();
675}
677function updatePlayButton(): void {
678 const btn = $('explore-play') as HTMLButtonElement;
679 btn.disabled = !currentExplorerRun();
680 if (playing) {
681 btn.innerHTML = '■ Stop';
682 } else {
683 btn.innerHTML = explorer.getSelection() ? '▶ Play selection' : '▶ Play';
684 }
685}
687$('explore-play').onclick = () => (playing ? stopPlayback() : startPlayback());
688explorer.onSelectionChange = () => { if (!playing) updatePlayButton(); };
690function populateExplorerControls(): void {
691 const runSel = $('explore-run') as HTMLSelectElement;
692 const algoSel = $('explore-algo') as HTMLSelectElement;
693 runSel.innerHTML = '';
694 algoSel.innerHTML = '';
695 if (!bench) { updateExplorer(); return; }
696 bench.runs.forEach((r, i) => {
697 const o = document.createElement('option');
698 o.value = String(i);
699 o.textContent = `run ${i + 1} (seed ${r.seed})`;
700 runSel.appendChild(o);
701 });
702 for (const a of bench.algos) {
703 const o = document.createElement('option');
704 o.value = a.id;
705 o.textContent = a.name;
706 algoSel.appendChild(o);
707 }
708 updateExplorer();
709}
711function updateExplorer(): void {
712 stopPlayback();
713 if (!bench) { explorer.set(null); updatePlayButton(); return; }
714 const ri = Number(($('explore-run') as HTMLSelectElement).value || '0');
715 const algoId = ($('explore-algo') as HTMLSelectElement).value;
716 const run = bench.runs[ri];
717 const result = bench.results.find((r) => r.runIndex === ri && r.algorithmId === algoId);
718 if (!run || !result) { explorer.set(null); updatePlayButton(); return; }
719 explorer.set({
720 x: run.x,
721 fs: FS,
722 names: bench.names,
723 colors: bench.colors,
724 events: result.score.events,
725 });
726 updatePlayButton();
727}
729($('explore-run') as HTMLSelectElement).onchange = updateExplorer;
730($('explore-algo') as HTMLSelectElement).onchange = updateExplorer;
732// ---- export / import / reset -----------------------------------------------------
734$('export').onclick = () => {
735 const json = exportProject(doc.types, doc.config, doc.algorithms, getAudio);
736 const blob = new Blob([json], { type: 'application/json' });
737 const a = document.createElement('a');
738 a.href = URL.createObjectURL(blob);
739 a.download = 'audio-spike-sorting-project.json';
740 a.click();
741 URL.revokeObjectURL(a.href);
742};
744$('import').onclick = () => ($('import-file') as HTMLInputElement).click();
745($('import-file') as HTMLInputElement).onchange = async (e) => {
746 const file = (e.target as HTMLInputElement).files?.[0];
747 if (!file) return;
748 try {
749 const imported = importProject(await file.text());
750 if (!confirm('Importing replaces the current project. Continue?')) return;
751 for (const t of doc.types) for (const c of t.clips) { await deleteClipAudio(c.id); }
752 clipAudio.clear();
753 doc.types = imported.types;
754 doc.config = { ...DEFAULT_CONFIG, ...imported.config };
755 doc.algorithms = imported.algorithms;
756 for (const b of BUILTIN_ALGORITHMS) {
757 if (!doc.algorithms.some((a) => a.id === b.id)) doc.algorithms.push({ ...b });
758 }
759 for (const a of imported.clipAudio) {
760 clipAudio.set(a.id, { samples: a.samples, sr: a.sr });
761 await putClipAudio(a.id, a.samples, a.sr);
762 }
763 save();
764 closeClipEditor();
765 renderTypes(); renderConfig(); renderAlgos();
766 status(`imported ${imported.types.length} event types and ${imported.algorithms.length} algorithms`);
767 } catch (err) {
768 status(`import failed: ${(err as Error).message}`);
769 } finally {
770 ($('import-file') as HTMLInputElement).value = '';
771 }
772};
774$('reset-all').onclick = async () => {
775 if (!confirm('Forget all event types, recordings, algorithms, and results?')) return;
776 await clearAll();
777 location.reload();
778};
780// ---- startup ---------------------------------------------------------------------
782async function startup(): Promise<void> {
783 for (const t of doc.types) {
784 for (const c of t.clips) {
785 const a = await getClipAudio(c.id);
786 if (a) clipAudio.set(c.id, a);
787 }
788 }
789 bench = await getBenchmark();
790 renderTypes();
791 renderConfig();
792 renderAlgos();
793 renderResults();
794 populateExplorerControls();
795}
797void startup();