audio-spike-sorting: benchmark framework for audio event detection and classification
Record labeled examples of event types, synthesize ground-truth datasets
with controlled overlap and gain variation, run MATLAB-syntax sorting
algorithms in the browser via numbl, and score misses, false positives,
and misclassifications against exact ground truth. Includes three baseline
algorithms, a dataset explorer, demo data, a node end-to-end test, and a
headless-browser smoke test.
32 changed files+6801−0
.gitignoreadded+2−0View file
@@ -0,0 +1,2 @@
1+node_modules/
2+dist/
README.mdadded+116−0View file
@@ -0,0 +1,116 @@
1+# audio-spike-sorting
2+
3+A benchmark framework for the detection and classification of short audio
4+events, in the spirit of spike sorting. You record hundreds of examples of
5+each event type (tapping the table, tapping the floor, tapping with a
6+spoon), cleanly separated so the examples are unambiguous. The framework
7+then synthesizes recordings in which known events are placed at known
8+times, possibly overlapping, hands each candidate algorithm a small
9+labeled training set, and scores the algorithm's output against the exact
10+ground truth. Candidate algorithms are written in MATLAB syntax and
11+executed in the browser by [numbl](https://numbl.org); everything, from
12+recording through scoring and visual exploration, runs on one page with
13+nothing to install.
14+
15+A limitation of evaluating event-detection methods on real recordings is
16+that the ground truth is itself the product of some annotation process,
17+with its own errors and biases. Synthesis from recorded examples avoids
18+this: the event times and labels are correct by construction, while the
19+audio itself remains real. The trade-off is that a synthesized mixture is
20+not a real performance; in particular, overlapping events here are sums of
21+independently recorded events, which ignores any physical interaction
22+between them.
23+
24+## How a benchmark run works
25+
26+1. **Snippets.** Each recorded clip is segmented by an onset detector
27+ (spectral flux, with an adjustable sensitivity), and the detected
28+ events stay editable: click to audition, exclude mistakes. Every kept
29+ onset is refined to the foot of its energy rise and a fixed window
30+ (default 20 ms before, 180 ms after) is cut around it. This refined
31+ onset is the project's single definition of event time, used both for
32+ training snippets and for ground truth, so all algorithms are scored
33+ against one consistent convention.
34+2. **Split.** For each run, every type's snippets are randomly divided
35+ into a training set (default 30%) given to the algorithm, and a
36+ placement pool used to build the dataset. An algorithm is therefore
37+ always classifying events it has not literally seen.
38+3. **Synthesis.** Events from the placement pool are placed on a silent
39+ timeline, each scaled by a random gain (default 0 to -12 dB). An
40+ overlap knob in [0, 1] sets the fraction of events deliberately placed
41+ inside another event's span; the rest keep at least one snippet length
42+ of separation. Run r uses seed + r, so the whole benchmark is
43+ reproducible from the configuration.
44+4. **Execution.** Each algorithm is a `.m` file defining
45+ `function [times, labels] = name(x, fs, training)`, where `training`
46+ carries the per-type snippet matrices, the type names, and the onset
47+ position within a snippet. numbl runs it in a web worker; the sources
48+ are editable in place on the page.
49+5. **Scoring.** Detections are matched to ground truth greedily by time
50+ difference within a tolerance (default 20 ms), ignoring labels. A
51+ matched pair with the right label is correct, with the wrong label a
52+ misclassification; unmatched truth events are misses and unmatched
53+ detections false positives. The summary accuracy is
54+ correct / (correct + misclassified + missed + false positives),
55+ reported as mean and standard deviation over runs, alongside a
56+ confusion matrix per algorithm with an extra column for misses and an
57+ extra row for false positives.
58+
59+The explorer shows any synthesized recording with the ground truth lanes
60+above the waveform and one algorithm's detections below it. A correct
61+detection is a filled marker in its type's color, a misclassification is
62+ringed in red, a false positive is a red cross, and a missed truth event
63+is a hollow circle. The wheel zooms, dragging pans, and a click auditions
64+the audio at that time.
65+
66+## Baseline algorithms
67+
68+Three built-ins seed the comparison and can be edited or reset in place:
69+
70+- **template_matched_filter**: one unit-norm mean template per type,
71+ cross-correlated against the recording via the FFT; the per-type scores
72+ are normalized so one threshold treats loud and quiet types alike. The
73+ classic spike-sorting approach; it handles nothing specially for
74+ overlaps beyond the correlation itself.
75+- **onset_knn**: detection on a smoothed energy envelope, classification
76+ by distance-weighted k-nearest-neighbours over spectral shape features
77+ (band-energy ratios, centroid, zero-crossing rate). Levels are used only
78+ as ratios, so the random per-event gain carries no information.
79+- **naive_threshold**: an amplitude threshold and a classifier that looks
80+ only at event duration. A deliberately weak floor; a real method that
81+ fails to clear it is in trouble.
82+
83+On the synthetic demo data (three types, 20% overlap), the baselines land
84+around 0.6 to 0.8 accuracy, with the losses concentrated in the
85+overlapping events. None of the built-ins attempts overlap resolution
86+(subtraction or joint fitting), which is exactly the direction candidate
87+algorithms are expected to explore.
88+
89+## Running it
90+
91+```bash
92+npm install
93+npm run dev # the app, at the printed URL
94+npm test # node end-to-end test of the pipeline (no browser)
95+npm run build # typecheck and bundle
96+```
97+
98+"Load demo data" creates three synthetic event types so the whole
99+pipeline can be tried without a microphone. Projects persist in the
100+browser (clip audio in IndexedDB, everything else in localStorage) and
101+round-trip through Export/Import as a single JSON file, so a recorded
102+snippet library can be shared or committed.
103+
104+## Notes and limitations
105+
106+- The internal rate is 16 kHz mono; microphone input is downsampled on
107+ capture with a box average, which is a crude anti-alias filter. Events
108+ whose identity lives above 8 kHz will lose that information.
109+- Synthesis adds no background noise; the noise floor is whatever the
110+ recordings carry. A noise knob is a natural next step, as is an option
111+ to place events over a recorded room-tone bed.
112+- The matching is greedy rather than globally optimal; with a 20 ms
113+ tolerance and events at realistic densities the difference is
114+ negligible, but it is not the Hungarian algorithm.
115+- Algorithm execution is single-threaded per run, so per-algorithm
116+ timings are comparable but a benchmark with many runs takes their sum.
index.htmladded+157−0View file
@@ -0,0 +1,157 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="utf-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📣</text></svg>" />
7+ <title>audio-spike-sorting</title>
8+ <style>
9+ :root {
10+ color-scheme: light dark;
11+ font-family: system-ui, sans-serif;
12+ }
13+ body {
14+ margin: 0 auto;
15+ max-width: 76rem;
16+ padding: 1rem 1.5rem 4rem;
17+ line-height: 1.45;
18+ }
19+ h1 { font-size: 1.3rem; margin: 0.3rem 0 0; }
20+ h1 + p { margin-top: 0.2rem; opacity: 0.75; }
21+ h2 { font-size: 0.95rem; margin: 1.5rem 0 0.4rem; }
22+ .cost { font-weight: normal; opacity: 0.6; font-size: 0.8rem; }
23+ .bar { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; margin: 0.5rem 0; }
24+ button { font: inherit; padding: 0.3rem 0.8rem; }
25+ button:disabled { opacity: 0.5; }
26+ select, input[type='text'], input[type='number'] { font: inherit; }
27+ input[type='number'] { width: 5.5rem; }
28+ .type-row {
29+ display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap;
30+ padding: 0.3rem 0.4rem; border-radius: 6px; font-size: 0.9rem;
31+ }
32+ .type-row:nth-child(odd) { background: rgba(128, 128, 128, 0.09); }
33+ .type-row .swatch { width: 0.8rem; height: 0.8rem; border-radius: 3px; flex: none; }
34+ .type-row input.name { width: 9rem; border: none; background: transparent; font-weight: 600; }
35+ .type-row .count { opacity: 0.65; font-size: 0.8rem; min-width: 11rem; }
36+ .type-row .spacer { flex: 1; }
37+ .rec { color: #d33; }
38+ canvas.wave { width: 100%; height: 110px; border-radius: 6px; display: block; touch-action: none; }
39+ canvas.explorer { width: 100%; border-radius: 6px; display: block; touch-action: none; cursor: crosshair; }
40+ .meter { width: 7rem; height: 0.55rem; border-radius: 3px; background: rgba(128,128,128,0.25); overflow: hidden; }
41+ .meter div { height: 100%; width: 0; background: #3dbb6e; }
42+ .panel { border: 1px solid rgba(128,128,128,0.35); border-radius: 8px; padding: 0.6rem 0.8rem; margin-top: 0.8rem; }
43+ #status { font-size: 0.85rem; opacity: 0.8; min-height: 1.3em; }
44+ .hint { font-size: 0.8rem; opacity: 0.6; }
45+ label { display: inline-flex; gap: 0.35rem; align-items: center; }
46+ .config-grid {
47+ display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
48+ gap: 0.35rem 1.5rem; font-size: 0.85rem;
49+ }
50+ .config-grid label { display: flex; justify-content: space-between; }
51+ .algo-row {
52+ display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap;
53+ padding: 0.25rem 0.4rem; border-radius: 6px; font-size: 0.9rem;
54+ }
55+ .algo-row:nth-child(odd) { background: rgba(128, 128, 128, 0.09); }
56+ .algo-row .tag { font-size: 0.7rem; opacity: 0.55; border: 1px solid currentColor; border-radius: 3px; padding: 0 0.3rem; }
57+ .algo-row .spacer { flex: 1; }
58+ textarea.source {
59+ width: 100%; min-height: 22rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
60+ font-size: 0.8rem; line-height: 1.4; tab-size: 4; box-sizing: border-box;
61+ }
62+ table.results { border-collapse: collapse; font-size: 0.85rem; font-variant-numeric: tabular-nums; }
63+ table.results th, table.results td { padding: 0.25rem 0.7rem; text-align: right; border-bottom: 1px solid rgba(128,128,128,0.25); }
64+ table.results th:first-child, table.results td:first-child { text-align: left; }
65+ table.confusion { border-collapse: collapse; font-size: 0.78rem; font-variant-numeric: tabular-nums; margin: 0.4rem 0 0.8rem; }
66+ table.confusion th, table.confusion td { padding: 0.15rem 0.55rem; text-align: right; border: 1px solid rgba(128,128,128,0.2); }
67+ table.confusion th { font-weight: 600; }
68+ table.confusion td.diag { background: rgba(61, 187, 110, 0.15); }
69+ table.confusion td.bad { background: rgba(228, 87, 61, 0.14); }
70+ .err { color: #e4573d; font-size: 0.85rem; white-space: pre-wrap; }
71+ progress { width: 14rem; }
72+ </style>
73+ </head>
74+ <body>
75+ <h1>audio-spike-sorting</h1>
76+ <p>
77+ Spike sorting for audio events: record examples of each event type,
78+ synthesize recordings with known ground truth, run MATLAB-syntax
79+ sorting algorithms in the browser via numbl, and score the errors.
80+ </p>
81+
82+ <h2>Event types <span class="cost">record hundreds of examples of each; keep events at least 200 ms apart</span></h2>
83+ <div id="type-list"></div>
84+ <div class="bar">
85+ <input id="type-name" type="text" placeholder="new event type (e.g. table tap)" />
86+ <button id="add-type">Add event type</button>
87+ <div class="meter" id="meter" hidden><div></div></div>
88+ <span style="flex:1"></span>
89+ <button id="load-demo" title="three synthetic event types, no microphone needed">Load demo data</button>
90+ </div>
91+
92+ <div id="clip-editor" class="panel" hidden>
93+ <div class="bar">
94+ <strong id="clip-title"></strong>
95+ <select id="clip-select"></select>
96+ <span class="hint">click a marker to audition · drag or shift-click to select several · X toggles exclusion</span>
97+ </div>
98+ <canvas id="clip-wave" class="wave"></canvas>
99+ <div class="bar">
100+ <label>sensitivity <input id="clip-sens" type="range" min="0" max="1" step="0.01" value="0.6" /></label>
101+ <button id="clip-exclude" disabled>Exclude</button>
102+ <span id="clip-count" class="hint"></span>
103+ <span style="flex:1"></span>
104+ <button id="clip-delete">Delete clip</button>
105+ <button id="clip-done">Done</button>
106+ </div>
107+ </div>
108+
109+ <h2>Benchmark configuration <span class="cost">each run synthesizes a fresh dataset from seed + run index</span></h2>
110+ <div class="config-grid" id="config-grid"></div>
111+
112+ <h2>Algorithms <span class="cost">function [times, labels] = name(x, fs, training) · MATLAB syntax, executed by numbl</span></h2>
113+ <div id="algo-list"></div>
114+ <div class="bar">
115+ <button id="add-algo">Add algorithm</button>
116+ </div>
117+
118+ <div id="algo-editor" class="panel" hidden>
119+ <div class="bar">
120+ <input id="algo-name" type="text" style="font-weight:600" />
121+ <span class="hint">the file must define one function taking (x, fs, training) and returning [times, labels]</span>
122+ <span style="flex:1"></span>
123+ <button id="algo-reset" hidden>Reset to built-in</button>
124+ <button id="algo-remove">Delete</button>
125+ <button id="algo-close">Close</button>
126+ </div>
127+ <textarea id="algo-source" class="source" spellcheck="false"></textarea>
128+ </div>
129+
130+ <h2>Run <span class="cost">every enabled algorithm on every synthesized dataset</span></h2>
131+ <div class="bar">
132+ <button id="run-bench">Run benchmark</button>
133+ <button id="cancel-bench" hidden>Cancel</button>
134+ <progress id="bench-progress" hidden></progress>
135+ <span id="bench-label" class="hint"></span>
136+ </div>
137+ <div id="results"></div>
138+
139+ <h2>Explore <span class="cost">wheel zooms, drag pans, click auditions</span></h2>
140+ <div class="bar">
141+ <label>run <select id="explore-run"></select></label>
142+ <label>algorithm <select id="explore-algo"></select></label>
143+ <span id="explore-hover" class="hint"></span>
144+ </div>
145+ <canvas id="explorer" class="explorer"></canvas>
146+
147+ <div class="bar" style="margin-top:1.5rem">
148+ <button id="export">Export project</button>
149+ <button id="import">Import project</button>
150+ <input id="import-file" type="file" accept=".json,application/json" hidden />
151+ <span style="flex:1"></span>
152+ <button id="reset-all">Forget everything</button>
153+ </div>
154+ <p id="status"></p>
155+ <script type="module" src="./src/main.ts"></script>
156+ </body>
157+</html>
package-lock.jsonadded+3654−0View file
This diff is 3,659 lines long and is not shown.
package.jsonadded+26−0View file
@@ -0,0 +1,26 @@
1+{
2+ "name": "audio-spike-sorting",
3+ "version": "0.1.0",
4+ "description": "Benchmark framework for detection and classification of audio events: record labeled examples, synthesize ground-truth datasets with controlled overlap, run MATLAB-syntax algorithms via numbl, and score them spike-sorting style",
5+ "type": "module",
6+ "engines": {
7+ "node": ">=22.6"
8+ },
9+ "license": "Apache-2.0",
10+ "scripts": {
11+ "dev": "vite",
12+ "build": "tsc --noEmit && vite build",
13+ "test": "vite-node scripts/test-node.ts",
14+ "smoke": "vite-node scripts/smoke-browser.ts"
15+ },
16+ "dependencies": {
17+ "numbl": "^0.4.19"
18+ },
19+ "devDependencies": {
20+ "@types/node": "^26.1.1",
21+ "playwright-core": "^1.62.1",
22+ "typescript": "^5.5.0",
23+ "vite": "^5.4.0",
24+ "vite-node": "^6.0.0"
25+ }
26+}
scripts/smoke-browser.tsadded+67−0View file
@@ -0,0 +1,67 @@
1+/**
2+ * Browser smoke test: serve the built app, load the demo data, run a
3+ * short benchmark, and check that scores and the explorer arrive. Uses
4+ * the system Chrome; no microphone is involved (demo data is synthetic).
5+ * Run `npm run build` first, then `vite-node scripts/smoke-browser.ts`.
6+ */
7+import { chromium } from 'playwright-core';
8+import { preview } from 'vite';
9+
10+const server = await preview({ preview: { port: 4173, strictPort: false } });
11+const url = server.resolvedUrls!.local[0];
12+console.log('serving', url);
13+
14+const browser = await chromium.launch({
15+ executablePath: '/usr/bin/google-chrome',
16+ headless: true,
17+});
18+const page = await browser.newPage();
19+const errors: string[] = [];
20+page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));
21+page.on('console', (m) => { if (m.type() === 'error') errors.push(`console: ${m.text()}`); });
22+
23+try {
24+ await page.goto(url, { waitUntil: 'networkidle' });
25+
26+ await page.click('#load-demo');
27+ await page.waitForFunction(() => document.querySelectorAll('.type-row').length === 3);
28+ const counts = await page.$$eval('.type-row .count', (els) => els.map((e) => e.textContent));
29+ console.log('type rows:', counts);
30+
31+ // shrink the benchmark so the smoke test stays quick
32+ await page.evaluate(() => {
33+ const inputs = [...document.querySelectorAll<HTMLInputElement>('#config-grid input')];
34+ const set = (label: string, v: string) => {
35+ const el = inputs.find((i) => i.parentElement!.textContent!.includes(label))!;
36+ el.value = v;
37+ el.dispatchEvent(new Event('change'));
38+ };
39+ set('dataset duration', '15');
40+ set('events per type', '15');
41+ set('runs', '2');
42+ });
43+
44+ await page.click('#run-bench');
45+ await page.waitForFunction(
46+ () => document.getElementById('status')!.textContent!.includes('benchmark complete'),
47+ undefined,
48+ { timeout: 180000 },
49+ );
50+ const rows = await page.$$eval('table.results tr', (trs) =>
51+ trs.slice(1).map((tr) => [...tr.children].map((td) => td.textContent).join(' | ')));
52+ console.log('results:');
53+ for (const r of rows) console.log(' ', r);
54+
55+ const explorerVisible = await page.$eval('#explorer', (c) => (c as HTMLCanvasElement).height > 100);
56+ console.log('explorer canvas populated:', explorerVisible);
57+
58+ // accuracy of the two real baselines should beat 0.5 on demo data
59+ const accs = rows.map((r) => Number(r.split('|')[1].trim().split(' ')[0]));
60+ if (rows.length !== 3) throw new Error(`expected 3 result rows, got ${rows.length}`);
61+ if (!(accs[0] > 0.5 && accs[1] > 0.5)) throw new Error(`accuracies too low: ${accs.join(', ')}`);
62+ if (errors.length) throw new Error(`page errors:\n${errors.join('\n')}`);
63+ console.log('SMOKE PASS');
64+} finally {
65+ await browser.close();
66+ await server.close();
67+}
scripts/test-node.tsadded+117−0View file
@@ -0,0 +1,117 @@
1+/**
2+ * End-to-end pipeline test without a microphone: three synthetic event
3+ * types (a low ringing tone, a high short tone, a noise burst) stand in
4+ * for recorded taps. Builds a snippet library, synthesizes datasets across
5+ * runs, executes each baseline algorithm through numbl, and prints scores.
6+ * The matched filter and the kNN sorter should sit far above the naive
7+ * floor; the run fails loudly if they do not.
8+ */
9+import { mulberry32 } from '../src/bench/rng.ts';
10+import { FS, type SnippetLibrary, type SynthConfig } from '../src/bench/types.ts';
11+import { synthesize } from '../src/bench/synthesize.ts';
12+import { score } from '../src/bench/score.ts';
13+import { buildTraining } from '../src/bench/training.ts';
14+import { runAlgorithm } from '../src/bench/execute.ts';
15+import matchedFilterSrc from '../src/algorithms/template_matched_filter.m?raw';
16+import onsetKnnSrc from '../src/algorithms/onset_knn.m?raw';
17+import naiveSrc from '../src/algorithms/naive_threshold.m?raw';
18+
19+const PRE_S = 0.02, POST_S = 0.18;
20+const pre = Math.round(PRE_S * FS), post = Math.round(POST_S * FS);
21+const snipLen = pre + post;
22+
23+// A tap excites the struck object's impulse response, so instances of one
24+// type are highly repeatable up to gain and small decay/pitch drift. The
25+// burst type reuses one fixed noise pattern (the "object") rather than
26+// fresh noise per instance.
27+const burstBase = new Float32Array(post);
28+{
29+ const brng = mulberry32(7);
30+ for (let i = 0; i < post; i++) burstBase[i] = brng() * 2 - 1;
31+}
32+
33+function makeSnippet(kind: number, rng: () => number): Float32Array {
34+ const s = new Float32Array(snipLen);
35+ const jitF = 1 + 0.01 * (rng() - 0.5);
36+ const jitT = 1 + 0.2 * (rng() - 0.5);
37+ if (kind === 0) {
38+ // low ringing tone ~300 Hz, slow decay
39+ const f = 300 * jitF, tau = 0.06 * jitT;
40+ for (let i = 0; i < post; i++) {
41+ const t = i / FS;
42+ s[pre + i] = Math.exp(-t / tau) * Math.sin(2 * Math.PI * f * t) * 0.8;
43+ }
44+ } else if (kind === 1) {
45+ // high short tone ~1400 Hz, fast decay
46+ const f = 1400 * jitF, tau = 0.025 * jitT;
47+ for (let i = 0; i < post; i++) {
48+ const t = i / FS;
49+ s[pre + i] = Math.exp(-t / tau) * Math.sin(2 * Math.PI * f * t) * 0.8;
50+ }
51+ } else {
52+ // repeatable noise burst, very fast decay
53+ const tau = 0.012 * jitT;
54+ for (let i = 0; i < post; i++) {
55+ const t = i / FS;
56+ s[pre + i] = Math.exp(-t / tau) * (burstBase[i] + 0.15 * (rng() * 2 - 1)) * 0.7;
57+ }
58+ }
59+ // a touch of recording noise everywhere
60+ for (let i = 0; i < snipLen; i++) s[i] += (rng() * 2 - 1) * 0.002;
61+ return s;
62+}
63+
64+const rng = mulberry32(42);
65+const lib: SnippetLibrary = { names: ['low', 'high', 'burst'], pre, post, snippets: [] };
66+for (let k = 0; k < 3; k++) {
67+ for (let e = 0; e < 120; e++) lib.snippets.push({ typeIndex: k, samples: makeSnippet(k, rng) });
68+}
69+
70+const cfg: SynthConfig = {
71+ durationS: 30,
72+ eventsPerType: 30,
73+ gainMinDb: -12,
74+ overlap: 0.2,
75+ trainFraction: 0.3,
76+ seed: 1,
77+};
78+
79+const algos = [
80+ { name: 'template_matched_filter', source: matchedFilterSrc },
81+ { name: 'onset_knn', source: onsetKnnSrc },
82+ { name: 'naive_threshold', source: naiveSrc },
83+];
84+
85+const NRUNS = 2;
86+const TOL = 0.02;
87+const acc: Record<string, number[]> = {};
88+for (let r = 0; r < NRUNS; r++) {
89+ const ds = synthesize(lib, cfg, r);
90+ const training = buildTraining(lib, ds.trainIndices);
91+ console.log(`run ${r}: ${ds.truth.length} ground-truth events, ${ds.x.length} samples`);
92+ for (const a of algos) {
93+ const t0 = Date.now();
94+ try {
95+ const out = runAlgorithm(a.source, ds.x, FS, training);
96+ const s = score(ds.truth, out.detections, lib.names.length, TOL);
97+ (acc[a.name] ??= []).push(s.accuracy);
98+ console.log(
99+ ` ${a.name.padEnd(26)} acc=${s.accuracy.toFixed(3)} correct=${s.correct}` +
100+ ` miss=${s.missed} fp=${s.falsePositives} misclass=${s.misclassified}` +
101+ ` (${Date.now() - t0} ms)`,
102+ );
103+ } catch (err) {
104+ console.log(` ${a.name.padEnd(26)} ERROR: ${(err as Error).message}`);
105+ }
106+ }
107+}
108+
109+const mean = (v: number[]) => v.reduce((a, b) => a + b, 0) / Math.max(v.length, 1);
110+console.log('\nmean accuracy over runs:');
111+for (const a of algos) console.log(` ${a.name.padEnd(26)} ${mean(acc[a.name] ?? []).toFixed(3)}`);
112+
113+const mf = mean(acc['template_matched_filter'] ?? []);
114+const knn = mean(acc['onset_knn'] ?? []);
115+if (!(mf > 0.7)) { console.error('FAIL: matched filter accuracy too low'); process.exit(1); }
116+if (!(knn > 0.5)) { console.error('FAIL: onset_knn accuracy too low'); process.exit(1); }
117+console.log('PASS');
src/algorithms/index.tsadded+44−0View file
@@ -0,0 +1,44 @@
1+/**
2+ * The built-in baseline algorithms. Their sources ship with the app and
3+ * can be reset to these originals after editing; user-added algorithms
4+ * live only in the store.
5+ */
6+import type { AlgorithmDef } from '../bench/types.ts';
7+import matchedFilterSrc from './template_matched_filter.m?raw';
8+import onsetKnnSrc from './onset_knn.m?raw';
9+import naiveSrc from './naive_threshold.m?raw';
10+
11+export const BUILTIN_ALGORITHMS: AlgorithmDef[] = [
12+ {
13+ id: 'builtin:template_matched_filter',
14+ name: 'template_matched_filter',
15+ source: matchedFilterSrc,
16+ enabled: true,
17+ builtin: true,
18+ },
19+ {
20+ id: 'builtin:onset_knn',
21+ name: 'onset_knn',
22+ source: onsetKnnSrc,
23+ enabled: true,
24+ builtin: true,
25+ },
26+ {
27+ id: 'builtin:naive_threshold',
28+ name: 'naive_threshold',
29+ source: naiveSrc,
30+ enabled: true,
31+ builtin: true,
32+ },
33+];
34+
35+export const NEW_ALGORITHM_TEMPLATE = `function [times, labels] = my_sorter(x, fs, training)
36+% x recording, column vector at fs Hz
37+% training struct: snips {1 x nTypes} cell of [snipLen x nSnips] matrices
38+% (columns are snippets), names {1 x nTypes}, pre (samples
39+% before the event onset within each snippet)
40+% Return event times in seconds and 1-based type labels.
41+times = zeros(0, 1);
42+labels = zeros(0, 1);
43+end
44+`;
src/algorithms/naive_threshold.madded+47−0View file
@@ -0,0 +1,47 @@
1+function [times, labels] = naive_threshold(x, fs, training)
2+% Deliberately weak floor: detect where the smoothed amplitude envelope
3+% peaks above a threshold, and classify every event by its duration alone
4+% (time the envelope stays above a fraction of the peak), nearest to the
5+% mean duration of each training type. Scores from this baseline are the
6+% floor real methods should clear.
7+nTypes = numel(training.snips);
8+L = size(training.snips{1}, 1);
9+
10+w = round(0.005 * fs);
11+kern = ones(w, 1) / w;
12+env = conv(abs(x), kern, 'same');
13+n = numel(env);
14+thr = max(8 * median(env), 0.05 * max(env));
15+refr = round(L / 2);
16+
17+% mean above-threshold duration per type, from the training snippets
18+durs = zeros(nTypes, 1);
19+for k = 1:nTypes
20+ S = training.snips{k};
21+ dk = 0;
22+ for j = 1:size(S, 2)
23+ ev = conv(abs(S(:, j)), kern, 'same');
24+ dk = dk + sum(ev > 0.2 * max(ev));
25+ end
26+ durs(k) = dk / max(size(S, 2), 1);
27+end
28+
29+times = zeros(0, 1);
30+labels = zeros(0, 1);
31+i = 2;
32+while i < n
33+ if env(i) > thr && env(i-1) <= thr
34+ j2 = min(n, i + refr - 1);
35+ [pk, rel] = max(env(i:j2));
36+ p = i + rel - 1;
37+ j3 = min(n, i + L - 1);
38+ dur = sum(env(i:j3) > 0.2 * pk);
39+ [~, lab] = min(abs(durs - dur));
40+ times(end+1, 1) = (i - 1) / fs;
41+ labels(end+1, 1) = lab;
42+ i = p + refr;
43+ else
44+ i = i + 1;
45+ end
46+end
47+end
src/algorithms/onset_knn.madded+108−0View file
@@ -0,0 +1,108 @@
1+function [times, labels] = onset_knn(x, fs, training)
2+% Detection and classification kept separate: events are found on a
3+% smoothed energy envelope (rise above a robust threshold), then each
4+% event is classified by distance-weighted k-nearest-neighbours over
5+% spectral-shape features of the training snippets. Features are level
6+% ratios, so the random per-event gain carries no information.
7+nTypes = numel(training.snips);
8+L = size(training.snips{1}, 1);
9+pre = training.pre;
10+
11+% ---- detection ----------------------------------------------------------
12+w = round(0.004 * fs);
13+env = conv(x .* x, ones(w, 1) / w, 'same');
14+env = sqrt(env);
15+n = numel(env);
16+thr = max(8 * median(env), 0.05 * max(env));
17+refr = round(L / 2);
18+
19+starts = zeros(0, 1);
20+i = 2;
21+while i < n
22+ if env(i) > thr && env(i-1) <= thr
23+ % crossing; find the local peak, then walk back to the foot of the rise
24+ j2 = min(n, i + refr - 1);
25+ [pk, rel] = max(env(i:j2));
26+ p = i + rel - 1;
27+ s = p;
28+ while s > 1 && env(s-1) < env(s) && env(s) > 0.05 * pk
29+ s = s - 1;
30+ end
31+ starts(end+1, 1) = s;
32+ % hold off until the envelope falls well below threshold (hysteresis),
33+ % so ripple in a long ringing tail is not reported again
34+ q = p;
35+ while q < n && env(q) > 0.5 * thr
36+ q = q + 1;
37+ end
38+ i = max(p + refr, q);
39+ else
40+ i = i + 1;
41+ end
42+end
43+
44+% ---- training features --------------------------------------------------
45+nTrain = 0;
46+for k = 1:nTypes
47+ nTrain = nTrain + size(training.snips{k}, 2);
48+end
49+F = zeros(nTrain, 6);
50+Fl = zeros(nTrain, 1);
51+r = 0;
52+for k = 1:nTypes
53+ S = training.snips{k};
54+ for j = 1:size(S, 2)
55+ r = r + 1;
56+ F(r, :) = event_features(S(pre+1:end, j), fs);
57+ Fl(r) = k;
58+ end
59+end
60+mu = mean(F, 1);
61+sd = std(F, 0, 1) + 1e-9;
62+Fz = (F - repmat(mu, nTrain, 1)) ./ repmat(sd, nTrain, 1);
63+
64+% ---- classify each detected event --------------------------------------
65+kNN = min(5, nTrain);
66+times = zeros(0, 1);
67+labels = zeros(0, 1);
68+for e = 1:numel(starts)
69+ s = starts(e);
70+ seg = x(s:min(n, s + (L - pre) - 1));
71+ f = (event_features(seg, fs) - mu) ./ sd;
72+ d = sqrt(sum((Fz - repmat(f, nTrain, 1)).^2, 2));
73+ [ds, di] = sort(d);
74+ votes = zeros(nTypes, 1);
75+ for v = 1:kNN
76+ votes(Fl(di(v))) = votes(Fl(di(v))) + 1 / (ds(v) + 1e-6);
77+ end
78+ [~, lab] = max(votes);
79+ times(end+1, 1) = (s - 1) / fs;
80+ labels(end+1, 1) = lab;
81+end
82+end
83+
84+function f = event_features(seg, fs)
85+% Spectral shape of the event: four log band-energy ratios (mean removed,
86+% so absolute level cancels), log spectral centroid, and zero-crossing
87+% rate over the first 25 ms.
88+nfft = 1024;
89+m = min(numel(seg), nfft);
90+s2 = seg(1:m);
91+win = 0.5 - 0.5 * cos(2 * pi * (0:m-1)' / max(m - 1, 1));
92+S = fft(s2 .* win, nfft);
93+p = abs(S(1:nfft/2)).^2;
94+hz = fs / nfft;
95+edges = [0 200 800 2500 8000];
96+b = zeros(1, 4);
97+for q = 1:4
98+ k0 = max(2, floor(edges(q) / hz) + 1);
99+ k1 = min(nfft/2, floor(edges(q+1) / hz));
100+ b(q) = log10(sum(p(k0:k1)) + 1e-12);
101+end
102+b = b - mean(b);
103+kv = (0:nfft/2-1)' * hz;
104+cen = sum(p .* kv) / (sum(p) + 1e-12);
105+zn = min(numel(seg), round(0.025 * fs));
106+zc = sum(abs(diff(seg(1:zn) >= 0)));
107+f = [b, log10(cen + 1), zc / max(zn, 1) * fs / 1000];
108+end
src/algorithms/template_matched_filter.madded+66−0View file
@@ -0,0 +1,66 @@
1+function [times, labels] = template_matched_filter(x, fs, training)
2+% Classic spike-sorting approach: one mean template per type, unit norm,
3+% cross-correlated against the recording via the FFT. At each sample the
4+% best-scoring type is kept; peaks above an adaptive threshold become
5+% events, with a refractory period of half a snippet so one event is not
6+% reported twice.
7+nTypes = numel(training.snips);
8+L = size(training.snips{1}, 1);
9+n = numel(x);
10+
11+% Unit-norm mean template per type; scores are then divided by the mean
12+% training-instance norm of the type, so a peak reads roughly in units of
13+% the placed gain and one threshold treats loud and quiet types alike.
14+T = zeros(L, nTypes);
15+instNorm = zeros(nTypes, 1);
16+for k = 1:nTypes
17+ S = training.snips{k};
18+ tk = mean(S, 2);
19+ T(:, k) = tk / (norm(tk) + 1e-12);
20+ nk = 0;
21+ for j = 1:size(S, 2)
22+ nk = nk + norm(S(:, j));
23+ end
24+ instNorm(k) = nk / max(size(S, 2), 1) + 1e-12;
25+end
26+
27+% Correlation of each template with the signal: score(s, k) is the match
28+% of template k starting at sample s.
29+nfft = 2^nextpow2(n + L - 1);
30+X = fft(x, nfft);
31+score = zeros(n, nTypes);
32+for k = 1:nTypes
33+ c = real(ifft(X .* conj(fft(T(:, k), nfft))));
34+ score(:, k) = c(1:n) / instNorm(k);
35+end
36+[best, bestType] = max(score, [], 2);
37+
38+% Threshold: robust noise scale from the median absolute score, with a
39+% floor relative to the largest peak so a silent background does not send
40+% the threshold to zero.
41+thr = max(10 * median(abs(best)), 0.1 * max(best));
42+refr = round(L / 2);
43+
44+times = zeros(0, 1);
45+labels = zeros(0, 1);
46+i = 2;
47+while i < n
48+ if best(i) > thr && best(i) >= best(i-1) && best(i) >= best(i+1)
49+ % local peak; take the maximum over the refractory window
50+ j2 = min(n, i + refr - 1);
51+ [pk, rel] = max(best(i:j2));
52+ s = i + rel - 1;
53+ times(end+1, 1) = (s - 1 + training.pre) / fs;
54+ labels(end+1, 1) = bestType(s);
55+ % hold off past the refractory while the score stays near threshold,
56+ % so the periodic correlation tail of a ringing event is not re-picked
57+ q = s + refr;
58+ while q < n && best(q) > 0.8 * thr
59+ q = q + 1;
60+ end
61+ i = q;
62+ else
63+ i = i + 1;
64+ end
65+end
66+end
src/analysis/onsets.tsadded+98−0View file
@@ -0,0 +1,98 @@
1+/**
2+ * Onset detection by spectral flux, adapted from instrawave-compose for
3+ * the 16 kHz internal rate (shorter frame and hop, so timing stays sharp).
4+ * The half-wave rectified frame-to-frame increase in magnitude peaks where
5+ * a new sound starts; peaks are picked against a local mean plus a floor
6+ * the single `sensitivity` control moves.
7+ */
8+import { fft } from '../audio/fft.ts';
9+
10+const FRAME = 512;
11+const HOP = 128;
12+const MIN_GAP_S = 0.05;
13+
14+/** Onset times in seconds, earliest first. sensitivity in [0, 1]. */
15+export function detectOnsets(x: Float32Array, sr: number, sensitivity: number): number[] {
16+ const nFrames = Math.floor((x.length - FRAME) / HOP);
17+ if (nFrames < 3) return [];
18+
19+ const win = new Float32Array(FRAME);
20+ for (let i = 0; i < FRAME; i++) win[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (FRAME - 1));
21+ const re = new Float32Array(FRAME);
22+ const im = new Float32Array(FRAME);
23+ const prev = new Float32Array(FRAME >> 1);
24+ const flux = new Float32Array(nFrames);
25+
26+ for (let f = 0; f < nFrames; f++) {
27+ const off = f * HOP;
28+ for (let i = 0; i < FRAME; i++) { re[i] = x[off + i] * win[i]; im[i] = 0; }
29+ fft(re, im);
30+ let s = 0;
31+ for (let k = 0; k < FRAME >> 1; k++) {
32+ const mag = Math.sqrt(re[k] * re[k] + im[k] * im[k]);
33+ const d = mag - prev[k];
34+ if (d > 0) s += d;
35+ prev[k] = mag;
36+ }
37+ flux[f] = s;
38+ }
39+
40+ let max = 0;
41+ for (let f = 1; f < nFrames; f++) if (flux[f] > max) max = flux[f];
42+ if (max === 0) return [];
43+ for (let f = 0; f < nFrames; f++) flux[f] /= max;
44+ flux[0] = 0; // the first frame's flux is the frame itself, not an onset
45+
46+ const W = 8;
47+ const floor = 0.02 + 0.28 * (1 - sensitivity);
48+ const minGap = Math.max(1, Math.round((MIN_GAP_S * sr) / HOP));
49+ const onsets: number[] = [];
50+ let last = -minGap;
51+ for (let f = 1; f < nFrames - 1; f++) {
52+ let mean = 0, cnt = 0;
53+ for (let j = Math.max(0, f - W); j <= Math.min(nFrames - 1, f + W); j++) { mean += flux[j]; cnt++; }
54+ mean /= cnt;
55+ if (flux[f] < mean * 1.3 + floor) continue;
56+ if (flux[f] < flux[f - 1] || flux[f] < flux[f + 1]) continue; // local max only
57+ if (f - last < minGap) continue;
58+ last = f;
59+ onsets.push((f * HOP + FRAME / 2) / sr);
60+ }
61+ return onsets;
62+}
63+
64+/**
65+ * Refine a coarse onset to the foot of the energy rise: find the
66+ * short-term energy peak within a window after the coarse time, then walk
67+ * back to where the envelope first falls below a small fraction of that
68+ * peak. This is the project's single definition of "when an event is",
69+ * used both when cutting training snippets and (by construction) as the
70+ * ground-truth time of a placed snippet, so every algorithm is scored
71+ * against a consistent convention.
72+ */
73+export function refineOnset(x: Float32Array, sr: number, tCoarse: number): number {
74+ const w = Math.max(1, Math.round(0.002 * sr)); // 2 ms energy window
75+ const from = Math.max(0, Math.round((tCoarse - 0.03) * sr));
76+ const to = Math.min(x.length - w, Math.round((tCoarse + 0.05) * sr));
77+ if (to <= from) return Math.round(tCoarse * sr);
78+
79+ const env = (s: number) => {
80+ let e = 0;
81+ for (let i = 0; i < w; i++) e += x[s + i] * x[s + i];
82+ return e;
83+ };
84+ let peakS = from, peakE = 0;
85+ for (let s = from; s <= to; s += w) {
86+ const e = env(s);
87+ if (e > peakE) { peakE = e; peakS = s; }
88+ }
89+ if (peakE === 0) return Math.round(tCoarse * sr);
90+ let s = peakS;
91+ while (s - w >= from) {
92+ const e = env(s - w);
93+ if (e < 0.02 * peakE) break;
94+ if (e > env(s)) break; // rising again: an earlier, separate bump
95+ s -= w;
96+ }
97+ return s;
98+}
src/audio/fft.tsadded+49−0View file
@@ -0,0 +1,49 @@
1+/** In-place radix-2 complex FFT; lengths must be powers of two. Small and
2+ * sufficient for the analysis windows used here (1024 and 4096). */
3+export function fft(re: Float32Array, im: Float32Array): void {
4+ const n = re.length;
5+ if ((n & (n - 1)) !== 0) throw new Error(`fft wants a power of two, got ${n}`);
6+ for (let i = 1, j = 0; i < n; i++) {
7+ let bit = n >> 1;
8+ for (; j & bit; bit >>= 1) j ^= bit;
9+ j ^= bit;
10+ if (i < j) {
11+ let t = re[i]; re[i] = re[j]; re[j] = t;
12+ t = im[i]; im[i] = im[j]; im[j] = t;
13+ }
14+ }
15+ for (let len = 2; len <= n; len <<= 1) {
16+ const half = len >> 1;
17+ const ang = (-2 * Math.PI) / len;
18+ const wr = Math.cos(ang), wi = Math.sin(ang);
19+ for (let i = 0; i < n; i += len) {
20+ let cr = 1, ci = 0;
21+ for (let k = 0; k < half; k++) {
22+ const ar = re[i + k], ai = im[i + k];
23+ const br = re[i + k + half] * cr - im[i + k + half] * ci;
24+ const bi = re[i + k + half] * ci + im[i + k + half] * cr;
25+ re[i + k] = ar + br; im[i + k] = ai + bi;
26+ re[i + k + half] = ar - br; im[i + k + half] = ai - bi;
27+ const t = cr * wr - ci * wi;
28+ ci = cr * wi + ci * wr;
29+ cr = t;
30+ }
31+ }
32+ }
33+}
34+
35+/** Hann-windowed power spectrum of x zero-padded to size n (power of two);
36+ * returns n/2 bins. Bin k is frequency k * sr / n. */
37+export function powerSpectrum(x: Float32Array, n: number): Float32Array {
38+ const re = new Float32Array(n);
39+ const im = new Float32Array(n);
40+ const m = Math.min(x.length, n);
41+ for (let i = 0; i < m; i++) {
42+ const w = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (m - 1 || 1));
43+ re[i] = x[i] * w;
44+ }
45+ fft(re, im);
46+ const p = new Float32Array(n >> 1);
47+ for (let k = 0; k < p.length; k++) p[k] = re[k] * re[k] + im[k] * im[k];
48+ return p;
49+}
src/audio/recorder.tsadded+95−0View file
@@ -0,0 +1,95 @@
1+/**
2+ * Microphone capture, adapted from instrawave-compose. The browser's
3+ * processing chain is asked to stand down: automatic gain control would
4+ * rewrite the relative levels the benchmark deliberately randomizes, and
5+ * noise suppression eats the sharp attacks that distinguish event types.
6+ * Capture goes through MediaRecorder, is decoded to mono on stop, and is
7+ * immediately reduced to the benchmark's internal rate with a box-filter
8+ * average per output sample (a crude but adequate anti-alias for
9+ * percussive material), so everything downstream sees one rate.
10+ */
11+export interface Clip {
12+ samples: Float32Array;
13+ sr: number;
14+}
15+
16+/** Downsample by box-averaging the input samples covered by each output
17+ * sample. Assumes srTo <= srFrom. */
18+export function downsampleBox(x: Float32Array, srFrom: number, srTo: number): Float32Array {
19+ if (srFrom === srTo) return x;
20+ const n = Math.floor((x.length * srTo) / srFrom);
21+ const out = new Float32Array(n);
22+ for (let i = 0; i < n; i++) {
23+ const a = Math.floor((i * srFrom) / srTo);
24+ const b = Math.max(a + 1, Math.floor(((i + 1) * srFrom) / srTo));
25+ let s = 0;
26+ for (let j = a; j < b && j < x.length; j++) s += x[j];
27+ out[i] = s / (b - a);
28+ }
29+ return out;
30+}
31+
32+export class Recorder {
33+ private stream: MediaStream | null = null;
34+ private rec: MediaRecorder | null = null;
35+ private chunks: Blob[] = [];
36+ private ctx: AudioContext;
37+ private analyser: AnalyserNode | null = null;
38+ private levelBuf = new Float32Array(1024);
39+
40+ constructor(ctx: AudioContext) {
41+ this.ctx = ctx;
42+ }
43+
44+ get recording(): boolean {
45+ return this.rec !== null;
46+ }
47+
48+ async start(): Promise<void> {
49+ this.stream = await navigator.mediaDevices.getUserMedia({
50+ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
51+ });
52+ if (this.ctx.state === 'suspended') await this.ctx.resume();
53+ const src = this.ctx.createMediaStreamSource(this.stream);
54+ this.analyser = this.ctx.createAnalyser();
55+ this.analyser.fftSize = 1024;
56+ src.connect(this.analyser); // analyser is a sink; nothing reaches the speakers
57+ this.chunks = [];
58+ this.rec = new MediaRecorder(this.stream);
59+ this.rec.ondataavailable = (e) => { if (e.data.size) this.chunks.push(e.data); };
60+ this.rec.start();
61+ }
62+
63+ /** Instantaneous input level in [0, 1], for a meter. */
64+ level(): number {
65+ if (!this.analyser) return 0;
66+ this.analyser.getFloatTimeDomainData(this.levelBuf);
67+ let peak = 0;
68+ for (let i = 0; i < this.levelBuf.length; i++) {
69+ const a = Math.abs(this.levelBuf[i]);
70+ if (a > peak) peak = a;
71+ }
72+ return Math.min(1, peak * 1.4);
73+ }
74+
75+ /** Stop and return the clip at the requested rate. */
76+ async stop(srTo: number): Promise<Clip> {
77+ const rec = this.rec;
78+ if (!rec) throw new Error('not recording');
79+ this.rec = null;
80+ const blob = await new Promise<Blob>((resolve) => {
81+ rec.onstop = () => resolve(new Blob(this.chunks, { type: rec.mimeType }));
82+ rec.stop();
83+ });
84+ this.stream?.getTracks().forEach((t) => t.stop());
85+ this.stream = null;
86+ this.analyser = null;
87+ const buf = await this.ctx.decodeAudioData(await blob.arrayBuffer());
88+ const mono = new Float32Array(buf.length);
89+ for (let c = 0; c < buf.numberOfChannels; c++) {
90+ const ch = buf.getChannelData(c);
91+ for (let i = 0; i < buf.length; i++) mono[i] += ch[i] / buf.numberOfChannels;
92+ }
93+ return { samples: downsampleBox(mono, buf.sampleRate, srTo), sr: srTo };
94+ }
95+}
src/bench/algo.worker.tsadded+42−0View file
@@ -0,0 +1,42 @@
1+/// <reference lib="webworker" />
2+/**
3+ * Runs one algorithm on one dataset off the main thread, so a slow or
4+ * broken candidate cannot freeze the page. One request, one response;
5+ * the runner keeps a single worker and feeds it sequentially, which also
6+ * makes per-algorithm timing honest.
7+ */
8+import { runAlgorithm } from './execute.ts';
9+import type { TrainingArrays } from './training.ts';
10+import type { Detection } from './types.ts';
11+
12+export interface AlgoRequest {
13+ id: number;
14+ source: string;
15+ x: Float32Array;
16+ fs: number;
17+ training: TrainingArrays;
18+}
19+
20+export interface AlgoResponse {
21+ id: number;
22+ ok: boolean;
23+ detections?: Detection[];
24+ logs: string[];
25+ elapsedMs: number;
26+ error?: string;
27+}
28+
29+self.onmessage = (e: MessageEvent<AlgoRequest>) => {
30+ const { id, source, x, fs, training } = e.data;
31+ try {
32+ const out = runAlgorithm(source, x, fs, training);
33+ const resp: AlgoResponse = { id, ok: true, ...out };
34+ self.postMessage(resp);
35+ } catch (err) {
36+ const resp: AlgoResponse = {
37+ id, ok: false, logs: [], elapsedMs: 0,
38+ error: err instanceof Error ? err.message : String(err),
39+ };
40+ self.postMessage(resp);
41+ }
42+};
src/bench/execute.tsadded+86−0View file
@@ -0,0 +1,86 @@
1+/**
2+ * Runs one algorithm on one dataset through numbl. The algorithm is a
3+ * MATLAB-syntax file defining
4+ *
5+ * function [times, labels] = anyname(x, fs, training)
6+ *
7+ * where x is the recording (column vector), fs the sampling rate, and
8+ * training a struct with fields:
9+ * snips {1 x nTypes} cell, each [snipLen x nSnips] (columns are snippets)
10+ * names {1 x nTypes} cell of type names
11+ * pre samples before the event onset within each snippet
12+ * It must return event times in seconds and 1-based type labels. This
13+ * module is pure of the DOM so the node test and the web worker share it.
14+ */
15+import { executeCode, RTV } from 'numbl';
16+import type { RuntimeValue } from 'numbl';
17+import type { Detection } from './types.ts';
18+import type { TrainingArrays } from './training.ts';
19+
20+/** Name of the first function defined in a .m source, or null for a plain script. */
21+export function extractFunctionName(source: string): string | null {
22+ const m = source.match(/^\s*function\s+(?:\[[^\]]*\]\s*=\s*|\w+\s*=\s*)?([A-Za-z]\w*)\s*\(/m);
23+ return m ? m[1] : null;
24+}
25+
26+export interface ExecuteOutcome {
27+ detections: Detection[];
28+ logs: string[];
29+ elapsedMs: number;
30+}
31+
32+export function runAlgorithm(
33+ source: string,
34+ x: Float32Array | Float64Array,
35+ fs: number,
36+ training: TrainingArrays,
37+): ExecuteOutcome {
38+ const fnName = extractFunctionName(source);
39+ if (!fnName) throw new Error('algorithm source must define a function [times, labels] = name(x, fs, training)');
40+
41+ const xd = x instanceof Float64Array ? x : Float64Array.from(x);
42+ const xv = RTV.tensor(xd, [xd.length, 1]);
43+ const trainingStruct = RTV.struct({
44+ snips: RTV.cell(
45+ training.snips.map((s) => RTV.tensor(s.data, [s.snipLen, Math.max(s.count, 0)])),
46+ [1, training.snips.length],
47+ ),
48+ names: RTV.cell(training.names.map((n) => RTV.char(n)), [1, training.names.length]),
49+ pre: RTV.num(training.pre),
50+ });
51+
52+ const logs: string[] = [];
53+ const t0 = (typeof performance !== 'undefined' ? performance : Date).now();
54+ const result = executeCode(
55+ `[times__, labels__] = ${fnName}(x, fs, training);\ntimes__ = times__(:); labels__ = labels__(:);`,
56+ {
57+ initialVariableValues: { x: xv, fs: RTV.num(fs), training: trainingStruct },
58+ optimization: '1',
59+ displayResults: false,
60+ onOutput: (text: string) => logs.push(text),
61+ },
62+ [{ name: `${fnName}.m`, source }],
63+ 'main.m',
64+ );
65+ const elapsedMs = (typeof performance !== 'undefined' ? performance : Date).now() - t0;
66+
67+ const times = toVector(result.variableValues.times__);
68+ const labels = toVector(result.variableValues.labels__);
69+ if (times.length !== labels.length) {
70+ throw new Error(`times (${times.length}) and labels (${labels.length}) differ in length`);
71+ }
72+ const detections: Detection[] = [];
73+ for (let i = 0; i < times.length; i++) {
74+ detections.push({ t: times[i], typeIndex: Math.round(labels[i]) - 1 });
75+ }
76+ detections.sort((a, b) => a.t - b.t);
77+ return { detections, logs, elapsedMs };
78+}
79+
80+function toVector(v: RuntimeValue | undefined): number[] {
81+ if (v === undefined || v === null) throw new Error('algorithm did not return times/labels');
82+ if (typeof v === 'number') return [v];
83+ const t = v as { data?: Float64Array };
84+ if (t.data instanceof Float64Array) return Array.from(t.data);
85+ throw new Error('times/labels must be numeric vectors');
86+}
src/bench/rng.tsadded+23−0View file
@@ -0,0 +1,23 @@
1+/** Seeded RNG (mulberry32) so every synthesized dataset is reproducible
2+ * from (base seed, run index) alone. */
3+export type Rng = () => number;
4+
5+export function mulberry32(seed: number): Rng {
6+ let a = seed >>> 0;
7+ return () => {
8+ a |= 0;
9+ a = (a + 0x6d2b79f5) | 0;
10+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
11+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
12+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
13+ };
14+}
15+
16+/** Fisher-Yates shuffle in place. */
17+export function shuffle<T>(arr: T[], rng: Rng): T[] {
18+ for (let i = arr.length - 1; i > 0; i--) {
19+ const j = Math.floor(rng() * (i + 1));
20+ const t = arr[i]; arr[i] = arr[j]; arr[j] = t;
21+ }
22+ return arr;
23+}
src/bench/runner.tsadded+106−0View file
@@ -0,0 +1,106 @@
1+/**
2+ * Benchmark orchestration: for each run, synthesize one dataset (run r
3+ * uses seed + r, so the whole benchmark is reproducible from the config),
4+ * hand the same dataset to every enabled algorithm through the worker,
5+ * and score each result against the ground truth. Algorithms run
6+ * sequentially on one worker so timings are comparable and a runaway
7+ * script can be cancelled by killing the worker.
8+ */
9+import { buildTraining } from './training.ts';
10+import { score } from './score.ts';
11+import { synthesize } from './synthesize.ts';
12+import type { AlgorithmDef, RunResult, SnippetLibrary, SynthConfig } from './types.ts';
13+import { FS } from './types.ts';
14+import type { AlgoRequest, AlgoResponse } from './algo.worker.ts';
15+
16+export interface BenchmarkProgress {
17+ runIndex: number;
18+ runCount: number;
19+ algorithmName: string;
20+ done: number;
21+ total: number;
22+}
23+
24+export interface BenchmarkOutput {
25+ runs: { seed: number; x: Float32Array; truth: { t: number; typeIndex: number; gain: number }[] }[];
26+ results: RunResult[];
27+}
28+
29+export class BenchmarkRunner {
30+ private worker: Worker | null = null;
31+ private nextId = 1;
32+ cancelled = false;
33+
34+ cancel(): void {
35+ this.cancelled = true;
36+ this.worker?.terminate();
37+ this.worker = null;
38+ }
39+
40+ private exec(req: Omit<AlgoRequest, 'id'>): Promise<AlgoResponse> {
41+ this.worker ??= new Worker(new URL('./algo.worker.ts', import.meta.url), { type: 'module' });
42+ const worker = this.worker;
43+ const id = this.nextId++;
44+ return new Promise((resolve, reject) => {
45+ const onMsg = (e: MessageEvent<AlgoResponse>) => {
46+ if (e.data.id !== id) return;
47+ worker.removeEventListener('message', onMsg);
48+ resolve(e.data);
49+ };
50+ worker.addEventListener('message', onMsg);
51+ worker.addEventListener('error', (e) => reject(new Error(e.message)), { once: true });
52+ worker.postMessage({ id, ...req });
53+ });
54+ }
55+
56+ async run(
57+ lib: SnippetLibrary,
58+ cfg: SynthConfig,
59+ runCount: number,
60+ toleranceMs: number,
61+ algorithms: AlgorithmDef[],
62+ onProgress: (p: BenchmarkProgress) => void,
63+ ): Promise<BenchmarkOutput> {
64+ this.cancelled = false;
65+ const runs: BenchmarkOutput['runs'] = [];
66+ const results: RunResult[] = [];
67+ const total = runCount * algorithms.length;
68+ let done = 0;
69+ for (let r = 0; r < runCount && !this.cancelled; r++) {
70+ const ds = synthesize(lib, cfg, r);
71+ runs.push({ seed: cfg.seed + r, x: ds.x, truth: ds.truth });
72+ const training = buildTraining(lib, ds.trainIndices);
73+ for (const algo of algorithms) {
74+ if (this.cancelled) break;
75+ onProgress({ runIndex: r, runCount, algorithmName: algo.name, done, total });
76+ const resp = await this.exec({ source: algo.source, x: ds.x, fs: FS, training });
77+ done++;
78+ if (resp.ok && resp.detections) {
79+ results.push({
80+ runIndex: r,
81+ seed: cfg.seed + r,
82+ algorithmId: algo.id,
83+ score: score(ds.truth, resp.detections, lib.names.length, toleranceMs / 1000),
84+ elapsedMs: resp.elapsedMs,
85+ logs: resp.logs,
86+ detections: resp.detections,
87+ });
88+ } else {
89+ results.push({
90+ runIndex: r,
91+ seed: cfg.seed + r,
92+ algorithmId: algo.id,
93+ score: score(ds.truth, [], lib.names.length, toleranceMs / 1000),
94+ elapsedMs: resp.elapsedMs,
95+ error: resp.error ?? 'unknown error',
96+ logs: resp.logs,
97+ detections: [],
98+ });
99+ }
100+ }
101+ }
102+ this.worker?.terminate();
103+ this.worker = null;
104+ return { runs, results };
105+ }
106+}
src/bench/score.tsadded+91−0View file
@@ -0,0 +1,91 @@
1+/**
2+ * Comparison with ground truth. Matching ignores labels: candidate pairs
3+ * within the tolerance are taken greedily by |dt|, so each truth event and
4+ * each detection is used at most once and the closest pairs win. A matched
5+ * pair with the right label is correct, with the wrong label a
6+ * misclassification; unmatched truth is a miss, an unmatched detection a
7+ * false positive. The confusion matrix carries all four kinds: an extra
8+ * "missed" column and a false-positive row.
9+ */
10+import type { Detection, GroundTruthEvent, Score, ScoredEvent } from './types.ts';
11+
12+export function score(
13+ truth: GroundTruthEvent[],
14+ detections: Detection[],
15+ nTypes: number,
16+ toleranceS: number,
17+): Score {
18+ const pairs: { ti: number; di: number; dt: number }[] = [];
19+ // truth is sorted by t; walk a window rather than the full cross product.
20+ const dets = [...detections].sort((a, b) => a.t - b.t);
21+ let lo = 0;
22+ for (let di = 0; di < dets.length; di++) {
23+ while (lo < truth.length && truth[lo].t < dets[di].t - toleranceS) lo++;
24+ for (let ti = lo; ti < truth.length && truth[ti].t <= dets[di].t + toleranceS; ti++) {
25+ pairs.push({ ti, di, dt: Math.abs(truth[ti].t - dets[di].t) });
26+ }
27+ }
28+ pairs.sort((a, b) => a.dt - b.dt);
29+
30+ const truthMatch = new Array<number>(truth.length).fill(-1);
31+ const detMatch = new Array<number>(dets.length).fill(-1);
32+ for (const p of pairs) {
33+ if (truthMatch[p.ti] >= 0 || detMatch[p.di] >= 0) continue;
34+ truthMatch[p.ti] = p.di;
35+ detMatch[p.di] = p.ti;
36+ }
37+
38+ // confusion: (nTypes + 1) x (nTypes + 1); row nTypes = false positives,
39+ // column nTypes = missed.
40+ const confusion = Array.from({ length: nTypes + 1 }, () => new Array<number>(nTypes + 1).fill(0));
41+ const perType = Array.from({ length: nTypes }, () => ({
42+ correct: 0, misclassified: 0, missed: 0, falsePositives: 0, nTruth: 0,
43+ }));
44+ const events: ScoredEvent[] = [];
45+ let correct = 0, misclassified = 0, missed = 0, falsePositives = 0;
46+
47+ for (let ti = 0; ti < truth.length; ti++) {
48+ const tr = truth[ti];
49+ perType[tr.typeIndex].nTruth++;
50+ const di = truthMatch[ti];
51+ if (di < 0) {
52+ missed++;
53+ perType[tr.typeIndex].missed++;
54+ confusion[tr.typeIndex][nTypes]++;
55+ events.push({ kind: 'missed', truth: tr });
56+ } else {
57+ const d = dets[di];
58+ confusion[tr.typeIndex][d.typeIndex]++;
59+ if (d.typeIndex === tr.typeIndex) {
60+ correct++;
61+ perType[tr.typeIndex].correct++;
62+ events.push({ kind: 'correct', truth: tr, det: d });
63+ } else {
64+ misclassified++;
65+ perType[tr.typeIndex].misclassified++;
66+ events.push({ kind: 'misclassified', truth: tr, det: d });
67+ }
68+ }
69+ }
70+ for (let di = 0; di < dets.length; di++) {
71+ if (detMatch[di] >= 0) continue;
72+ falsePositives++;
73+ const d = dets[di];
74+ if (d.typeIndex >= 0 && d.typeIndex < nTypes) {
75+ perType[d.typeIndex].falsePositives++;
76+ confusion[nTypes][d.typeIndex]++;
77+ }
78+ events.push({ kind: 'falsePositive', det: d });
79+ }
80+
81+ const denom = correct + misclassified + missed + falsePositives;
82+ return {
83+ nTruth: truth.length,
84+ nDetected: dets.length,
85+ correct, misclassified, missed, falsePositives,
86+ accuracy: denom > 0 ? correct / denom : 1,
87+ confusion,
88+ events,
89+ perType,
90+ };
91+}
src/bench/snippets.tsadded+57−0View file
@@ -0,0 +1,57 @@
1+/**
2+ * Turning edited clips into a snippet library. Each included onset is
3+ * refined to the foot of its energy rise (the project's one definition of
4+ * event time) and a fixed window [t - pre, t + post) is cut. Onsets whose
5+ * window would run off either end of the clip are skipped rather than
6+ * padded, so every snippet is honest audio.
7+ */
8+import { refineOnset } from '../analysis/onsets.ts';
9+import type { EventType } from '../store.ts';
10+import { FS, type SnippetLibrary } from './types.ts';
11+
12+export interface ClipAudioLookup {
13+ (clipId: string): { samples: Float32Array; sr: number } | null;
14+}
15+
16+export function extractLibrary(
17+ types: EventType[],
18+ preMs: number,
19+ postMs: number,
20+ getAudio: ClipAudioLookup,
21+): SnippetLibrary {
22+ const pre = Math.round((preMs / 1000) * FS);
23+ const post = Math.round((postMs / 1000) * FS);
24+ const lib: SnippetLibrary = { names: types.map((t) => t.name), pre, post, snippets: [] };
25+ types.forEach((type, typeIndex) => {
26+ for (const clip of type.clips) {
27+ const audio = getAudio(clip.id);
28+ if (!audio) continue;
29+ const excluded = new Set(clip.excluded);
30+ clip.onsets.forEach((t, i) => {
31+ if (excluded.has(i)) return;
32+ const s = refineOnset(audio.samples, audio.sr, t);
33+ if (s - pre < 0 || s + post > audio.samples.length) return;
34+ lib.snippets.push({
35+ typeIndex,
36+ samples: audio.samples.slice(s - pre, s + post),
37+ });
38+ });
39+ }
40+ });
41+ return lib;
42+}
43+
44+/** Usable snippet count per type id, for the UI. */
45+export function snippetCounts(
46+ types: EventType[],
47+ preMs: number,
48+ postMs: number,
49+ getAudio: ClipAudioLookup,
50+): Map<string, number> {
51+ const lib = extractLibrary(types, preMs, postMs, getAudio);
52+ const counts = new Map<string, number>();
53+ types.forEach((t, i) => {
54+ counts.set(t.id, lib.snippets.filter((s) => s.typeIndex === i).length);
55+ });
56+ return counts;
57+}
src/bench/synthesize.tsadded+102−0View file
@@ -0,0 +1,102 @@
1+/**
2+ * Dataset synthesis. Each run: split every type's snippets into a training
3+ * set and a placement pool, then place events on a silent timeline. The
4+ * overlap knob controls how events land: a "clean" event keeps at least one
5+ * snippet-length of separation from everything placed so far, an
6+ * "overlapper" is aimed inside the span of an already-placed event. Each
7+ * placed snippet gets a random gain in dB, so level carries no information
8+ * about identity. Ground truth is exact by construction.
9+ */
10+import { mulberry32, shuffle, type Rng } from './rng.ts';
11+import type { Dataset, GroundTruthEvent, SnippetLibrary, SynthConfig } from './types.ts';
12+import { FS } from './types.ts';
13+
14+/** Split snippet indices per type into [train, pool]. Guarantees at least
15+ * one snippet on each side when a type has two or more. */
16+export function splitSnippets(
17+ lib: SnippetLibrary,
18+ trainFraction: number,
19+ rng: Rng,
20+): { train: number[][]; pool: number[][] } {
21+ const byType: number[][] = lib.names.map(() => []);
22+ lib.snippets.forEach((s, i) => byType[s.typeIndex].push(i));
23+ const train: number[][] = [];
24+ const pool: number[][] = [];
25+ for (const idx of byType) {
26+ const order = shuffle([...idx], rng);
27+ let nTrain = Math.round(order.length * trainFraction);
28+ if (order.length >= 2) nTrain = Math.min(Math.max(nTrain, 1), order.length - 1);
29+ train.push(order.slice(0, nTrain));
30+ pool.push(order.slice(nTrain));
31+ }
32+ return { train, pool };
33+}
34+
35+interface Placement { start: number; typeIndex: number; snippetIndex: number; gain: number; }
36+
37+export function synthesize(lib: SnippetLibrary, cfg: SynthConfig, runIndex: number): Dataset {
38+ const rng = mulberry32(cfg.seed + runIndex);
39+ const { train, pool } = splitSnippets(lib, cfg.trainFraction, rng);
40+ const snipLen = lib.pre + lib.post;
41+ const n = Math.round(cfg.durationS * FS);
42+ const margin = snipLen; // keep events off the very edges
43+
44+ // Build the shuffled list of events to place: (type, snippet from the
45+ // placement pool, gain). Pools are sampled with replacement so
46+ // eventsPerType does not depend on how many examples were recorded.
47+ const wanted: { typeIndex: number; snippetIndex: number; gain: number }[] = [];
48+ for (let k = 0; k < lib.names.length; k++) {
49+ if (pool[k].length === 0) continue;
50+ for (let e = 0; e < cfg.eventsPerType; e++) {
51+ const snippetIndex = pool[k][Math.floor(rng() * pool[k].length)];
52+ const gainDb = cfg.gainMinDb * rng();
53+ wanted.push({ typeIndex: k, snippetIndex, gain: Math.pow(10, gainDb / 20) });
54+ }
55+ }
56+ shuffle(wanted, rng);
57+
58+ const placed: Placement[] = [];
59+ const maxTries = 200;
60+ for (const w of wanted) {
61+ const overlapper = placed.length > 0 && rng() < cfg.overlap;
62+ let start = -1;
63+ if (overlapper) {
64+ const host = placed[Math.floor(rng() * placed.length)];
65+ const off = Math.round((rng() - 0.5) * snipLen);
66+ const s = host.start + off;
67+ if (s >= 0 && s + snipLen <= n) start = s;
68+ }
69+ if (start < 0) {
70+ // Clean placement: rejection-sample a start at least snipLen away
71+ // from every placed event (measured start-to-start, which for equal
72+ // lengths means no sample overlaps).
73+ for (let t = 0; t < maxTries; t++) {
74+ const s = margin + Math.floor(rng() * Math.max(1, n - snipLen - 2 * margin));
75+ let ok = true;
76+ for (const p of placed) {
77+ if (Math.abs(p.start - s) < snipLen) { ok = false; break; }
78+ }
79+ if (ok) { start = s; break; }
80+ }
81+ }
82+ if (start < 0) continue; // timeline too crowded; drop the event
83+ placed.push({ start, ...w });
84+ }
85+
86+ const x = new Float32Array(n);
87+ const truth: GroundTruthEvent[] = [];
88+ for (const p of placed) {
89+ const snip = lib.snippets[p.snippetIndex].samples;
90+ for (let i = 0; i < snip.length && p.start + i < n; i++) x[p.start + i] += snip[i] * p.gain;
91+ truth.push({ t: (p.start + lib.pre) / FS, typeIndex: p.typeIndex, gain: p.gain });
92+ }
93+ truth.sort((a, b) => a.t - b.t);
94+
95+ // Protect against clipping without rescaling typical data: only scale
96+ // down when the mix actually exceeds full scale.
97+ let peak = 0;
98+ for (let i = 0; i < n; i++) { const a = Math.abs(x[i]); if (a > peak) peak = a; }
99+ if (peak > 0.99) { const g = 0.99 / peak; for (let i = 0; i < n; i++) x[i] *= g; }
100+
101+ return { x, fs: FS, truth, trainIndices: train };
102+}
src/bench/training.tsadded+27−0View file
@@ -0,0 +1,27 @@
1+/**
2+ * The training payload handed to an algorithm, kept free of any numbl
3+ * import so the main thread can assemble it without pulling the whole
4+ * interpreter into its bundle; the worker turns it into runtime values.
5+ */
6+import type { SnippetLibrary } from './types.ts';
7+
8+export interface TrainingArrays {
9+ names: string[];
10+ pre: number;
11+ /** Per type: column-major [snipLen x count] snippet matrix. */
12+ snips: { data: Float64Array; snipLen: number; count: number }[];
13+}
14+
15+/** Assemble per-type training matrices from a library and a train split. */
16+export function buildTraining(lib: SnippetLibrary, trainIndices: number[][]): TrainingArrays {
17+ const snipLen = lib.pre + lib.post;
18+ const snips = trainIndices.map((idx) => {
19+ const data = new Float64Array(snipLen * idx.length);
20+ idx.forEach((si, j) => {
21+ const s = lib.snippets[si].samples;
22+ for (let i = 0; i < snipLen; i++) data[j * snipLen + i] = s[i] ?? 0;
23+ });
24+ return { data, snipLen, count: idx.length };
25+ });
26+ return { names: lib.names, pre: lib.pre, snips };
27+}
src/bench/types.tsadded+108−0View file
@@ -0,0 +1,108 @@
1+/** Shared benchmark types. Everything runs at a fixed internal rate; audio
2+ * recorded at the mic's native rate is resampled once on capture. */
3+
4+export const FS = 16000;
5+
6+/** A labeled snippet: a fixed window around one event onset. The onset sits
7+ * `pre` samples in, so a snippet is [pre + post] samples long and the event
8+ * time of a placed snippet is placement + pre. */
9+export interface Snippet {
10+ typeIndex: number;
11+ samples: Float32Array;
12+}
13+
14+export interface SnippetLibrary {
15+ /** Event type names, index-aligned with Snippet.typeIndex (0-based here;
16+ * 1-based when handed to MATLAB code). */
17+ names: string[];
18+ /** Samples before the onset in every snippet. */
19+ pre: number;
20+ /** Samples after the onset (snippet length = pre + post). */
21+ post: number;
22+ snippets: Snippet[];
23+}
24+
25+export interface SynthConfig {
26+ /** Dataset duration, seconds. */
27+ durationS: number;
28+ /** Events placed per type (capped by available placement snippets, which
29+ * are reused with replacement when fewer). */
30+ eventsPerType: number;
31+ /** Random per-event gain, uniform in dB over [gainMinDb, 0]. */
32+ gainMinDb: number;
33+ /** Overlap knob in [0, 1]: fraction of events deliberately placed inside
34+ * another event's span; the rest keep a clean separation. */
35+ overlap: number;
36+ /** Fraction of each type's snippets given to the algorithm as training;
37+ * the rest form the placement pool. */
38+ trainFraction: number;
39+ /** Base RNG seed; run r uses seed + r. */
40+ seed: number;
41+}
42+
43+export interface GroundTruthEvent {
44+ /** Event (onset) time in seconds. */
45+ t: number;
46+ typeIndex: number;
47+ gain: number;
48+}
49+
50+export interface Dataset {
51+ x: Float32Array;
52+ fs: number;
53+ truth: GroundTruthEvent[];
54+ /** Training snippets per type (indices into library.snippets). */
55+ trainIndices: number[][];
56+}
57+
58+export interface Detection {
59+ t: number;
60+ typeIndex: number;
61+}
62+
63+export type MatchKind = 'correct' | 'misclassified' | 'missed' | 'falsePositive';
64+
65+export interface ScoredEvent {
66+ kind: MatchKind;
67+ /** Ground-truth side (absent for false positives). */
68+ truth?: GroundTruthEvent;
69+ /** Detection side (absent for misses). */
70+ det?: Detection;
71+}
72+
73+export interface Score {
74+ nTruth: number;
75+ nDetected: number;
76+ correct: number;
77+ misclassified: number;
78+ missed: number;
79+ falsePositives: number;
80+ /** correct / (correct + misclassified + missed + falsePositives). */
81+ accuracy: number;
82+ /** confusion[i][j]: true type i predicted as type j; the extra last column
83+ * is "missed" and the extra last row is false positives by predicted type. */
84+ confusion: number[][];
85+ events: ScoredEvent[];
86+ perType: { correct: number; misclassified: number; missed: number; falsePositives: number; nTruth: number }[];
87+}
88+
89+export interface AlgorithmDef {
90+ id: string;
91+ name: string;
92+ /** MATLAB-syntax source defining
93+ * function [times, labels] = <name>(x, fs, training). */
94+ source: string;
95+ enabled: boolean;
96+ builtin?: boolean;
97+}
98+
99+export interface RunResult {
100+ runIndex: number;
101+ seed: number;
102+ algorithmId: string;
103+ score: Score;
104+ elapsedMs: number;
105+ error?: string;
106+ logs: string[];
107+ detections: Detection[];
108+}
src/demo.tsadded+73−0View file
@@ -0,0 +1,73 @@
1+/**
2+ * Demo data: three synthetic event types generated as if they had been
3+ * recorded (one long clip per type, events well separated), then pushed
4+ * through the same onset detector a real recording would meet. Lets the
5+ * whole pipeline be tried without a microphone, and gives the browser
6+ * smoke test something deterministic to chew on.
7+ */
8+import { detectOnsets } from './analysis/onsets.ts';
9+import { mulberry32 } from './bench/rng.ts';
10+import { FS } from './bench/types.ts';
11+import type { ClipMeta, EventType } from './store.ts';
12+
13+export interface DemoClip { typeName: string; color: string; samples: Float32Array; meta: ClipMeta; }
14+
15+const uid = () => Math.random().toString(36).slice(2, 10);
16+
17+export function makeDemoData(eventsPerType = 40): DemoClip[] {
18+ const rng = mulberry32(1234);
19+ const burstBase = new Float32Array(Math.round(0.18 * FS));
20+ for (let i = 0; i < burstBase.length; i++) burstBase[i] = rng() * 2 - 1;
21+
22+ const kinds: { name: string; color: string; gen: (out: Float32Array, at: number) => void }[] = [
23+ {
24+ name: 'demo low tone', color: '#e4573d',
25+ gen: (out, at) => {
26+ const f = 300 * (1 + 0.01 * (rng() - 0.5)), tau = 0.06 * (1 + 0.2 * (rng() - 0.5));
27+ for (let i = 0; i < 0.18 * FS && at + i < out.length; i++) {
28+ const t = i / FS;
29+ out[at + i] += Math.exp(-t / tau) * Math.sin(2 * Math.PI * f * t) * 0.7;
30+ }
31+ },
32+ },
33+ {
34+ name: 'demo high tone', color: '#3d7de4',
35+ gen: (out, at) => {
36+ const f = 1400 * (1 + 0.01 * (rng() - 0.5)), tau = 0.025 * (1 + 0.2 * (rng() - 0.5));
37+ for (let i = 0; i < 0.18 * FS && at + i < out.length; i++) {
38+ const t = i / FS;
39+ out[at + i] += Math.exp(-t / tau) * Math.sin(2 * Math.PI * f * t) * 0.7;
40+ }
41+ },
42+ },
43+ {
44+ name: 'demo burst', color: '#3dbb6e',
45+ gen: (out, at) => {
46+ const tau = 0.012 * (1 + 0.2 * (rng() - 0.5));
47+ for (let i = 0; i < burstBase.length && at + i < out.length; i++) {
48+ const t = i / FS;
49+ out[at + i] += Math.exp(-t / tau) * (burstBase[i] + 0.15 * (rng() * 2 - 1)) * 0.6;
50+ }
51+ },
52+ },
53+ ];
54+
55+ return kinds.map((kind) => {
56+ const gap = 0.3;
57+ const n = Math.round((0.5 + eventsPerType * gap) * FS);
58+ const samples = new Float32Array(n);
59+ for (let i = 0; i < n; i++) samples[i] = (rng() * 2 - 1) * 0.002;
60+ for (let e = 0; e < eventsPerType; e++) {
61+ const at = Math.round((0.25 + e * gap + 0.02 * (rng() - 0.5)) * FS);
62+ kind.gen(samples, at);
63+ }
64+ const sens = 0.6;
65+ const onsets = detectOnsets(samples, FS, sens);
66+ const meta: ClipMeta = { id: uid(), sens, onsets, excluded: [] };
67+ return { typeName: kind.name, color: kind.color, samples, meta };
68+ });
69+}
70+
71+export function demoTypes(clips: DemoClip[]): EventType[] {
72+ return clips.map((c) => ({ id: uid(), name: c.typeName, color: c.color, clips: [c.meta] }));
73+}
src/export.tsadded+77−0View file
@@ -0,0 +1,77 @@
1+/**
2+ * Whole-project export and import as one JSON file: event types with
3+ * their clip audio (base64), algorithm sources, and the benchmark
4+ * configuration. Benchmark results are deliberately left out; they are
5+ * reproducible from the config and the seed.
6+ */
7+import type { AlgorithmDef } from './bench/types.ts';
8+import type { BenchConfig, ClipMeta, EventType } from './store.ts';
9+
10+export interface ProjectFile {
11+ format: 'audio-spike-sorting';
12+ version: 1;
13+ types: (EventType & { clipAudio: Record<string, { b64: string; sr: number }> })[];
14+ config: BenchConfig;
15+ algorithms: AlgorithmDef[];
16+}
17+
18+function f32ToB64(x: Float32Array): string {
19+ const bytes = new Uint8Array(x.buffer, x.byteOffset, x.byteLength);
20+ let s = '';
21+ const CHUNK = 0x8000;
22+ for (let i = 0; i < bytes.length; i += CHUNK) {
23+ s += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
24+ }
25+ return btoa(s);
26+}
27+
28+function b64ToF32(b64: string): Float32Array {
29+ const bin = atob(b64);
30+ const bytes = new Uint8Array(bin.length);
31+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
32+ return new Float32Array(bytes.buffer);
33+}
34+
35+export function exportProject(
36+ types: EventType[],
37+ config: BenchConfig,
38+ algorithms: AlgorithmDef[],
39+ getAudio: (clipId: string) => { samples: Float32Array; sr: number } | null,
40+): string {
41+ const doc: ProjectFile = {
42+ format: 'audio-spike-sorting',
43+ version: 1,
44+ types: types.map((t) => {
45+ const clipAudio: Record<string, { b64: string; sr: number }> = {};
46+ for (const c of t.clips) {
47+ const a = getAudio(c.id);
48+ if (a) clipAudio[c.id] = { b64: f32ToB64(a.samples), sr: a.sr };
49+ }
50+ return { ...t, clipAudio };
51+ }),
52+ config,
53+ algorithms,
54+ };
55+ return JSON.stringify(doc);
56+}
57+
58+export interface ImportedProject {
59+ types: EventType[];
60+ config: BenchConfig;
61+ algorithms: AlgorithmDef[];
62+ clipAudio: { id: string; samples: Float32Array; sr: number }[];
63+}
64+
65+export function importProject(json: string): ImportedProject {
66+ const doc = JSON.parse(json) as ProjectFile;
67+ if (doc.format !== 'audio-spike-sorting') throw new Error('not an audio-spike-sorting project file');
68+ const clipAudio: ImportedProject['clipAudio'] = [];
69+ const types: EventType[] = doc.types.map((t) => {
70+ for (const [id, a] of Object.entries(t.clipAudio ?? {})) {
71+ clipAudio.push({ id, samples: b64ToF32(a.b64), sr: a.sr });
72+ }
73+ const clips: ClipMeta[] = t.clips;
74+ return { id: t.id, name: t.name, color: t.color, clips };
75+ });
76+ return { types, config: doc.config, algorithms: doc.algorithms, clipAudio };
77+}
src/m-raw.d.tsadded+1−0View file
@@ -0,0 +1 @@
1+declare module "*.m?raw" { const src: string; export default src; }
src/main.tsadded+736−0View file
@@ -0,0 +1,736 @@
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+ */
8+import { Recorder, type Clip } from './audio/recorder.ts';
9+import { detectOnsets } from './analysis/onsets.ts';
10+import { BUILTIN_ALGORITHMS, NEW_ALGORITHM_TEMPLATE } from './algorithms/index.ts';
11+import { extractLibrary, snippetCounts } from './bench/snippets.ts';
12+import { BenchmarkRunner } from './bench/runner.ts';
13+import { FS, type AlgorithmDef, type RunResult } from './bench/types.ts';
14+import { exportProject, importProject } from './export.ts';
15+import {
16+ clearAll, deleteClipAudio, getBenchmark, getClipAudio, loadDoc, putBenchmark, putClipAudio,
17+ saveDoc, DEFAULT_CONFIG, type BenchConfig, type EventType, type StoredBenchmark,
18+} from './store.ts';
19+import { WaveView, type Marker } from './ui/waveform.ts';
20+import { Explorer } from './ui/explorer.ts';
21+
22+const PALETTE = ['#e4573d', '#3d7de4', '#3dbb6e', '#d9a520', '#9061d9', '#d95fa8', '#4fb8c9', '#96a13a'];
23+
24+const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
25+const uid = () => Math.random().toString(36).slice(2, 10);
26+
27+// ---- state ------------------------------------------------------------------
28+
29+const 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.
32+for (const b of BUILTIN_ALGORITHMS) {
33+ if (!doc.algorithms.some((a) => a.id === b.id)) doc.algorithms.push({ ...b });
34+}
35+const clipAudio = new Map<string, { samples: Float32Array; sr: number }>();
36+let bench: StoredBenchmark | null = null;
37+
38+let actx: AudioContext | null = null;
39+const audio = (): AudioContext => (actx ??= new AudioContext());
40+let recorder: Recorder | null = null;
41+let recTypeId: string | null = null;
42+let meterTimer = 0;
43+let edit: { typeId: string; clipId: string } | null = null;
44+let editingAlgoId: string | null = null;
45+const runner = new BenchmarkRunner();
46+
47+function save(): void { saveDoc(doc); }
48+function status(msg: string): void { $('status').textContent = msg; }
49+function typeById(id: string): EventType | undefined { return doc.types.find((t) => t.id === id); }
50+const getAudio = (clipId: string) => clipAudio.get(clipId) ?? null;
51+
52+// ---- audition ----------------------------------------------------------------
53+
54+function 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+}
65+
66+// ---- event types ---------------------------------------------------------------
67+
68+function 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';
75+
76+ const swatch = document.createElement('div');
77+ swatch.className = 'swatch';
78+ swatch.style.background = type.color;
79+ row.appendChild(swatch);
80+
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);
86+
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);
92+
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);
99+
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+ }
106+
107+ const spacer = document.createElement('span');
108+ spacer.className = 'spacer';
109+ row.appendChild(spacer);
110+
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);
122+
123+ list.appendChild(row);
124+ });
125+}
126+
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+};
136+
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+};
150+
151+// ---- recording ---------------------------------------------------------------
152+
153+async 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+}
170+
171+async 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+}
200+
201+// ---- clip editor ---------------------------------------------------------------
202+
203+const waveView = new WaveView($('clip-wave') as HTMLCanvasElement);
204+
205+function 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+}
212+
213+function 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+}
217+
218+function 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+}
237+
238+function closeClipEditor(): void {
239+ edit = null;
240+ $('clip-editor').hidden = true;
241+}
242+
243+function 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+}
250+
251+waveView.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+};
257+waveView.onSelectionChange = () => updateClipCount();
258+
259+($('clip-select') as HTMLSelectElement).onchange = (e) => {
260+ if (edit) openClipEditor(edit.typeId, (e.target as HTMLSelectElement).value);
261+};
262+
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+};
274+
275+function 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+}
289+
290+$('clip-exclude').onclick = toggleExcludeSelection;
291+document.addEventListener('keydown', (e) => {
292+ if (e.key.toLowerCase() === 'x' && edit && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)) {
293+ toggleExcludeSelection();
294+ }
295+});
296+
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+};
309+
310+$('clip-done').onclick = closeClipEditor;
311+
312+// ---- configuration -------------------------------------------------------------
313+
314+interface ConfigField {
315+ key: keyof BenchConfig;
316+ label: string;
317+ min: number; max: number; step: number;
318+}
319+const 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+];
331+
332+function 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+}
356+
357+// ---- algorithms ----------------------------------------------------------------
358+
359+function algoById(id: string): AlgorithmDef | undefined { return doc.algorithms.find((a) => a.id === id); }
360+
361+function 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';
367+
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);
373+
374+ const name = document.createElement('strong');
375+ name.textContent = algo.name;
376+ row.appendChild(name);
377+
378+ if (algo.builtin) {
379+ const tag = document.createElement('span');
380+ tag.className = 'tag';
381+ tag.textContent = 'built-in';
382+ row.appendChild(tag);
383+ }
384+
385+ const spacer = document.createElement('span');
386+ spacer.className = 'spacer';
387+ row.appendChild(spacer);
388+
389+ const editBtn = document.createElement('button');
390+ editBtn.textContent = 'edit';
391+ editBtn.onclick = () => openAlgoEditor(algo.id);
392+ row.appendChild(editBtn);
393+
394+ list.appendChild(row);
395+ });
396+}
397+
398+function 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+}
411+
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+};
419+
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+};
426+
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+};
435+
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+};
446+
447+$('algo-close').onclick = () => {
448+ editingAlgoId = null;
449+ $('algo-editor').hidden = true;
450+};
451+
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+};
464+
465+// ---- benchmark -----------------------------------------------------------------
466+
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+ }
476+
477+ ($('run-bench') as HTMLButtonElement).disabled = true;
478+ $('cancel-bench').hidden = false;
479+ const progress = $('bench-progress') as HTMLProgressElement;
480+ progress.hidden = false;
481+
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+};
517+
518+$('cancel-bench').onclick = () => runner.cancel();
519+
520+// ---- results -------------------------------------------------------------------
521+
522+function mean(v: number[]): number { return v.length ? v.reduce((a, b) => a + b, 0) / v.length : 0; }
523+function 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+}
528+
529+function renderResults(): void {
530+ const host = $('results');
531+ host.innerHTML = '';
532+ if (!bench) return;
533+ const b = bench;
534+
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>`;
540+
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);
559+
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+ }
601+
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+}
613+
614+// ---- explorer -------------------------------------------------------------------
615+
616+const explorer = new Explorer($('explorer') as HTMLCanvasElement);
617+explorer.onHover = (text) => { $('explore-hover').textContent = text; };
618+explorer.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+};
624+
625+function currentExplorerRun() {
626+ if (!bench) return null;
627+ const ri = Number(($('explore-run') as HTMLSelectElement).value);
628+ return bench.runs[ri] ?? null;
629+}
630+
631+function populateExplorerControls(): void {
632+ const runSel = $('explore-run') as HTMLSelectElement;
633+ const algoSel = $('explore-algo') as HTMLSelectElement;
634+ runSel.innerHTML = '';
635+ algoSel.innerHTML = '';
636+ if (!bench) { updateExplorer(); return; }
637+ bench.runs.forEach((r, i) => {
638+ const o = document.createElement('option');
639+ o.value = String(i);
640+ o.textContent = `run ${i + 1} (seed ${r.seed})`;
641+ runSel.appendChild(o);
642+ });
643+ for (const a of bench.algos) {
644+ const o = document.createElement('option');
645+ o.value = a.id;
646+ o.textContent = a.name;
647+ algoSel.appendChild(o);
648+ }
649+ updateExplorer();
650+}
651+
652+function updateExplorer(): void {
653+ if (!bench) { explorer.set(null); return; }
654+ const ri = Number(($('explore-run') as HTMLSelectElement).value || '0');
655+ const algoId = ($('explore-algo') as HTMLSelectElement).value;
656+ const run = bench.runs[ri];
657+ const result = bench.results.find((r) => r.runIndex === ri && r.algorithmId === algoId);
658+ if (!run || !result) { explorer.set(null); return; }
659+ explorer.set({
660+ x: run.x,
661+ fs: FS,
662+ names: bench.names,
663+ colors: bench.colors,
664+ events: result.score.events,
665+ });
666+}
667+
668+($('explore-run') as HTMLSelectElement).onchange = updateExplorer;
669+($('explore-algo') as HTMLSelectElement).onchange = updateExplorer;
670+
671+// ---- export / import / reset -----------------------------------------------------
672+
673+$('export').onclick = () => {
674+ const json = exportProject(doc.types, doc.config, doc.algorithms, getAudio);
675+ const blob = new Blob([json], { type: 'application/json' });
676+ const a = document.createElement('a');
677+ a.href = URL.createObjectURL(blob);
678+ a.download = 'audio-spike-sorting-project.json';
679+ a.click();
680+ URL.revokeObjectURL(a.href);
681+};
682+
683+$('import').onclick = () => ($('import-file') as HTMLInputElement).click();
684+($('import-file') as HTMLInputElement).onchange = async (e) => {
685+ const file = (e.target as HTMLInputElement).files?.[0];
686+ if (!file) return;
687+ try {
688+ const imported = importProject(await file.text());
689+ if (!confirm('Importing replaces the current project. Continue?')) return;
690+ for (const t of doc.types) for (const c of t.clips) { await deleteClipAudio(c.id); }
691+ clipAudio.clear();
692+ doc.types = imported.types;
693+ doc.config = { ...DEFAULT_CONFIG, ...imported.config };
694+ doc.algorithms = imported.algorithms;
695+ for (const b of BUILTIN_ALGORITHMS) {
696+ if (!doc.algorithms.some((a) => a.id === b.id)) doc.algorithms.push({ ...b });
697+ }
698+ for (const a of imported.clipAudio) {
699+ clipAudio.set(a.id, { samples: a.samples, sr: a.sr });
700+ await putClipAudio(a.id, a.samples, a.sr);
701+ }
702+ save();
703+ closeClipEditor();
704+ renderTypes(); renderConfig(); renderAlgos();
705+ status(`imported ${imported.types.length} event types and ${imported.algorithms.length} algorithms`);
706+ } catch (err) {
707+ status(`import failed: ${(err as Error).message}`);
708+ } finally {
709+ ($('import-file') as HTMLInputElement).value = '';
710+ }
711+};
712+
713+$('reset-all').onclick = async () => {
714+ if (!confirm('Forget all event types, recordings, algorithms, and results?')) return;
715+ await clearAll();
716+ location.reload();
717+};
718+
719+// ---- startup ---------------------------------------------------------------------
720+
721+async function startup(): Promise<void> {
722+ for (const t of doc.types) {
723+ for (const c of t.clips) {
724+ const a = await getClipAudio(c.id);
725+ if (a) clipAudio.set(c.id, a);
726+ }
727+ }
728+ bench = await getBenchmark();
729+ renderTypes();
730+ renderConfig();
731+ renderAlgos();
732+ renderResults();
733+ populateExplorerControls();
734+}
735+
736+void startup();
src/store.tsadded+132−0View file
@@ -0,0 +1,132 @@
1+/**
2+ * Persistence. Small structured state lives in localStorage: event types
3+ * with their clips' onset lists, algorithm sources, and the benchmark
4+ * configuration. Bulk audio lives in IndexedDB: clip audio, and the last
5+ * benchmark's synthesized recordings so the explorer can reopen them
6+ * after a reload. The stores can drift only in harmless directions: a
7+ * clip whose audio is missing simply stops contributing snippets.
8+ */
9+import type { AlgorithmDef, RunResult, SynthConfig } from './bench/types.ts';
10+
11+export interface ClipMeta {
12+ id: string;
13+ sens: number; // the sensitivity the onsets were detected with
14+ onsets: number[]; // seconds
15+ excluded: number[]; // indices into onsets
16+}
17+
18+export interface EventType {
19+ id: string;
20+ name: string;
21+ color: string;
22+ clips: ClipMeta[];
23+}
24+
25+export interface BenchConfig extends SynthConfig {
26+ preMs: number;
27+ postMs: number;
28+ runs: number;
29+ toleranceMs: number;
30+}
31+
32+export const DEFAULT_CONFIG: BenchConfig = {
33+ durationS: 30,
34+ eventsPerType: 30,
35+ gainMinDb: -12,
36+ overlap: 0.2,
37+ trainFraction: 0.3,
38+ seed: 1,
39+ preMs: 20,
40+ postMs: 180,
41+ runs: 5,
42+ toleranceMs: 20,
43+};
44+
45+const KEY = 'audio-spike-sorting/v1';
46+
47+interface Doc {
48+ types: EventType[];
49+ config: BenchConfig;
50+ algorithms: AlgorithmDef[];
51+}
52+
53+export function loadDoc(): Doc {
54+ try {
55+ const raw = localStorage.getItem(KEY);
56+ if (raw) {
57+ const d = JSON.parse(raw) as Doc;
58+ d.config = { ...DEFAULT_CONFIG, ...d.config };
59+ return d;
60+ }
61+ } catch { /* fall through */ }
62+ return { types: [], config: { ...DEFAULT_CONFIG }, algorithms: [] };
63+}
64+
65+export function saveDoc(doc: Doc): void {
66+ localStorage.setItem(KEY, JSON.stringify(doc));
67+}
68+
69+// ---- bulk data in IndexedDB -------------------------------------------------
70+
71+const STORES = ['clips', 'bench'] as const;
72+
73+let dbPromise: Promise<IDBDatabase> | null = null;
74+function db(): Promise<IDBDatabase> {
75+ return (dbPromise ??= new Promise((resolve, reject) => {
76+ const req = indexedDB.open('audio-spike-sorting', 1);
77+ req.onupgradeneeded = () => {
78+ for (const s of STORES) {
79+ if (!req.result.objectStoreNames.contains(s)) req.result.createObjectStore(s);
80+ }
81+ };
82+ req.onsuccess = () => resolve(req.result);
83+ req.onerror = () => reject(req.error);
84+ }));
85+}
86+
87+function op<T>(store: string, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
88+ return db().then((d) => new Promise<T>((resolve, reject) => {
89+ const req = fn(d.transaction(store, mode).objectStore(store));
90+ req.onsuccess = () => resolve(req.result);
91+ req.onerror = () => reject(req.error);
92+ }));
93+}
94+
95+export function putClipAudio(id: string, samples: Float32Array, sr: number): Promise<unknown> {
96+ return op('clips', 'readwrite', (s) => s.put({ samples, sr }, id));
97+}
98+export function getClipAudio(id: string): Promise<{ samples: Float32Array; sr: number } | null> {
99+ return op<{ samples: Float32Array; sr: number } | undefined>('clips', 'readonly', (s) => s.get(id))
100+ .then((r) => r ?? null);
101+}
102+export function deleteClipAudio(id: string): Promise<unknown> {
103+ return op('clips', 'readwrite', (s) => s.delete(id));
104+}
105+
106+/** The last completed benchmark, everything the explorer and the results
107+ * tables need to come back after a reload. */
108+export interface StoredBenchmark {
109+ when: number;
110+ names: string[];
111+ colors: string[];
112+ algos: { id: string; name: string }[];
113+ config: BenchConfig;
114+ runs: {
115+ seed: number;
116+ x: Float32Array;
117+ truth: { t: number; typeIndex: number; gain: number }[];
118+ }[];
119+ results: RunResult[];
120+}
121+
122+export function putBenchmark(b: StoredBenchmark): Promise<unknown> {
123+ return op('bench', 'readwrite', (s) => s.put(b, 'last'));
124+}
125+export function getBenchmark(): Promise<StoredBenchmark | null> {
126+ return op<StoredBenchmark | undefined>('bench', 'readonly', (s) => s.get('last')).then((r) => r ?? null);
127+}
128+
129+export async function clearAll(): Promise<void> {
130+ localStorage.removeItem(KEY);
131+ for (const s of STORES) await op(s, 'readwrite', (st) => st.clear());
132+}
src/ui/explorer.tsadded+290−0View file
@@ -0,0 +1,290 @@
1+/**
2+ * The dataset explorer: one synthesized recording with ground truth above
3+ * the waveform and one algorithm's detections below it. Every event's
4+ * fate is visible at a glance: a correct detection is a filled marker in
5+ * its type's color, a misclassification is filled in the predicted color
6+ * with a red ring, a false positive is a red cross, and a missed truth
7+ * event is a hollow red-edged circle in the truth lanes. Wheel zooms
8+ * around the cursor, dragging pans, and a click auditions the audio
9+ * around that time.
10+ */
11+import type { ScoredEvent } from '../bench/types.ts';
12+
13+export interface ExplorerData {
14+ x: Float32Array;
15+ fs: number;
16+ names: string[];
17+ colors: string[];
18+ events: ScoredEvent[];
19+}
20+
21+const BG = '#16181d';
22+const WAVE = '#5b8dbf';
23+const LANE_H = 18;
24+const WAVE_H = 110;
25+const AXIS_H = 22;
26+const LABEL_W = 64;
27+
28+export class Explorer {
29+ private canvas: HTMLCanvasElement;
30+ private data: ExplorerData | null = null;
31+ private t0 = 0;
32+ private t1 = 1;
33+ private drag: { x: number; t0: number; t1: number; moved: boolean } | null = null;
34+ onAudition: ((t: number) => void) | null = null;
35+ onHover: ((text: string) => void) | null = null;
36+
37+ constructor(canvas: HTMLCanvasElement) {
38+ this.canvas = canvas;
39+ canvas.addEventListener('wheel', (e) => this.wheel(e), { passive: false });
40+ canvas.addEventListener('pointerdown', (e) => this.pointerDown(e));
41+ canvas.addEventListener('pointermove', (e) => this.pointerMove(e));
42+ canvas.addEventListener('pointerup', (e) => this.pointerUp(e));
43+ new ResizeObserver(() => this.draw()).observe(canvas);
44+ }
45+
46+ set(data: ExplorerData | null): void {
47+ this.data = data;
48+ if (data) { this.t0 = 0; this.t1 = data.x.length / data.fs; }
49+ this.canvas.style.height = `${this.height()}px`;
50+ this.draw();
51+ }
52+
53+ private height(): number {
54+ const n = this.data ? this.data.names.length : 0;
55+ return n * LANE_H * 2 + WAVE_H + AXIS_H + 8;
56+ }
57+
58+ private duration(): number {
59+ return this.data ? this.data.x.length / this.data.fs : 1;
60+ }
61+
62+ private xToT(px: number): number {
63+ const w = this.canvas.getBoundingClientRect().width - LABEL_W;
64+ return this.t0 + ((px - LABEL_W) / Math.max(w, 1)) * (this.t1 - this.t0);
65+ }
66+
67+ private tToX(t: number): number {
68+ const w = this.canvas.getBoundingClientRect().width - LABEL_W;
69+ return LABEL_W + ((t - this.t0) / (this.t1 - this.t0)) * w;
70+ }
71+
72+ private wheel(e: WheelEvent): void {
73+ if (!this.data) return;
74+ e.preventDefault();
75+ const t = this.xToT(e.clientX - this.canvas.getBoundingClientRect().left);
76+ const f = Math.exp(e.deltaY * 0.002);
77+ const span = Math.min(this.duration(), Math.max(0.02, (this.t1 - this.t0) * f));
78+ const frac = (t - this.t0) / (this.t1 - this.t0);
79+ this.t0 = t - frac * span;
80+ this.t1 = this.t0 + span;
81+ this.clampView();
82+ this.draw();
83+ }
84+
85+ private clampView(): void {
86+ const dur = this.duration();
87+ const span = this.t1 - this.t0;
88+ if (this.t0 < 0) { this.t0 = 0; this.t1 = span; }
89+ if (this.t1 > dur) { this.t1 = dur; this.t0 = Math.max(0, dur - span); }
90+ }
91+
92+ private pointerDown(e: PointerEvent): void {
93+ if (!this.data) return;
94+ const x = e.clientX - this.canvas.getBoundingClientRect().left;
95+ this.drag = { x, t0: this.t0, t1: this.t1, moved: false };
96+ this.canvas.setPointerCapture(e.pointerId);
97+ }
98+
99+ private pointerMove(e: PointerEvent): void {
100+ if (!this.data) return;
101+ const rect = this.canvas.getBoundingClientRect();
102+ const x = e.clientX - rect.left;
103+ if (this.drag) {
104+ const w = rect.width - LABEL_W;
105+ const dt = ((this.drag.x - x) / Math.max(w, 1)) * (this.drag.t1 - this.drag.t0);
106+ if (Math.abs(this.drag.x - x) > 3) this.drag.moved = true;
107+ this.t0 = this.drag.t0 + dt;
108+ this.t1 = this.drag.t1 + dt;
109+ this.clampView();
110+ this.draw();
111+ return;
112+ }
113+ const hit = this.eventAt(x, e.clientY - rect.top);
114+ this.onHover?.(hit ? this.describe(hit) : '');
115+ }
116+
117+ private pointerUp(e: PointerEvent): void {
118+ if (!this.drag) return;
119+ const moved = this.drag.moved;
120+ this.drag = null;
121+ if (this.canvas.hasPointerCapture(e.pointerId)) this.canvas.releasePointerCapture(e.pointerId);
122+ if (!moved) {
123+ const x = e.clientX - this.canvas.getBoundingClientRect().left;
124+ if (x > LABEL_W) this.onAudition?.(this.xToT(x));
125+ }
126+ }
127+
128+ private eventAt(px: number, _py: number): ScoredEvent | null {
129+ if (!this.data) return null;
130+ const tol = ((this.t1 - this.t0) / (this.canvas.getBoundingClientRect().width - LABEL_W)) * 6;
131+ let best: ScoredEvent | null = null;
132+ let bestDt = tol;
133+ for (const ev of this.data.events) {
134+ const t = ev.truth?.t ?? ev.det?.t;
135+ if (t === undefined) continue;
136+ const dt = Math.abs(t - this.xToT(px));
137+ if (dt < bestDt) { best = ev; bestDt = dt; }
138+ }
139+ return best;
140+ }
141+
142+ private describe(ev: ScoredEvent): string {
143+ const d = this.data!;
144+ const name = (i: number | undefined) => (i !== undefined && i >= 0 && i < d.names.length ? d.names[i] : '?');
145+ const at = (t: number | undefined) => (t !== undefined ? `${t.toFixed(3)} s` : '?');
146+ switch (ev.kind) {
147+ case 'correct':
148+ return `correct: ${name(ev.truth?.typeIndex)} at ${at(ev.truth?.t)} (detected ${at(ev.det?.t)})`;
149+ case 'misclassified':
150+ return `misclassified: ${name(ev.truth?.typeIndex)} at ${at(ev.truth?.t)} detected as ${name(ev.det?.typeIndex)}`;
151+ case 'missed':
152+ return `missed: ${name(ev.truth?.typeIndex)} at ${at(ev.truth?.t)} (gain ${ev.truth ? (20 * Math.log10(ev.truth.gain)).toFixed(1) : '?'} dB)`;
153+ case 'falsePositive':
154+ return `false positive: reported ${name(ev.det?.typeIndex)} at ${at(ev.det?.t)}`;
155+ }
156+ }
157+
158+ draw(): void {
159+ const c = this.canvas;
160+ const dpr = devicePixelRatio || 1;
161+ const w = c.clientWidth, h = this.height();
162+ if (w === 0) return;
163+ if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) {
164+ c.width = Math.round(w * dpr);
165+ c.height = Math.round(h * dpr);
166+ }
167+ const g = c.getContext('2d')!;
168+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
169+ g.fillStyle = BG;
170+ g.fillRect(0, 0, w, h);
171+ if (!this.data) {
172+ g.fillStyle = 'rgba(255,255,255,0.4)';
173+ g.font = '13px system-ui';
174+ g.fillText('run a benchmark, then pick a run and an algorithm', 12, 24);
175+ return;
176+ }
177+ const d = this.data;
178+ const nTypes = d.names.length;
179+ const truthTop = 0;
180+ const waveTop = nTypes * LANE_H;
181+ const detTop = waveTop + WAVE_H;
182+ const axisTop = detTop + nTypes * LANE_H;
183+
184+ // lane labels and separators
185+ g.font = '10px system-ui';
186+ for (let k = 0; k < nTypes; k++) {
187+ g.fillStyle = 'rgba(255,255,255,0.06)';
188+ if (k % 2 === 0) {
189+ g.fillRect(0, truthTop + k * LANE_H, w, LANE_H);
190+ g.fillRect(0, detTop + k * LANE_H, w, LANE_H);
191+ }
192+ g.fillStyle = d.colors[k];
193+ g.fillText(d.names[k].slice(0, 9), 4, truthTop + k * LANE_H + 12);
194+ g.fillText(d.names[k].slice(0, 9), 4, detTop + k * LANE_H + 12);
195+ }
196+ g.fillStyle = 'rgba(255,255,255,0.35)';
197+ g.textAlign = 'right';
198+ g.fillText('ground truth', w - 6, truthTop + 12);
199+ g.fillText('detections', w - 6, detTop + 12);
200+ g.textAlign = 'left';
201+
202+ // waveform over the visible window
203+ const s0 = Math.max(0, Math.floor(this.t0 * d.fs));
204+ const s1 = Math.min(d.x.length, Math.ceil(this.t1 * d.fs));
205+ const plotW = w - LABEL_W;
206+ const mid = waveTop + WAVE_H / 2;
207+ let peak = 1e-6;
208+ for (let i = s0; i < s1; i++) { const a = Math.abs(d.x[i]); if (a > peak) peak = a; }
209+ const yScale = (WAVE_H / 2 - 4) / peak;
210+ g.strokeStyle = WAVE;
211+ g.lineWidth = 1;
212+ g.beginPath();
213+ for (let px = 0; px < plotW; px++) {
214+ const i0 = s0 + Math.floor((px / plotW) * (s1 - s0));
215+ const i1 = s0 + Math.floor(((px + 1) / plotW) * (s1 - s0));
216+ let lo = 0, hi = 0;
217+ for (let i = i0; i < Math.max(i1, i0 + 1); i++) {
218+ if (d.x[i] < lo) lo = d.x[i];
219+ if (d.x[i] > hi) hi = d.x[i];
220+ }
221+ g.moveTo(LABEL_W + px + 0.5, mid - hi * yScale - 0.5);
222+ g.lineTo(LABEL_W + px + 0.5, mid - lo * yScale + 0.5);
223+ }
224+ g.stroke();
225+
226+ // events
227+ const laneY = (top: number, k: number) => top + k * LANE_H + LANE_H / 2;
228+ for (const ev of d.events) {
229+ // truth side
230+ if (ev.truth) {
231+ const x = this.tToX(ev.truth.t);
232+ if (x >= LABEL_W - 8 && x <= w + 8) {
233+ const y = laneY(truthTop, ev.truth.typeIndex);
234+ if (ev.kind === 'missed') {
235+ g.strokeStyle = '#e4573d';
236+ g.lineWidth = 1.6;
237+ g.beginPath(); g.arc(x, y, 4.5, 0, 2 * Math.PI); g.stroke();
238+ } else {
239+ g.fillStyle = d.colors[ev.truth.typeIndex];
240+ g.globalAlpha = ev.kind === 'misclassified' ? 0.9 : 0.9;
241+ g.beginPath(); g.arc(x, y, 4, 0, 2 * Math.PI); g.fill();
242+ g.globalAlpha = 1;
243+ }
244+ }
245+ }
246+ // detection side
247+ if (ev.det) {
248+ const x = this.tToX(ev.det.t);
249+ if (x < LABEL_W - 8 || x > w + 8) continue;
250+ const k = ev.det.typeIndex;
251+ if (k < 0 || k >= nTypes) continue;
252+ const y = laneY(detTop, k);
253+ if (ev.kind === 'falsePositive') {
254+ g.strokeStyle = '#e4573d';
255+ g.lineWidth = 1.8;
256+ g.beginPath();
257+ g.moveTo(x - 4, y - 4); g.lineTo(x + 4, y + 4);
258+ g.moveTo(x - 4, y + 4); g.lineTo(x + 4, y - 4);
259+ g.stroke();
260+ } else {
261+ g.fillStyle = d.colors[k];
262+ g.beginPath(); g.arc(x, y, 4, 0, 2 * Math.PI); g.fill();
263+ if (ev.kind === 'misclassified') {
264+ g.strokeStyle = '#e4573d';
265+ g.lineWidth = 1.8;
266+ g.beginPath(); g.arc(x, y, 6, 0, 2 * Math.PI); g.stroke();
267+ }
268+ }
269+ }
270+ }
271+
272+ // time axis
273+ g.fillStyle = 'rgba(255,255,255,0.5)';
274+ g.font = '10px system-ui';
275+ const span = this.t1 - this.t0;
276+ const step = niceStep(span / Math.max(2, plotW / 90));
277+ const dec = Math.max(0, -Math.floor(Math.log10(step) + 1e-9));
278+ for (let t = Math.ceil(this.t0 / step) * step; t <= this.t1; t += step) {
279+ const x = this.tToX(t);
280+ g.fillRect(x, axisTop, 1, 4);
281+ g.fillText(`${t.toFixed(dec)} s`, x + 3, axisTop + 14);
282+ }
283+ }
284+}
285+
286+function niceStep(raw: number): number {
287+ const p = Math.pow(10, Math.floor(Math.log10(raw)));
288+ for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p;
289+ return 10 * p;
290+}
src/ui/waveform.tsadded+181−0View file
@@ -0,0 +1,181 @@
1+/**
2+ * The training-clip editor's view: one clip as a waveform with a marker
3+ * per detected onset. Click selects one marker and auditions it;
4+ * shift- or ctrl-click toggles a marker in the selection; dragging over
5+ * empty space sweeps a rubber-band selection. Excluded markers draw
6+ * hollow. Exclusion itself is the owner's business (a button or the X
7+ * key); this view only manages what is selected.
8+ */
9+import type { Clip } from '../audio/recorder.ts';
10+
11+export interface Marker {
12+ t: number;
13+ excluded: boolean;
14+}
15+
16+const BG = '#16181d';
17+const WAVE = '#5b8dbf';
18+
19+export class WaveView {
20+ private canvas: HTMLCanvasElement;
21+ private clip: Clip | null = null;
22+ private markers: Marker[] = [];
23+ private color = '#e4573d';
24+ private band: { x0: number; x1: number; base: Set<number>; moved: boolean; additive: boolean } | null = null;
25+ selection = new Set<number>();
26+ onAudition: ((index: number) => void) | null = null;
27+ onSelectionChange: (() => void) | null = null;
28+
29+ constructor(canvas: HTMLCanvasElement) {
30+ this.canvas = canvas;
31+ canvas.addEventListener('pointerdown', (e) => this.pointerDown(e));
32+ canvas.addEventListener('pointermove', (e) => this.pointerMove(e));
33+ canvas.addEventListener('pointerup', (e) => this.pointerUp(e));
34+ new ResizeObserver(() => this.draw()).observe(canvas);
35+ }
36+
37+ set(clip: Clip | null, markers: Marker[], color: string): void {
38+ this.clip = clip;
39+ this.markers = markers;
40+ this.color = color;
41+ this.selection.clear();
42+ this.draw();
43+ }
44+
45+ /** Update markers without dropping the selection (indices unchanged). */
46+ updateMarkers(markers: Marker[]): void {
47+ this.markers = markers;
48+ this.draw();
49+ }
50+
51+ private markerX(i: number, w: number): number {
52+ const dur = this.clip ? this.clip.samples.length / this.clip.sr : 1;
53+ return (this.markers[i].t / dur) * w;
54+ }
55+
56+ private markerAt(x: number): number {
57+ const w = this.canvas.getBoundingClientRect().width;
58+ let best = -1, bestDx = 8;
59+ for (let i = 0; i < this.markers.length; i++) {
60+ const dx = Math.abs(this.markerX(i, w) - x);
61+ if (dx < bestDx) { best = i; bestDx = dx; }
62+ }
63+ return best;
64+ }
65+
66+ private pointerDown(e: PointerEvent): void {
67+ if (!this.clip) return;
68+ const x = e.clientX - this.canvas.getBoundingClientRect().left;
69+ const additive = e.shiftKey || e.ctrlKey || e.metaKey;
70+ const i = this.markerAt(x);
71+ if (i >= 0) {
72+ if (additive) {
73+ if (this.selection.has(i)) this.selection.delete(i);
74+ else this.selection.add(i);
75+ } else {
76+ this.selection = new Set([i]);
77+ this.onAudition?.(i);
78+ }
79+ this.onSelectionChange?.();
80+ this.draw();
81+ } else {
82+ this.band = { x0: x, x1: x, base: additive ? new Set(this.selection) : new Set(), moved: false, additive };
83+ this.canvas.setPointerCapture(e.pointerId);
84+ }
85+ }
86+
87+ private pointerMove(e: PointerEvent): void {
88+ if (!this.band) return;
89+ const x = e.clientX - this.canvas.getBoundingClientRect().left;
90+ this.band.x1 = x;
91+ if (Math.abs(x - this.band.x0) > 3) this.band.moved = true;
92+ const lo = Math.min(this.band.x0, this.band.x1);
93+ const hi = Math.max(this.band.x0, this.band.x1);
94+ const w = this.canvas.getBoundingClientRect().width;
95+ this.selection = new Set(this.band.base);
96+ for (let i = 0; i < this.markers.length; i++) {
97+ const mx = this.markerX(i, w);
98+ if (mx >= lo && mx <= hi) this.selection.add(i);
99+ }
100+ this.onSelectionChange?.();
101+ this.draw();
102+ }
103+
104+ private pointerUp(e: PointerEvent): void {
105+ if (!this.band) return;
106+ if (!this.band.moved && !this.band.additive && this.selection.size) {
107+ this.selection.clear(); // a plain click on empty space clears
108+ this.onSelectionChange?.();
109+ }
110+ this.band = null;
111+ if (this.canvas.hasPointerCapture(e.pointerId)) this.canvas.releasePointerCapture(e.pointerId);
112+ this.draw();
113+ }
114+
115+ draw(): void {
116+ const c = this.canvas;
117+ const dpr = devicePixelRatio || 1;
118+ const w = c.clientWidth, h = c.clientHeight;
119+ if (w === 0) return;
120+ if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) {
121+ c.width = Math.round(w * dpr);
122+ c.height = Math.round(h * dpr);
123+ }
124+ const g = c.getContext('2d')!;
125+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
126+ g.fillStyle = BG;
127+ g.fillRect(0, 0, w, h);
128+ if (!this.clip) return;
129+
130+ const { samples } = this.clip;
131+ const mid = h / 2;
132+ g.strokeStyle = WAVE;
133+ g.lineWidth = 1;
134+ g.beginPath();
135+ for (let px = 0; px < w; px++) {
136+ const i0 = Math.floor((px / w) * samples.length);
137+ const i1 = Math.floor(((px + 1) / w) * samples.length);
138+ let lo = 0, hi = 0;
139+ for (let i = i0; i < i1; i++) {
140+ if (samples[i] < lo) lo = samples[i];
141+ if (samples[i] > hi) hi = samples[i];
142+ }
143+ g.moveTo(px + 0.5, mid - hi * mid * 0.92 - 0.5);
144+ g.lineTo(px + 0.5, mid - lo * mid * 0.92 + 0.5);
145+ }
146+ g.stroke();
147+
148+ for (let i = 0; i < this.markers.length; i++) {
149+ const m = this.markers[i];
150+ const x = this.markerX(i, w);
151+ const sel = this.selection.has(i);
152+ g.strokeStyle = this.color;
153+ g.lineWidth = sel ? 2.5 : 1.2;
154+ g.globalAlpha = m.excluded ? 0.35 : 1;
155+ g.beginPath();
156+ g.moveTo(x, 2);
157+ g.lineTo(x, h - 2);
158+ g.stroke();
159+ g.beginPath();
160+ g.arc(x, 8, 4.5, 0, 2 * Math.PI);
161+ if (m.excluded) g.stroke();
162+ else { g.fillStyle = this.color; g.fill(); }
163+ if (sel) {
164+ g.strokeStyle = '#fff';
165+ g.lineWidth = 1.5;
166+ g.beginPath();
167+ g.arc(x, 8, 6.5, 0, 2 * Math.PI);
168+ g.stroke();
169+ }
170+ g.globalAlpha = 1;
171+ }
172+
173+ if (this.band) {
174+ const lo = Math.min(this.band.x0, this.band.x1);
175+ g.fillStyle = 'rgba(120, 170, 255, 0.15)';
176+ g.strokeStyle = 'rgba(120, 170, 255, 0.5)';
177+ g.fillRect(lo, 0, Math.abs(this.band.x1 - this.band.x0), h);
178+ g.strokeRect(lo + 0.5, 0.5, Math.abs(this.band.x1 - this.band.x0) - 1, h - 1);
179+ }
180+ }
181+}
tsconfig.jsonadded+16−0View file
@@ -0,0 +1,16 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "bundler",
6+ "allowImportingTsExtensions": true,
7+ "resolveJsonModule": true,
8+ "strict": true,
9+ "noUncheckedIndexedAccess": false,
10+ "noEmit": true,
11+ "skipLibCheck": true,
12+ "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
13+ "types": ["vite/client", "node"]
14+ },
15+ "include": ["src", "scripts"]
16+}
vite.config.tsadded+7−0View file
@@ -0,0 +1,7 @@
1+import { defineConfig } from 'vite';
2+
3+export default defineConfig({
4+ base: './',
5+ build: { target: 'es2022' },
6+ worker: { format: 'es' },
7+});