/** * The page: event types (named, trained by recorded example clips whose * detected onsets stay editable), a benchmark configuration, algorithms * (MATLAB-syntax sources run by numbl in a worker), the run loop with * scores and confusion matrices, and the explorer (ground truth against * one algorithm's detections, with every error visible and auditionable). */ import { Recorder, type Clip } from './audio/recorder.ts'; import { Player } from './audio/player.ts'; import { detectOnsets } from './analysis/onsets.ts'; import { BUILTIN_ALGORITHMS, NEW_ALGORITHM_TEMPLATE } from './algorithms/index.ts'; import { extractLibrary, snippetCounts } from './bench/snippets.ts'; import { BenchmarkRunner } from './bench/runner.ts'; import { FS, type AlgorithmDef, type RunResult } from './bench/types.ts'; import { exportProject, importProject } from './project-file.ts'; import { clearAll, deleteClipAudio, getBenchmark, getClipAudio, loadDoc, putBenchmark, putClipAudio, saveDoc, DEFAULT_CONFIG, type BenchConfig, type EventType, type StoredBenchmark, } from './store.ts'; import { WaveView, type Marker } from './ui/waveform.ts'; import { Explorer } from './ui/explorer.ts'; const PALETTE = ['#e4573d', '#3d7de4', '#3dbb6e', '#d9a520', '#9061d9', '#d95fa8', '#4fb8c9', '#96a13a']; const $ = (id: string) => document.getElementById(id) as T; const uid = () => Math.random().toString(36).slice(2, 10); // ---- state ------------------------------------------------------------------ const doc = loadDoc(); // Built-ins are merged in by id, so an edited built-in keeps its edits and // a fresh store gets all three. for (const b of BUILTIN_ALGORITHMS) { if (!doc.algorithms.some((a) => a.id === b.id)) doc.algorithms.push({ ...b }); } const clipAudio = new Map(); let bench: StoredBenchmark | null = null; let actx: AudioContext | null = null; const audio = (): AudioContext => (actx ??= new AudioContext()); let recorder: Recorder | null = null; let recTypeId: string | null = null; let meterTimer = 0; let edit: { typeId: string; clipId: string } | null = null; let editingAlgoId: string | null = null; const runner = new BenchmarkRunner(); function save(): void { saveDoc(doc); } function status(msg: string): void { $('status').textContent = msg; } function typeById(id: string): EventType | undefined { return doc.types.find((t) => t.id === id); } const getAudio = (clipId: string) => clipAudio.get(clipId) ?? null; // ---- audition ---------------------------------------------------------------- function playSlice(samples: Float32Array, sr: number, s0: number, s1: number): void { const ctx = audio(); const n = Math.min(s1, samples.length) - Math.max(s0, 0); if (n <= 0) return; const buf = ctx.createBuffer(1, n, sr); buf.getChannelData(0).set(samples.subarray(Math.max(s0, 0), Math.max(s0, 0) + n)); const src = ctx.createBufferSource(); src.buffer = buf; src.connect(ctx.destination); src.start(); } // ---- event types --------------------------------------------------------------- function renderTypes(): void { const list = $('type-list'); list.innerHTML = ''; const counts = snippetCounts(doc.types, doc.config.preMs, doc.config.postMs, getAudio); doc.types.forEach((type) => { const row = document.createElement('div'); row.className = 'type-row'; const swatch = document.createElement('div'); swatch.className = 'swatch'; swatch.style.background = type.color; row.appendChild(swatch); const name = document.createElement('input'); name.className = 'name'; name.value = type.name; name.onchange = () => { type.name = name.value.trim() || type.name; save(); }; row.appendChild(name); const count = document.createElement('span'); count.className = 'count'; const nClips = type.clips.length; count.textContent = `${counts.get(type.id) ?? 0} snippets in ${nClips} clip${nClips === 1 ? '' : 's'}`; row.appendChild(count); const rec = document.createElement('button'); const recording = recTypeId === type.id; rec.innerHTML = recording ? '■ stop' : '● record examples'; rec.disabled = recTypeId !== null && !recording; rec.onclick = () => (recording ? stopRecording() : startRecording(type.id)); row.appendChild(rec); if (type.clips.length) { const editBtn = document.createElement('button'); editBtn.textContent = 'edit clips'; editBtn.onclick = () => openClipEditor(type.id, type.clips[type.clips.length - 1].id); row.appendChild(editBtn); } const spacer = document.createElement('span'); spacer.className = 'spacer'; row.appendChild(spacer); const del = document.createElement('button'); del.textContent = 'delete'; del.onclick = async () => { if (!confirm(`Delete event type "${type.name}" and its ${type.clips.length} clip(s)?`)) return; for (const c of type.clips) { await deleteClipAudio(c.id); clipAudio.delete(c.id); } doc.types = doc.types.filter((t) => t.id !== type.id); if (edit?.typeId === type.id) closeClipEditor(); save(); renderTypes(); }; row.appendChild(del); list.appendChild(row); }); } $('add-type').onclick = () => { const nameInput = $('type-name') as HTMLInputElement; const name = nameInput.value.trim(); if (!name) { status('give the event type a name first'); return; } doc.types.push({ id: uid(), name, color: PALETTE[doc.types.length % PALETTE.length], clips: [] }); nameInput.value = ''; save(); renderTypes(); }; $('load-demo').onclick = async () => { const { makeDemoData, demoTypes } = await import('./demo.ts'); const clips = makeDemoData(); const types = demoTypes(clips); for (const c of clips) { clipAudio.set(c.meta.id, { samples: c.samples, sr: FS }); await putClipAudio(c.meta.id, c.samples, FS); } doc.types.push(...types); save(); renderTypes(); status('demo data loaded: three synthetic event types, ready to benchmark'); }; // ---- recording --------------------------------------------------------------- async function startRecording(typeId: string): Promise { recorder = new Recorder(audio()); try { await recorder.start(); } catch (err) { status(`microphone: ${(err as Error).message}`); recorder = null; return; } recTypeId = typeId; const meter = $('meter'); meter.hidden = false; const bar = meter.firstElementChild as HTMLElement; meterTimer = window.setInterval(() => { bar.style.width = `${(recorder?.level() ?? 0) * 100}%`; }, 60); status(`recording examples of "${typeById(typeId)?.name}"; separate events by at least 200 ms`); renderTypes(); } async function stopRecording(): Promise { if (!recorder || !recTypeId) return; const typeId = recTypeId; recTypeId = null; clearInterval(meterTimer); $('meter').hidden = true; let clip: Clip; try { clip = await recorder.stop(FS); } catch (err) { status(`recording failed: ${(err as Error).message}`); recorder = null; renderTypes(); return; } recorder = null; const type = typeById(typeId); if (!type) return; const sens = 0.6; const onsets = detectOnsets(clip.samples, clip.sr, sens); const clipId = uid(); clipAudio.set(clipId, { samples: clip.samples, sr: clip.sr }); await putClipAudio(clipId, clip.samples, clip.sr); type.clips.push({ id: clipId, sens, onsets, excluded: [] }); save(); renderTypes(); openClipEditor(typeId, clipId); status(`${onsets.length} events detected; audition and exclude bad ones, then Done`); } // ---- clip editor --------------------------------------------------------------- const waveView = new WaveView($('clip-wave') as HTMLCanvasElement); const clipPlayer = new Player(audio); clipPlayer.onTick = (t) => waveView.setPlayhead(t); clipPlayer.onStateChange = () => { $('clip-play').innerHTML = clipPlayer.playing ? '■ Stop' : '▶ Play'; }; $('clip-play').onclick = () => { if (clipPlayer.playing) { clipPlayer.stop(); return; } const cur = currentClip(); if (!cur) return; clipPlayer.start(cur.a.samples, cur.a.sr, 0, cur.a.samples.length / cur.a.sr); }; function currentClip() { if (!edit) return null; const type = typeById(edit.typeId); const meta = type?.clips.find((c) => c.id === edit!.clipId); const a = meta ? clipAudio.get(meta.id) : null; return type && meta && a ? { type, meta, a } : null; } function markersOf(meta: { onsets: number[]; excluded: number[] }): Marker[] { const ex = new Set(meta.excluded); return meta.onsets.map((t, i) => ({ t, excluded: ex.has(i) })); } function openClipEditor(typeId: string, clipId: string): void { clipPlayer.stop(); edit = { typeId, clipId }; const cur = currentClip(); if (!cur) { closeClipEditor(); return; } $('clip-editor').hidden = false; $('clip-title').textContent = cur.type.name; const sel = $('clip-select') as HTMLSelectElement; sel.innerHTML = ''; cur.type.clips.forEach((c, i) => { const o = document.createElement('option'); o.value = c.id; o.textContent = `clip ${i + 1} (${c.onsets.length} events)`; o.selected = c.id === clipId; sel.appendChild(o); }); ($('clip-sens') as HTMLInputElement).value = String(cur.meta.sens); waveView.set({ samples: cur.a.samples, sr: cur.a.sr }, markersOf(cur.meta), cur.type.color); updateClipCount(); } function closeClipEditor(): void { clipPlayer.stop(); edit = null; $('clip-editor').hidden = true; } function updateClipCount(): void { const cur = currentClip(); if (!cur) return; const kept = cur.meta.onsets.length - cur.meta.excluded.length; $('clip-count').textContent = `${kept} of ${cur.meta.onsets.length} events kept`; ($('clip-exclude') as HTMLButtonElement).disabled = waveView.selection.size === 0; } waveView.onAudition = (i) => { const cur = currentClip(); if (!cur) return; const s0 = Math.max(0, Math.round((cur.meta.onsets[i] - 0.02) * cur.a.sr)); playSlice(cur.a.samples, cur.a.sr, s0, s0 + Math.round(0.3 * cur.a.sr)); }; waveView.onSelectionChange = () => updateClipCount(); ($('clip-select') as HTMLSelectElement).onchange = (e) => { if (edit) openClipEditor(edit.typeId, (e.target as HTMLSelectElement).value); }; ($('clip-sens') as HTMLInputElement).oninput = () => { const cur = currentClip(); if (!cur) return; cur.meta.sens = Number(($('clip-sens') as HTMLInputElement).value); cur.meta.onsets = detectOnsets(cur.a.samples, cur.a.sr, cur.meta.sens); cur.meta.excluded = []; save(); waveView.set({ samples: cur.a.samples, sr: cur.a.sr }, markersOf(cur.meta), cur.type.color); updateClipCount(); renderTypes(); }; function toggleExcludeSelection(): void { const cur = currentClip(); if (!cur || waveView.selection.size === 0) return; const ex = new Set(cur.meta.excluded); const allExcluded = [...waveView.selection].every((i) => ex.has(i)); for (const i of waveView.selection) { if (allExcluded) ex.delete(i); else ex.add(i); } cur.meta.excluded = [...ex].sort((a, b) => a - b); save(); waveView.updateMarkers(markersOf(cur.meta)); updateClipCount(); renderTypes(); } $('clip-exclude').onclick = toggleExcludeSelection; document.addEventListener('keydown', (e) => { if (e.key.toLowerCase() === 'x' && edit && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)) { toggleExcludeSelection(); } }); $('clip-delete').onclick = async () => { const cur = currentClip(); if (!cur) return; if (!confirm('Delete this clip and its events?')) return; await deleteClipAudio(cur.meta.id); clipAudio.delete(cur.meta.id); cur.type.clips = cur.type.clips.filter((c) => c.id !== cur.meta.id); save(); renderTypes(); if (cur.type.clips.length) openClipEditor(cur.type.id, cur.type.clips[cur.type.clips.length - 1].id); else closeClipEditor(); }; $('clip-done').onclick = closeClipEditor; // ---- configuration ------------------------------------------------------------- interface ConfigField { key: keyof BenchConfig; label: string; min: number; max: number; step: number; title?: string; } const CONFIG_FIELDS: ConfigField[] = [ { key: 'durationS', label: 'dataset duration (s)', min: 5, max: 600, step: 1 }, { key: 'eventsPerType', label: 'events per type', min: 1, max: 2000, step: 1 }, { key: 'gainMinDb', label: 'level variation (dB)', min: -40, max: 0, step: 1, 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.', }, { key: 'overlap', label: 'overlap fraction (0-1)', min: 0, max: 1, step: 0.05 }, { key: 'coincidence', label: 'coincident fraction (0-1)', min: 0, max: 1, step: 0.05, title: 'Of the overlapping events, the fraction placed at their host event\u2019s onset rather than at a random offset inside its span, so the two onsets are separated by at most the coincidence window below. 0 leaves every overlapping event at a random offset.', }, { key: 'coincidenceMs', label: 'coincidence window (ms)', min: 0, max: 100, step: 1, title: 'Half-width of the coincident placement window: a coincident event starts within this many milliseconds either side of its host. 0 places the two events at exactly the same sample.', }, { key: 'trainFraction', label: 'training fraction (0-1)', min: 0.05, max: 0.95, step: 0.05 }, { key: 'runs', label: 'runs', min: 1, max: 50, step: 1 }, { key: 'seed', label: 'base seed', min: 0, max: 1e9, step: 1 }, { key: 'toleranceMs', label: 'match tolerance (ms)', min: 1, max: 200, step: 1 }, { key: 'preMs', label: 'snippet pre-onset (ms)', min: 0, max: 100, step: 5 }, { key: 'postMs', label: 'snippet post-onset (ms)', min: 20, max: 1000, step: 10 }, ]; function renderConfig(): void { const grid = $('config-grid'); grid.innerHTML = ''; for (const f of CONFIG_FIELDS) { const label = document.createElement('label'); if (f.title) label.title = f.title; const span = document.createElement('span'); span.textContent = f.label; const input = document.createElement('input'); input.type = 'number'; input.min = String(f.min); input.max = String(f.max); input.step = String(f.step); input.value = String(doc.config[f.key]); input.onchange = () => { const v = Number(input.value); if (Number.isFinite(v)) { (doc.config[f.key] as number) = Math.min(f.max, Math.max(f.min, v)); input.value = String(doc.config[f.key]); save(); if (f.key === 'preMs' || f.key === 'postMs') renderTypes(); } }; label.append(span, input); grid.appendChild(label); } } // ---- algorithms ---------------------------------------------------------------- function algoById(id: string): AlgorithmDef | undefined { return doc.algorithms.find((a) => a.id === id); } function renderAlgos(): void { const list = $('algo-list'); list.innerHTML = ''; doc.algorithms.forEach((algo) => { const row = document.createElement('div'); row.className = 'algo-row'; const enable = document.createElement('input'); enable.type = 'checkbox'; enable.checked = algo.enabled; enable.onchange = () => { algo.enabled = enable.checked; save(); }; row.appendChild(enable); const name = document.createElement('strong'); name.textContent = algo.name; row.appendChild(name); if (algo.builtin) { const tag = document.createElement('span'); tag.className = 'tag'; tag.textContent = 'built-in'; row.appendChild(tag); } const spacer = document.createElement('span'); spacer.className = 'spacer'; row.appendChild(spacer); const editBtn = document.createElement('button'); editBtn.textContent = 'edit'; editBtn.onclick = () => openAlgoEditor(algo.id); row.appendChild(editBtn); list.appendChild(row); }); } function openAlgoEditor(id: string): void { const algo = algoById(id); if (!algo) return; editingAlgoId = id; $('algo-editor').hidden = false; const nameInput = $('algo-name') as HTMLInputElement; nameInput.value = algo.name; nameInput.disabled = !!algo.builtin; ($('algo-source') as HTMLTextAreaElement).value = algo.source; ($('algo-reset') as HTMLButtonElement).hidden = !algo.builtin; ($('algo-remove') as HTMLButtonElement).hidden = !!algo.builtin; $('algo-editor').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } ($('algo-name') as HTMLInputElement).onchange = () => { const algo = editingAlgoId ? algoById(editingAlgoId) : null; if (!algo || algo.builtin) return; algo.name = ($('algo-name') as HTMLInputElement).value.trim() || algo.name; save(); renderAlgos(); }; ($('algo-source') as HTMLTextAreaElement).onchange = () => { const algo = editingAlgoId ? algoById(editingAlgoId) : null; if (!algo) return; algo.source = ($('algo-source') as HTMLTextAreaElement).value; save(); }; $('algo-reset').onclick = () => { const algo = editingAlgoId ? algoById(editingAlgoId) : null; const builtin = BUILTIN_ALGORITHMS.find((b) => b.id === algo?.id); if (!algo || !builtin) return; algo.source = builtin.source; ($('algo-source') as HTMLTextAreaElement).value = algo.source; save(); }; $('algo-remove').onclick = () => { const algo = editingAlgoId ? algoById(editingAlgoId) : null; if (!algo || algo.builtin) return; if (!confirm(`Delete algorithm "${algo.name}"?`)) return; doc.algorithms = doc.algorithms.filter((a) => a.id !== algo.id); editingAlgoId = null; $('algo-editor').hidden = true; save(); renderAlgos(); }; $('algo-close').onclick = () => { editingAlgoId = null; $('algo-editor').hidden = true; }; $('add-algo').onclick = () => { const algo: AlgorithmDef = { id: uid(), name: `my_sorter_${doc.algorithms.length}`, source: NEW_ALGORITHM_TEMPLATE, enabled: true, }; doc.algorithms.push(algo); save(); renderAlgos(); openAlgoEditor(algo.id); }; // ---- benchmark ----------------------------------------------------------------- $('run-bench').onclick = async () => { const enabled = doc.algorithms.filter((a) => a.enabled); if (!enabled.length) { status('enable at least one algorithm'); return; } const lib = extractLibrary(doc.types, doc.config.preMs, doc.config.postMs, getAudio); const perType = doc.types.map((t, i) => lib.snippets.filter((s) => s.typeIndex === i).length); if (!doc.types.length || perType.some((n) => n < 2)) { status('every event type needs at least 2 usable snippets (one to train, one to place)'); return; } ($('run-bench') as HTMLButtonElement).disabled = true; $('cancel-bench').hidden = false; const progress = $('bench-progress') as HTMLProgressElement; progress.hidden = false; try { const out = await runner.run( lib, doc.config, doc.config.runs, doc.config.toleranceMs, enabled, (p) => { progress.max = p.total; progress.value = p.done; $('bench-label').textContent = `run ${p.runIndex + 1}/${p.runCount}: ${p.algorithmName}`; }, ); if (!runner.cancelled) { bench = { when: Date.now(), names: doc.types.map((t) => t.name), colors: doc.types.map((t) => t.color), algos: enabled.map((a) => ({ id: a.id, name: a.name })), config: { ...doc.config }, runs: out.runs, results: out.results, }; await putBenchmark(bench); renderResults(); populateExplorerControls(); updateDownloadButton(); status(`benchmark complete: ${out.results.length} algorithm runs`); } else { status('benchmark cancelled'); } } catch (err) { status(`benchmark failed: ${(err as Error).message}`); } finally { ($('run-bench') as HTMLButtonElement).disabled = false; $('cancel-bench').hidden = true; progress.hidden = true; $('bench-label').textContent = ''; } }; $('cancel-bench').onclick = () => runner.cancel(); // ---- dataset download ------------------------------------------------------------- function updateDownloadButton(): void { ($('download-runs') as HTMLButtonElement).disabled = !bench || !bench.runs.length || !bench.runs.every((r) => r.training); } $('download-runs').onclick = async () => { if (!bench) return; if (!bench.runs.every((r) => r.training)) { status('this benchmark predates the download feature; run it again to enable the export'); return; } status('building the dataset archive...'); const { buildOfflineFiles } = await import('./export/offline.ts'); const { zipStore } = await import('./export/zip.ts'); const files = buildOfflineFiles({ names: bench.names, fs: FS, pre: Math.round((bench.config.preMs / 1000) * FS), post: Math.round((bench.config.postMs / 1000) * FS), toleranceMs: bench.config.toleranceMs, config: bench.config, algorithms: bench.algos .map((a) => doc.algorithms.find((d) => d.id === a.id)) .filter((a): a is AlgorithmDef => !!a) .map((a) => ({ source: a.source })), runs: bench.runs.map((r) => ({ x: r.x, truth: r.truth, training: r.training! })), }); const blob = new Blob([zipStore(files) as BlobPart], { type: 'application/zip' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'audio-spike-sorting-runs.zip'; a.click(); URL.revokeObjectURL(a.href); status(`dataset archive: ${bench.runs.length} runs, ${(blob.size / 1e6).toFixed(1)} MB`); }; // ---- results ------------------------------------------------------------------- function mean(v: number[]): number { return v.length ? v.reduce((a, b) => a + b, 0) / v.length : 0; } function std(v: number[]): number { if (v.length < 2) return 0; const m = mean(v); return Math.sqrt(v.reduce((a, b) => a + (b - m) * (b - m), 0) / (v.length - 1)); } function renderResults(): void { const host = $('results'); host.innerHTML = ''; if (!bench) return; const b = bench; const table = document.createElement('table'); table.className = 'results'; table.innerHTML = ` algorithmaccuracycorrectmisclassified missedfalse pos.truthtime/run`; for (const algo of b.algos) { const rs = b.results.filter((r) => r.algorithmId === algo.id); const failed = rs.filter((r) => r.error); const accs = rs.map((r) => r.score.accuracy); const sum = (f: (r: RunResult) => number) => rs.reduce((a, r) => a + f(r), 0); const tr = document.createElement('tr'); const acc = `${mean(accs).toFixed(3)} ± ${std(accs).toFixed(3)}`; tr.innerHTML = `${algo.name}${failed.length ? ` (${failed.length} failed)` : ''} ${acc} ${sum((r) => r.score.correct)} ${sum((r) => r.score.misclassified)} ${sum((r) => r.score.missed)} ${sum((r) => r.score.falsePositives)} ${sum((r) => r.score.nTruth)} ${Math.round(mean(rs.map((r) => r.elapsedMs)))} ms`; table.appendChild(tr); } host.appendChild(table); // per-algorithm drill-down: per-type breakdown and confusion matrix, // both summed over runs (per-type accuracy also as mean +- std over runs) for (const algo of b.algos) { const rs = b.results.filter((r) => r.algorithmId === algo.id && !r.error); if (!rs.length) { const err = b.results.find((r) => r.algorithmId === algo.id && r.error); if (err) { const p = document.createElement('p'); p.className = 'err'; p.textContent = `${algo.name}: ${err.error}`; host.appendChild(p); } continue; } const n = b.names.length; const det = document.createElement('details'); const summary = document.createElement('summary'); summary.textContent = `${algo.name}: per-type breakdown and confusion matrix (all runs)`; summary.style.cssText = 'cursor:pointer;font-size:0.85rem;margin-top:0.5rem'; det.appendChild(summary); // per-type table const swatch = (k: number) => ``; const typeAcc = (pt: { correct: number; misclassified: number; missed: number; falsePositives: number }) => { const d = pt.correct + pt.misclassified + pt.missed + pt.falsePositives; return d > 0 ? pt.correct / d : 1; }; const pt = document.createElement('table'); pt.className = 'results'; pt.style.marginTop = '0.4rem'; pt.innerHTML = ` event typeaccuracycorrectmisclassified missedfalse pos.truth`; for (let k = 0; k < n; k++) { const perRun = rs.map((r) => r.score.perType[k]); const accs = perRun.map(typeAcc); const sum = (f: (p: typeof perRun[number]) => number) => perRun.reduce((a, p) => a + f(p), 0); const tr = document.createElement('tr'); tr.innerHTML = `${swatch(k)}${b.names[k]} ${mean(accs).toFixed(3)} ± ${std(accs).toFixed(3)} ${sum((p) => p.correct)} ${sum((p) => p.misclassified)} ${sum((p) => p.missed)} ${sum((p) => p.falsePositives)} ${sum((p) => p.nTruth)}`; pt.appendChild(tr); } det.appendChild(pt); // confusion matrix const conf = Array.from({ length: n + 1 }, () => new Array(n + 1).fill(0)); for (const r of rs) { for (let i = 0; i <= n; i++) for (let j = 0; j <= n; j++) conf[i][j] += r.score.confusion[i][j]; } const t = document.createElement('table'); t.className = 'confusion'; const head = ['true \\ detected', ...b.names, 'missed']; t.innerHTML = `${head.map((h) => `${h}`).join('')}`; for (let i = 0; i < n; i++) { const tr = document.createElement('tr'); tr.innerHTML = `${b.names[i]}` + conf[i].map((v, j) => { const cls = j === i ? 'diag' : v > 0 ? 'bad' : ''; return `${v}`; }).join(''); t.appendChild(tr); } const fpRow = document.createElement('tr'); fpRow.innerHTML = `false pos.` + conf[n].slice(0, n).map((v) => `${v}`).join('') + ''; t.appendChild(fpRow); det.appendChild(t); host.appendChild(det); } const failures = b.results.filter((r) => r.error); if (failures.length) { const p = document.createElement('p'); p.className = 'err'; p.textContent = failures.map((r) => { const name = b.algos.find((a) => a.id === r.algorithmId)?.name ?? r.algorithmId; return `${name} (run ${r.runIndex + 1}): ${r.error}`; }).join('\n'); host.appendChild(p); } } // ---- explorer ------------------------------------------------------------------- const explorer = new Explorer($('explorer') as HTMLCanvasElement); explorer.onHover = (text) => { $('explore-hover').textContent = text; }; explorer.onAudition = (t) => { const run = currentExplorerRun(); if (!run) return; const s0 = Math.round((t - 0.05) * FS); playSlice(run.x, FS, s0, s0 + Math.round(0.4 * FS)); }; function currentExplorerRun() { if (!bench) return null; const ri = Number(($('explore-run') as HTMLSelectElement).value); return bench.runs[ri] ?? null; } // ---- run playback ---------------------------------------------------------- const runPlayer = new Player(audio); runPlayer.onTick = (t) => explorer.setPlayhead(t); runPlayer.onStateChange = () => updatePlayButton(); function updatePlayButton(): void { const btn = $('explore-play') as HTMLButtonElement; btn.disabled = !currentExplorerRun(); if (runPlayer.playing) { btn.innerHTML = '■ Stop'; } else { btn.innerHTML = explorer.getSelection() ? '▶ Play selection' : '▶ Play'; } } $('explore-play').onclick = () => { if (runPlayer.playing) { runPlayer.stop(); return; } const run = currentExplorerRun(); if (!run) return; const sel = explorer.getSelection(); runPlayer.start(run.x, FS, sel ? sel.t0 : 0, sel ? sel.t1 : run.x.length / FS); }; explorer.onSelectionChange = () => { if (!runPlayer.playing) updatePlayButton(); }; function populateExplorerControls(): void { const runSel = $('explore-run') as HTMLSelectElement; const algoSel = $('explore-algo') as HTMLSelectElement; runSel.innerHTML = ''; algoSel.innerHTML = ''; if (!bench) { updateExplorer(); return; } bench.runs.forEach((r, i) => { const o = document.createElement('option'); o.value = String(i); o.textContent = `run ${i + 1} (seed ${r.seed})`; runSel.appendChild(o); }); for (const a of bench.algos) { const o = document.createElement('option'); o.value = a.id; o.textContent = a.name; algoSel.appendChild(o); } updateExplorer(); } function updateExplorer(): void { runPlayer.stop(); if (!bench) { explorer.set(null); updatePlayButton(); return; } const ri = Number(($('explore-run') as HTMLSelectElement).value || '0'); const algoId = ($('explore-algo') as HTMLSelectElement).value; const run = bench.runs[ri]; const result = bench.results.find((r) => r.runIndex === ri && r.algorithmId === algoId); if (!run || !result) { explorer.set(null); updatePlayButton(); return; } explorer.set({ x: run.x, fs: FS, names: bench.names, colors: bench.colors, events: result.score.events, algoName: bench.algos.find((a) => a.id === algoId)?.name, }); updatePlayButton(); } ($('explore-run') as HTMLSelectElement).onchange = updateExplorer; ($('explore-algo') as HTMLSelectElement).onchange = updateExplorer; // ---- export / import / reset ----------------------------------------------------- $('export').onclick = () => { const json = exportProject(doc.types, doc.config, doc.algorithms, getAudio); const blob = new Blob([json], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'audio-spike-sorting-project.json'; a.click(); URL.revokeObjectURL(a.href); }; $('import').onclick = () => ($('import-file') as HTMLInputElement).click(); ($('import-file') as HTMLInputElement).onchange = async (e) => { const file = (e.target as HTMLInputElement).files?.[0]; if (!file) return; try { const imported = importProject(await file.text()); if (!confirm('Importing replaces the current project. Continue?')) return; for (const t of doc.types) for (const c of t.clips) { await deleteClipAudio(c.id); } clipAudio.clear(); doc.types = imported.types; doc.config = { ...DEFAULT_CONFIG, ...imported.config }; doc.algorithms = imported.algorithms; for (const b of BUILTIN_ALGORITHMS) { if (!doc.algorithms.some((a) => a.id === b.id)) doc.algorithms.push({ ...b }); } for (const a of imported.clipAudio) { clipAudio.set(a.id, { samples: a.samples, sr: a.sr }); await putClipAudio(a.id, a.samples, a.sr); } save(); closeClipEditor(); renderTypes(); renderConfig(); renderAlgos(); status(`imported ${imported.types.length} event types and ${imported.algorithms.length} algorithms`); } catch (err) { status(`import failed: ${(err as Error).message}`); } finally { ($('import-file') as HTMLInputElement).value = ''; } }; $('reset-all').onclick = async () => { if (!confirm('Forget all event types, recordings, algorithms, and results?')) return; await clearAll(); location.reload(); }; // ---- startup --------------------------------------------------------------------- async function startup(): Promise { for (const t of doc.types) { for (const c of t.clips) { const a = await getClipAudio(c.id); if (a) clipAudio.set(c.id, a); } } bench = await getBenchmark(); renderTypes(); renderConfig(); renderAlgos(); renderResults(); populateExplorerControls(); updateDownloadButton(); } void startup();