Download runs as a .zip for offline development: WAVs, ground truth, training snippets, algorithm sources, and a numbl evaluation harness that reproduces the browser scores
20 changed files+764−63
README.mdmodified+15−1View file
@@ -59,6 +59,15 @@ between them.
5959 confusion matrix per algorithm with an extra column for misses and an
6060 extra row for false positives.
6161
62+A completed benchmark can be downloaded as a .zip for offline algorithm
63+development: every run's recording as a float32 WAV, its ground truth as
64+a CSV, the exact training snippets it handed to the algorithms, the
65+algorithm sources, and an evaluation harness (evaluate.m plus tools/)
66+that the numbl CLI executes with `npx numbl run evaluate.m`, printing
67+the same scores as the browser. The archive's README documents the file
68+formats and the algorithm interface, so an algorithm developed against
69+it drops straight back into the app.
70+
6271 The explorer shows any synthesized recording with the ground truth lanes
6372 above the waveform and one algorithm's detections below it. A correct
6473 detection is a filled marker in its type's color, a misclassification is
@@ -102,7 +111,12 @@ npm run build # typecheck and bundle
102111 ```
103112
104113 "Load demo data" creates three synthetic event types so the whole
105-pipeline can be tried without a microphone. Projects persist in the
114+pipeline can be tried without a microphone.
115+
116+`npm run test:offline` exercises the dataset download end to end: it
117+builds the archive's files in node, runs the shipped evaluate.m through
118+the numbl CLI, and requires the printed per-run accuracies to match the
119+in-process scores exactly. Projects persist in the
106120 browser (clip audio in IndexedDB, everything else in localStorage) and
107121 round-trip through Export/Import as a single JSON file, so a recorded
108122 snippet library can be shared or committed.
index.htmlmodified+2−0View file
@@ -134,6 +134,8 @@
134134 <button id="cancel-bench" hidden>Cancel</button>
135135 <progress id="bench-progress" hidden></progress>
136136 <span id="bench-label" class="hint"></span>
137+ <span style="flex:1"></span>
138+ <button id="download-runs" disabled title="all runs with ground truth, training snippets, algorithms, and a numbl evaluation harness">Download runs (.zip)</button>
137139 </div>
138140 <div id="results"></div>
139141
package.jsonmodified+2−1View file
@@ -11,7 +11,8 @@
1111 "dev": "vite",
1212 "build": "tsc --noEmit && vite build",
1313 "test": "vite-node scripts/test-node.ts",
14- "smoke": "vite-node scripts/smoke-browser.ts"
14+ "smoke": "vite-node scripts/smoke-browser.ts",
15+ "test:offline": "vite-node scripts/test-offline.ts"
1516 },
1617 "dependencies": {
1718 "numbl": "^0.4.19"
scripts/synthetic-lib.tsadded+57−0View file
@@ -0,0 +1,57 @@
1+/**
2+ * Synthetic snippet library for the node tests: three event types (low
3+ * ringing tone, high short tone, repeatable noise burst) standing in for
4+ * recorded taps. A tap excites the struck object's impulse response, so
5+ * instances of one type are highly repeatable up to gain and small
6+ * decay/pitch drift; the burst type reuses one fixed noise pattern (the
7+ * "object") rather than fresh noise per instance.
8+ */
9+import { mulberry32 } from '../src/bench/rng.ts';
10+import { FS, type SnippetLibrary } from '../src/bench/types.ts';
11+
12+export const PRE_S = 0.02, POST_S = 0.18;
13+
14+export function makeSyntheticLibrary(perType = 120, seed = 42): SnippetLibrary {
15+ const pre = Math.round(PRE_S * FS), post = Math.round(POST_S * FS);
16+ const snipLen = pre + post;
17+ const rng = mulberry32(seed);
18+
19+ const burstBase = new Float32Array(post);
20+ {
21+ const brng = mulberry32(7);
22+ for (let i = 0; i < post; i++) burstBase[i] = brng() * 2 - 1;
23+ }
24+
25+ function makeSnippet(kind: number): Float32Array {
26+ const s = new Float32Array(snipLen);
27+ const jitF = 1 + 0.01 * (rng() - 0.5);
28+ const jitT = 1 + 0.2 * (rng() - 0.5);
29+ if (kind === 0) {
30+ const f = 300 * jitF, tau = 0.06 * jitT;
31+ for (let i = 0; i < post; i++) {
32+ const t = i / FS;
33+ s[pre + i] = Math.exp(-t / tau) * Math.sin(2 * Math.PI * f * t) * 0.8;
34+ }
35+ } else if (kind === 1) {
36+ const f = 1400 * jitF, tau = 0.025 * jitT;
37+ for (let i = 0; i < post; i++) {
38+ const t = i / FS;
39+ s[pre + i] = Math.exp(-t / tau) * Math.sin(2 * Math.PI * f * t) * 0.8;
40+ }
41+ } else {
42+ const tau = 0.012 * jitT;
43+ for (let i = 0; i < post; i++) {
44+ const t = i / FS;
45+ s[pre + i] = Math.exp(-t / tau) * (burstBase[i] + 0.15 * (rng() * 2 - 1)) * 0.7;
46+ }
47+ }
48+ for (let i = 0; i < snipLen; i++) s[i] += (rng() * 2 - 1) * 0.002;
49+ return s;
50+ }
51+
52+ const lib: SnippetLibrary = { names: ['low', 'high', 'burst'], pre, post, snippets: [] };
53+ for (let k = 0; k < 3; k++) {
54+ for (let e = 0; e < perType; e++) lib.snippets.push({ typeIndex: k, samples: makeSnippet(k) });
55+ }
56+ return lib;
57+}
scripts/test-node.tsmodified+3−52View file
@@ -6,66 +6,17 @@
66 * The matched filter and the kNN sorter should sit far above the naive
77 * floor; the run fails loudly if they do not.
88 */
9-import { mulberry32 } from '../src/bench/rng.ts';
10-import { FS, type SnippetLibrary, type SynthConfig } from '../src/bench/types.ts';
9+import { FS, type SynthConfig } from '../src/bench/types.ts';
1110 import { synthesize } from '../src/bench/synthesize.ts';
1211 import { score } from '../src/bench/score.ts';
1312 import { buildTraining } from '../src/bench/training.ts';
1413 import { runAlgorithm } from '../src/bench/execute.ts';
14+import { makeSyntheticLibrary } from './synthetic-lib.ts';
1515 import matchedFilterSrc from '../src/algorithms/template_matched_filter.m?raw';
1616 import onsetKnnSrc from '../src/algorithms/onset_knn.m?raw';
1717 import naiveSrc from '../src/algorithms/naive_threshold.m?raw';
1818
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-}
19+const lib = makeSyntheticLibrary();
6920
7021 const cfg: SynthConfig = {
7122 durationS: 30,
scripts/test-offline.tsadded+96−0View file
@@ -0,0 +1,96 @@
1+/**
2+ * Round-trip test of the dataset download: build a benchmark in-process,
3+ * write the offline archive's files to a scratch directory (unzipped;
4+ * the zip wrapper is byte bookkeeping tested separately), run the shipped
5+ * evaluate.m through the real numbl CLI, and require the per-run
6+ * accuracies it prints to match the in-process scores exactly. Float32
7+ * WAVs round-trip the samples bit-exactly, so any disagreement is a bug
8+ * in the harness, not noise.
9+ */
10+import { execFileSync } from 'node:child_process';
11+import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
12+import { dirname, join } from 'node:path';
13+import { FS, type SynthConfig } from '../src/bench/types.ts';
14+import { synthesize } from '../src/bench/synthesize.ts';
15+import { buildTraining } from '../src/bench/training.ts';
16+import { runAlgorithm } from '../src/bench/execute.ts';
17+import { score } from '../src/bench/score.ts';
18+import { buildOfflineFiles, type OfflineExportInput } from '../src/export/offline.ts';
19+import { zipStore } from '../src/export/zip.ts';
20+import { makeSyntheticLibrary } from './synthetic-lib.ts';
21+import matchedFilterSrc from '../src/algorithms/template_matched_filter.m?raw';
22+import onsetKnnSrc from '../src/algorithms/onset_knn.m?raw';
23+import naiveSrc from '../src/algorithms/naive_threshold.m?raw';
24+
25+const lib = makeSyntheticLibrary();
26+const cfg: SynthConfig = {
27+ durationS: 20, eventsPerType: 15, gainMinDb: -12,
28+ overlap: 0.2, trainFraction: 0.3, seed: 1,
29+};
30+const NRUNS = 2;
31+const TOL_MS = 20;
32+const algos = [
33+ { name: 'template_matched_filter', source: matchedFilterSrc },
34+ { name: 'onset_knn', source: onsetKnnSrc },
35+ { name: 'naive_threshold', source: naiveSrc },
36+];
37+
38+// in-process benchmark, collecting expected per-run accuracies
39+const runs: OfflineExportInput['runs'] = [];
40+const expected = new Map<string, string[]>();
41+for (let r = 0; r < NRUNS; r++) {
42+ const ds = synthesize(lib, cfg, r);
43+ const training = buildTraining(lib, ds.trainIndices);
44+ runs.push({
45+ x: ds.x,
46+ truth: ds.truth,
47+ training: training.snips.map((s) => ({ data: Float32Array.from(s.data), count: s.count })),
48+ });
49+ for (const a of algos) {
50+ const out = runAlgorithm(a.source, ds.x, FS, training);
51+ const s = score(ds.truth, out.detections, lib.names.length, TOL_MS / 1000);
52+ (expected.get(a.name) ?? expected.set(a.name, []).get(a.name)!).push(s.accuracy.toFixed(3));
53+ }
54+}
55+
56+// write the archive's files to a scratch directory
57+const files = buildOfflineFiles({
58+ names: lib.names,
59+ fs: FS,
60+ pre: lib.pre,
61+ post: lib.post,
62+ toleranceMs: TOL_MS,
63+ config: cfg,
64+ algorithms: algos.map((a) => ({ source: a.source })),
65+ runs,
66+});
67+const zip = zipStore(files);
68+console.log(`archive: ${files.length} files, ${(zip.length / 1e6).toFixed(1)} MB stored`);
69+
70+const dir = join(process.env.TMPDIR ?? '/tmp', `asstest-offline-${process.pid}`);
71+rmSync(dir, { recursive: true, force: true });
72+for (const f of files) {
73+ mkdirSync(join(dir, dirname(f.name)), { recursive: true });
74+ writeFileSync(join(dir, f.name), f.data);
75+}
76+
77+// run the shipped harness through the numbl CLI
78+console.log('running: npx numbl run evaluate.m');
79+const stdout = execFileSync('npx', ['numbl', 'run', 'evaluate.m'], {
80+ cwd: dir, encoding: 'utf8', timeout: 600000,
81+});
82+console.log(stdout);
83+
84+// compare per-run accuracies, algorithm by algorithm
85+let failures = 0;
86+for (const a of algos) {
87+ const block = stdout.split(`=== ${a.name} ===`)[1]?.split('===')[0] ?? '';
88+ const got = [...block.matchAll(/run\s+\d+: accuracy (\d\.\d{3})/g)].map((m) => m[1]);
89+ const want = expected.get(a.name)!;
90+ const ok = got.length === want.length && got.every((g, i) => g === want[i]);
91+ console.log(`${a.name.padEnd(26)} browser=[${want.join(', ')}] offline=[${got.join(', ')}] ${ok ? 'MATCH' : 'MISMATCH'}`);
92+ if (!ok) failures++;
93+}
94+rmSync(dir, { recursive: true, force: true });
95+if (failures) { console.error('OFFLINE FAIL'); process.exit(1); }
96+console.log('OFFLINE PASS');
src/bench/execute.tsmodified+1−6View file
@@ -16,12 +16,7 @@ import { executeCode, RTV } from 'numbl';
1616 import type { RuntimeValue } from 'numbl';
1717 import type { Detection } from './types.ts';
1818 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-}
19+import { extractFunctionName } from './source.ts';
2520
2621 export interface ExecuteOutcome {
2722 detections: Detection[];
src/bench/runner.tsmodified+14−2View file
@@ -22,7 +22,14 @@ export interface BenchmarkProgress {
2222 }
2323
2424 export interface BenchmarkOutput {
25- runs: { seed: number; x: Float32Array; truth: { t: number; typeIndex: number; gain: number }[] }[];
25+ runs: {
26+ seed: number;
27+ x: Float32Array;
28+ truth: { t: number; typeIndex: number; gain: number }[];
29+ /** Per type: the training snippets this run handed to every
30+ * algorithm, concatenated, so an export can reproduce the run. */
31+ training: { data: Float32Array; count: number }[];
32+ }[];
2633 results: RunResult[];
2734 }
2835
@@ -68,8 +75,13 @@ export class BenchmarkRunner {
6875 let done = 0;
6976 for (let r = 0; r < runCount && !this.cancelled; r++) {
7077 const ds = synthesize(lib, cfg, r);
71- runs.push({ seed: cfg.seed + r, x: ds.x, truth: ds.truth });
7278 const training = buildTraining(lib, ds.trainIndices);
79+ runs.push({
80+ seed: cfg.seed + r,
81+ x: ds.x,
82+ truth: ds.truth,
83+ training: training.snips.map((s) => ({ data: Float32Array.from(s.data), count: s.count })),
84+ });
7385 for (const algo of algorithms) {
7486 if (this.cancelled) break;
7587 onProgress({ runIndex: r, runCount, algorithmName: algo.name, done, total });
src/bench/source.tsadded+7−0View file
@@ -0,0 +1,7 @@
1+/** Kept free of any numbl import so the main thread can use it. */
2+
3+/** Name of the first function defined in a .m source, or null for a plain script. */
4+export function extractFunctionName(source: string): string | null {
5+ const m = source.match(/^\s*function\s+(?:\[[^\]]*\]\s*=\s*|\w+\s*=\s*)?([A-Za-z]\w*)\s*\(/m);
6+ return m ? m[1] : null;
7+}
src/export/assets/OFFLINE_README.mdadded+106−0View file
@@ -0,0 +1,106 @@
1+# audio-spike-sorting dataset export
2+
3+This archive is a self-contained benchmark for developing audio event
4+detection and classification algorithms outside the browser. It was
5+exported from the audio-spike-sorting app and contains every synthesized
6+run of one benchmark, with exact ground truth, the training snippets each
7+run handed to the algorithms, the algorithm sources as they stood at
8+export time, and an evaluation harness that reproduces the browser's
9+scores.
10+
11+## Dataset facts
12+
13+- Event types ({{N_TYPES}}): {{TYPE_NAMES}}
14+- Sampling rate: {{FS}} Hz, mono, float32
15+- Runs: {{N_RUNS}}, each an independent synthesis (run r used seed
16+ {{SEED}} + r - 1)
17+- Events per type per run: {{EVENTS_PER_TYPE}}
18+- Snippet window: {{PRE_MS}} ms before the event onset, {{POST_MS}} ms
19+ after ({{SNIP_LEN}} samples; the onset therefore sits {{PRE}} samples
20+ into every training snippet)
21+- Level variation: each placed event scaled by a random gain in
22+ [{{GAIN_MIN_DB}}, 0] dB (0 means events kept their recorded volume)
23+- Overlap fraction: {{OVERLAP}} of events deliberately placed inside
24+ another event's span; the rest keep at least one snippet length of
25+ separation
26+- Training fraction: {{TRAIN_FRACTION}} of each type's snippets went to
27+ the training set, drawn independently per run; the rest were placed
28+- Match tolerance for scoring: {{TOLERANCE_MS}} ms
29+
30+## Layout
31+
32+```
33+README.md this file
34+dataset_info.m the facts above, as a function the harness calls
35+evaluate.m runs and scores algorithms; edit its list
36+algorithms/*.m algorithm sources from the app
37+tools/*.m load_wav, load_csv, score_detections
38+runs/run_01/
39+ recording.wav the synthesized recording (float32, {{FS}} Hz)
40+ ground_truth.csv one row per placed event, no header:
41+ time_sec, label (1-based), gain
42+ training_type_01.wav training snippets for type 1, concatenated;
43+ reshape to [{{SNIP_LEN}} x nSnippets]
44+ ...
45+runs/run_02/ ...
46+```
47+
48+Ground-truth times refer to the event onset, which is {{PRE}} samples
49+after the start of the placed snippet. The WAV files carry a plain
50+44-byte header (IEEE float, format tag 3); audioread in MATLAB, soundfile
51+in Python, and ordinary audio editors read them directly, and
52+tools/load_wav.m reads them with fopen for numbl, which has no audioread.
53+
54+## The algorithm interface
55+
56+An algorithm is one MATLAB-syntax file, algorithms/<name>.m, defining a
57+function whose name matches the file:
58+
59+```matlab
60+function [times, labels] = my_sorter(x, fs, training)
61+% x the recording, column vector at fs Hz
62+% fs sampling rate ({{FS}})
63+% training struct with fields:
64+% snips {1 x nTypes} cell; snips{k} is [{{SNIP_LEN}} x nSnips_k],
65+% one training snippet per column
66+% names {1 x nTypes} cell of type names
67+% pre samples before the event onset within each snippet ({{PRE}})
68+% Return column vectors: event times in seconds and 1-based type labels.
69+times = zeros(0, 1);
70+labels = zeros(0, 1);
71+end
72+```
73+
74+The training snippets are the only labeled data an algorithm may use;
75+the events placed in the recording come from a disjoint pool, so the
76+task includes generalizing across instances, not recognizing exact
77+waveforms.
78+
79+## Evaluating
80+
81+With Node.js installed:
82+
83+```
84+npx numbl run evaluate.m
85+```
86+
87+numbl (https://numbl.org) executes MATLAB-syntax code; no MATLAB license
88+is involved. Add your algorithm's name to the `algorithms` list at the
89+top of evaluate.m and run again. For each algorithm the harness prints
90+per-run accuracy, the mean and standard deviation over runs, total
91+counts of the four error kinds, and a per-type breakdown, matching the
92+browser's report exactly.
93+
94+Scoring: detections are matched to ground truth greedily by time
95+difference within the {{TOLERANCE_MS}} ms tolerance, labels ignored; a
96+matched pair with the right label is correct and with the wrong label a
97+misclassification, unmatched truth events are misses, and unmatched
98+detections are false positives. Accuracy is
99+correct / (correct + misclassified + missed + false positives).
100+tools/score_detections.m is the reference implementation and mirrors the
101+browser harness; an algorithm developed against this archive will score
102+identically when pasted back into the app.
103+
104+Note that numbl does not implement every MATLAB function. The set it
105+does implement is documented at https://numbl.org/docs/builtins; an
106+algorithm that runs here will also run in the app, since both use numbl.
src/export/assets/evaluate.madded+61−0View file
@@ -0,0 +1,61 @@
1+% evaluate.m Run every listed algorithm on every run in runs/ and score
2+% it against the ground truth, printing the same numbers the browser
3+% benchmark reports.
4+%
5+% Usage:
6+% npx numbl run evaluate.m
7+%
8+% To evaluate your own algorithm, add a file algorithms/<name>.m defining
9+% function [times, labels] = <name>(x, fs, training)
10+% and add '<name>' to the list below. See README.md for the interface.
11+
12+algorithms = {{{ALGORITHM_LIST}}};
13+
14+addpath('algorithms');
15+addpath('tools');
16+
17+info = dataset_info();
18+nTypes = numel(info.names);
19+tol = info.tolerance_ms / 1000;
20+
21+for a = 1:numel(algorithms)
22+ name = algorithms{a};
23+ fprintf('\n=== %s ===\n', name);
24+ accs = zeros(info.n_runs, 1);
25+ totals = zeros(1, 4);
26+ per_type = zeros(nTypes, 5);
27+ for r = 1:info.n_runs
28+ run_dir = sprintf('runs/run_%02d', r);
29+ [x, fs] = load_wav(sprintf('%s/recording.wav', run_dir));
30+ gt = load_csv(sprintf('%s/ground_truth.csv', run_dir), 3);
31+ training = struct();
32+ training.names = info.names;
33+ training.pre = info.pre;
34+ snips = cell(1, nTypes);
35+ for k = 1:nTypes
36+ sk = load_wav(sprintf('%s/training_type_%02d.wav', run_dir, k));
37+ snips{k} = reshape(sk, info.snip_len, []);
38+ end
39+ training.snips = snips;
40+ [times, labels] = feval(name, x, fs, training);
41+ sc = score_detections(gt(:, 1), gt(:, 2), times, labels, tol, nTypes);
42+ accs(r) = sc.accuracy;
43+ totals = totals + [sc.correct, sc.misclassified, sc.missed, sc.false_positives];
44+ per_type = per_type + sc.per_type;
45+ fprintf('run %2d: accuracy %.3f (correct %d, misclassified %d, missed %d, false pos %d)\n', ...
46+ r, sc.accuracy, sc.correct, sc.misclassified, sc.missed, sc.false_positives);
47+ end
48+ fprintf('mean accuracy %.3f +/- %.3f | correct %d, misclassified %d, missed %d, false pos %d\n', ...
49+ mean(accs), std(accs), totals(1), totals(2), totals(3), totals(4));
50+ fprintf('per event type (all runs):\n');
51+ for k = 1:nTypes
52+ d = sum(per_type(k, 1:4));
53+ if d > 0
54+ ak = per_type(k, 1) / d;
55+ else
56+ ak = 1;
57+ end
58+ fprintf(' %s: acc %.3f correct %d misclassified %d missed %d false pos %d truth %d\n', ...
59+ info.names{k}, ak, per_type(k, 1), per_type(k, 2), per_type(k, 3), per_type(k, 4), per_type(k, 5));
60+ end
61+end
src/export/assets/load_csv.madded+10−0View file
@@ -0,0 +1,10 @@
1+function m = load_csv(path, ncols)
2+% Read a headerless numeric CSV with ncols columns into a matrix.
3+s = strtrim(fileread(path));
4+if isempty(s)
5+ m = zeros(0, ncols);
6+ return;
7+end
8+v = sscanf(regexprep(s, ',', ' '), '%f');
9+m = reshape(v, ncols, []).';
10+end
src/export/assets/load_wav.madded+14−0View file
@@ -0,0 +1,14 @@
1+function [x, fs] = load_wav(path)
2+% Read a mono float32 WAV as written by audio-spike-sorting: a fixed
3+% 44-byte header (sample rate at byte 24) followed by IEEE float samples.
4+% numbl has no audioread; in real MATLAB, audioread(path) works too.
5+fid = fopen(path, 'r');
6+if fid < 0
7+ error('cannot open %s', path);
8+end
9+fseek(fid, 24, 'bof');
10+fs = fread(fid, 1, 'uint32');
11+fseek(fid, 44, 'bof');
12+x = fread(fid, Inf, 'single');
13+fclose(fid);
14+end
src/export/assets/score_detections.madded+102−0View file
@@ -0,0 +1,102 @@
1+function s = score_detections(truth_t, truth_lab, det_t, det_lab, tol, nTypes)
2+% Compare detections against ground truth, mirroring the browser harness
3+% exactly. Matching ignores labels: candidate pairs within the tolerance
4+% are taken greedily by |dt|, so each truth event and each detection is
5+% used at most once and the closest pairs win. A matched pair with the
6+% right label is correct, with the wrong label a misclassification;
7+% unmatched truth is a miss, an unmatched detection a false positive.
8+%
9+% Returns a struct with counts, accuracy, per_type (nTypes x 5:
10+% correct, misclassified, missed, false positives, truth count), and
11+% confusion ((nTypes+1) x (nTypes+1): row nTypes+1 = false positives,
12+% column nTypes+1 = missed).
13+[det_t, ord] = sort(det_t(:));
14+det_lab = det_lab(:);
15+det_lab = det_lab(ord);
16+truth_t = truth_t(:);
17+truth_lab = truth_lab(:);
18+nT = numel(truth_t);
19+nD = numel(det_t);
20+
21+% candidate pairs within tolerance (truth_t is sorted; walk a window)
22+pti = zeros(0, 1);
23+pdi = zeros(0, 1);
24+pdt = zeros(0, 1);
25+lo = 1;
26+for j = 1:nD
27+ while lo <= nT && truth_t(lo) < det_t(j) - tol
28+ lo = lo + 1;
29+ end
30+ i = lo;
31+ while i <= nT && truth_t(i) <= det_t(j) + tol
32+ pti(end+1, 1) = i;
33+ pdi(end+1, 1) = j;
34+ pdt(end+1, 1) = abs(truth_t(i) - det_t(j));
35+ i = i + 1;
36+ end
37+end
38+
39+[~, order] = sort(pdt);
40+tMatch = zeros(nT, 1);
41+dMatch = zeros(nD, 1);
42+for q = 1:numel(order)
43+ i = pti(order(q));
44+ j = pdi(order(q));
45+ if tMatch(i) == 0 && dMatch(j) == 0
46+ tMatch(i) = j;
47+ dMatch(j) = i;
48+ end
49+end
50+
51+per_type = zeros(nTypes, 5);
52+confusion = zeros(nTypes + 1, nTypes + 1);
53+correct = 0; misclassified = 0; missed = 0; falsepos = 0;
54+
55+for i = 1:nT
56+ k = truth_lab(i);
57+ per_type(k, 5) = per_type(k, 5) + 1;
58+ j = tMatch(i);
59+ if j == 0
60+ missed = missed + 1;
61+ per_type(k, 3) = per_type(k, 3) + 1;
62+ confusion(k, nTypes + 1) = confusion(k, nTypes + 1) + 1;
63+ else
64+ lab = det_lab(j);
65+ if lab >= 1 && lab <= nTypes
66+ confusion(k, lab) = confusion(k, lab) + 1;
67+ end
68+ if lab == k
69+ correct = correct + 1;
70+ per_type(k, 1) = per_type(k, 1) + 1;
71+ else
72+ misclassified = misclassified + 1;
73+ per_type(k, 2) = per_type(k, 2) + 1;
74+ end
75+ end
76+end
77+for j = 1:nD
78+ if dMatch(j) == 0
79+ falsepos = falsepos + 1;
80+ lab = det_lab(j);
81+ if lab >= 1 && lab <= nTypes
82+ per_type(lab, 4) = per_type(lab, 4) + 1;
83+ confusion(nTypes + 1, lab) = confusion(nTypes + 1, lab) + 1;
84+ end
85+ end
86+end
87+
88+denom = correct + misclassified + missed + falsepos;
89+if denom > 0
90+ acc = correct / denom;
91+else
92+ acc = 1;
93+end
94+s = struct();
95+s.correct = correct;
96+s.misclassified = misclassified;
97+s.missed = missed;
98+s.false_positives = falsepos;
99+s.accuracy = acc;
100+s.per_type = per_type;
101+s.confusion = confusion;
102+end
src/export/offline.tsadded+120−0View file
@@ -0,0 +1,120 @@
1+/**
2+ * Builds the offline dataset export: every synthesized run with its
3+ * ground truth and the exact training snippets it used, the algorithm
4+ * sources, and a numbl-runnable evaluation harness whose scoring mirrors
5+ * the browser's. Pure of the DOM so the node test exercises the same
6+ * files the download button ships.
7+ */
8+import { extractFunctionName } from '../bench/source.ts';
9+import { wavFloat32 } from './wav.ts';
10+import evaluateTpl from './assets/evaluate.m?raw';
11+import readmeTpl from './assets/OFFLINE_README.md?raw';
12+import loadWavSrc from './assets/load_wav.m?raw';
13+import loadCsvSrc from './assets/load_csv.m?raw';
14+import scoreSrc from './assets/score_detections.m?raw';
15+
16+export interface OfflineExportInput {
17+ names: string[];
18+ fs: number;
19+ /** Samples before / after the onset in every snippet. */
20+ pre: number;
21+ post: number;
22+ toleranceMs: number;
23+ config: {
24+ durationS: number; eventsPerType: number; gainMinDb: number;
25+ overlap: number; trainFraction: number; seed: number;
26+ };
27+ algorithms: { source: string }[];
28+ runs: {
29+ x: Float32Array;
30+ truth: { t: number; typeIndex: number; gain: number }[];
31+ /** Per type: the run's training snippets, concatenated. */
32+ training: { data: Float32Array; count: number }[];
33+ }[];
34+}
35+
36+export interface OfflineFile { name: string; data: Uint8Array; }
37+
38+const pad2 = (n: number) => String(n).padStart(2, '0');
39+const mstr = (s: string) => `'${s.replace(/'/g, "''")}'`;
40+
41+export function buildOfflineFiles(input: OfflineExportInput): OfflineFile[] {
42+ const enc = new TextEncoder();
43+ const files: OfflineFile[] = [];
44+ const snipLen = input.pre + input.post;
45+
46+ // algorithms/: file names must match the function each source defines
47+ const algoNames: string[] = [];
48+ for (const a of input.algorithms) {
49+ const fn = extractFunctionName(a.source);
50+ if (!fn || algoNames.includes(fn)) continue;
51+ algoNames.push(fn);
52+ files.push({ name: `algorithms/${fn}.m`, data: enc.encode(a.source) });
53+ }
54+
55+ // tools/ and the harness
56+ files.push({ name: 'tools/load_wav.m', data: enc.encode(loadWavSrc) });
57+ files.push({ name: 'tools/load_csv.m', data: enc.encode(loadCsvSrc) });
58+ files.push({ name: 'tools/score_detections.m', data: enc.encode(scoreSrc) });
59+ files.push({
60+ name: 'evaluate.m',
61+ data: enc.encode(evaluateTpl.replace('{{ALGORITHM_LIST}}', algoNames.map(mstr).join(', '))),
62+ });
63+ files.push({
64+ name: 'dataset_info.m',
65+ data: enc.encode(
66+ `function info = dataset_info()\n` +
67+ `% Generated by audio-spike-sorting; facts about this export.\n` +
68+ `info = struct();\n` +
69+ `info.fs = ${input.fs};\n` +
70+ `info.pre = ${input.pre};\n` +
71+ `info.post = ${input.post};\n` +
72+ `info.snip_len = ${snipLen};\n` +
73+ `info.n_runs = ${input.runs.length};\n` +
74+ `info.tolerance_ms = ${input.toleranceMs};\n` +
75+ `info.seed = ${input.config.seed};\n` +
76+ `info.names = {${input.names.map(mstr).join(', ')}};\n` +
77+ `end\n`,
78+ ),
79+ });
80+
81+ // README with the facts substituted
82+ const subs: Record<string, string> = {
83+ N_TYPES: String(input.names.length),
84+ TYPE_NAMES: input.names.join(', '),
85+ FS: String(input.fs),
86+ N_RUNS: String(input.runs.length),
87+ SEED: String(input.config.seed),
88+ EVENTS_PER_TYPE: String(input.config.eventsPerType),
89+ PRE_MS: String(Math.round((input.pre / input.fs) * 1000)),
90+ POST_MS: String(Math.round((input.post / input.fs) * 1000)),
91+ SNIP_LEN: String(snipLen),
92+ PRE: String(input.pre),
93+ GAIN_MIN_DB: String(input.config.gainMinDb),
94+ OVERLAP: String(input.config.overlap),
95+ TRAIN_FRACTION: String(input.config.trainFraction),
96+ TOLERANCE_MS: String(input.toleranceMs),
97+ };
98+ files.push({
99+ name: 'README.md',
100+ data: enc.encode(readmeTpl.replace(/\{\{(\w+)\}\}/g, (_, k: string) => subs[k] ?? `{{${k}}}`)),
101+ });
102+
103+ // runs/
104+ input.runs.forEach((run, r) => {
105+ const dir = `runs/run_${pad2(r + 1)}`;
106+ files.push({ name: `${dir}/recording.wav`, data: wavFloat32(run.x, input.fs) });
107+ const gt = run.truth
108+ .map((e) => `${e.t.toFixed(6)},${e.typeIndex + 1},${e.gain.toFixed(6)}`)
109+ .join('\n');
110+ files.push({ name: `${dir}/ground_truth.csv`, data: enc.encode(gt + '\n') });
111+ run.training.forEach((tr, k) => {
112+ files.push({
113+ name: `${dir}/training_type_${pad2(k + 1)}.wav`,
114+ data: wavFloat32(tr.data, input.fs),
115+ });
116+ });
117+ });
118+
119+ return files;
120+}
src/export/wav.tsadded+28−0View file
@@ -0,0 +1,28 @@
1+/**
2+ * Minimal float32 WAV encoder (format tag 3, IEEE float, mono). The
3+ * header is exactly 44 bytes with no extra chunks, which the offline
4+ * evaluation harness relies on: numbl has no audioread, so tools/load_wav.m
5+ * reads the rate at byte 24 and the samples from byte 44. Real MATLAB,
6+ * Python soundfile, and audio editors read these files normally.
7+ */
8+export function wavFloat32(samples: Float32Array, sr: number): Uint8Array {
9+ const dataLen = samples.length * 4;
10+ const buf = new ArrayBuffer(44 + dataLen);
11+ const v = new DataView(buf);
12+ const ascii = (off: number, s: string) => { for (let i = 0; i < s.length; i++) v.setUint8(off + i, s.charCodeAt(i)); };
13+ ascii(0, 'RIFF');
14+ v.setUint32(4, 36 + dataLen, true);
15+ ascii(8, 'WAVE');
16+ ascii(12, 'fmt ');
17+ v.setUint32(16, 16, true); // fmt chunk size
18+ v.setUint16(20, 3, true); // IEEE float
19+ v.setUint16(22, 1, true); // mono
20+ v.setUint32(24, sr, true);
21+ v.setUint32(28, sr * 4, true); // byte rate
22+ v.setUint16(32, 4, true); // block align
23+ v.setUint16(34, 32, true); // bits per sample
24+ ascii(36, 'data');
25+ v.setUint32(40, dataLen, true);
26+ new Float32Array(buf, 44).set(samples);
27+ return new Uint8Array(buf);
28+}
src/export/zip.tsadded+82−0View file
@@ -0,0 +1,82 @@
1+/**
2+ * Store-only ZIP writer, enough for the dataset download without a
3+ * dependency. WAV float data barely compresses, so storing costs little;
4+ * every entry gets a real CRC so ordinary unzip tools verify it.
5+ */
6+interface ZipEntry { name: string; data: Uint8Array; }
7+
8+const CRC_TABLE = (() => {
9+ const t = new Uint32Array(256);
10+ for (let n = 0; n < 256; n++) {
11+ let c = n;
12+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
13+ t[n] = c >>> 0;
14+ }
15+ return t;
16+})();
17+
18+function crc32(data: Uint8Array): number {
19+ let c = 0xffffffff;
20+ for (let i = 0; i < data.length; i++) c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
21+ return (c ^ 0xffffffff) >>> 0;
22+}
23+
24+export function zipStore(entries: ZipEntry[]): Uint8Array {
25+ const enc = new TextEncoder();
26+ const parts: Uint8Array[] = [];
27+ const central: Uint8Array[] = [];
28+ let offset = 0;
29+
30+ for (const e of entries) {
31+ const name = enc.encode(e.name);
32+ const crc = crc32(e.data);
33+ const local = new Uint8Array(30 + name.length);
34+ const lv = new DataView(local.buffer);
35+ lv.setUint32(0, 0x04034b50, true);
36+ lv.setUint16(4, 20, true); // version needed
37+ lv.setUint16(6, 0x0800, true); // utf-8 names
38+ lv.setUint16(8, 0, true); // stored
39+ lv.setUint16(10, 0, true); // time
40+ lv.setUint16(12, 0x21, true); // date (1980-01-01)
41+ lv.setUint32(14, crc, true);
42+ lv.setUint32(18, e.data.length, true);
43+ lv.setUint32(22, e.data.length, true);
44+ lv.setUint16(26, name.length, true);
45+ lv.setUint16(28, 0, true);
46+ local.set(name, 30);
47+ parts.push(local, e.data);
48+
49+ const cd = new Uint8Array(46 + name.length);
50+ const cv = new DataView(cd.buffer);
51+ cv.setUint32(0, 0x02014b50, true);
52+ cv.setUint16(4, 20, true);
53+ cv.setUint16(6, 20, true);
54+ cv.setUint16(8, 0x0800, true);
55+ cv.setUint16(10, 0, true);
56+ cv.setUint16(12, 0, true);
57+ cv.setUint16(14, 0x21, true);
58+ cv.setUint32(16, crc, true);
59+ cv.setUint32(20, e.data.length, true);
60+ cv.setUint32(24, e.data.length, true);
61+ cv.setUint16(28, name.length, true);
62+ cv.setUint32(42, offset, true);
63+ cd.set(name, 46);
64+ central.push(cd);
65+ offset += local.length + e.data.length;
66+ }
67+
68+ const cdSize = central.reduce((a, c) => a + c.length, 0);
69+ const eocd = new Uint8Array(22);
70+ const ev = new DataView(eocd.buffer);
71+ ev.setUint32(0, 0x06054b50, true);
72+ ev.setUint16(8, entries.length, true);
73+ ev.setUint16(10, entries.length, true);
74+ ev.setUint32(12, cdSize, true);
75+ ev.setUint32(16, offset, true);
76+
77+ const total = offset + cdSize + 22;
78+ const out = new Uint8Array(total);
79+ let p = 0;
80+ for (const part of [...parts, ...central, eocd]) { out.set(part, p); p += part.length; }
81+ return out;
82+}
src/main.tsmodified+41−1View file
@@ -12,7 +12,7 @@ import { BUILTIN_ALGORITHMS, NEW_ALGORITHM_TEMPLATE } from './algorithms/index.t
1212 import { extractLibrary, snippetCounts } from './bench/snippets.ts';
1313 import { BenchmarkRunner } from './bench/runner.ts';
1414 import { FS, type AlgorithmDef, type RunResult } from './bench/types.ts';
15-import { exportProject, importProject } from './export.ts';
15+import { exportProject, importProject } from './project-file.ts';
1616 import {
1717 clearAll, deleteClipAudio, getBenchmark, getClipAudio, loadDoc, putBenchmark, putClipAudio,
1818 saveDoc, DEFAULT_CONFIG, type BenchConfig, type EventType, type StoredBenchmark,
@@ -521,6 +521,7 @@ $('run-bench').onclick = async () => {
521521 await putBenchmark(bench);
522522 renderResults();
523523 populateExplorerControls();
524+ updateDownloadButton();
524525 status(`benchmark complete: ${out.results.length} algorithm runs`);
525526 } else {
526527 status('benchmark cancelled');
@@ -537,6 +538,44 @@ $('run-bench').onclick = async () => {
537538
538539 $('cancel-bench').onclick = () => runner.cancel();
539540
541+// ---- dataset download -------------------------------------------------------------
542+
543+function updateDownloadButton(): void {
544+ ($('download-runs') as HTMLButtonElement).disabled =
545+ !bench || !bench.runs.length || !bench.runs.every((r) => r.training);
546+}
547+
548+$('download-runs').onclick = async () => {
549+ if (!bench) return;
550+ if (!bench.runs.every((r) => r.training)) {
551+ status('this benchmark predates the download feature; run it again to enable the export');
552+ return;
553+ }
554+ status('building the dataset archive...');
555+ const { buildOfflineFiles } = await import('./export/offline.ts');
556+ const { zipStore } = await import('./export/zip.ts');
557+ const files = buildOfflineFiles({
558+ names: bench.names,
559+ fs: FS,
560+ pre: Math.round((bench.config.preMs / 1000) * FS),
561+ post: Math.round((bench.config.postMs / 1000) * FS),
562+ toleranceMs: bench.config.toleranceMs,
563+ config: bench.config,
564+ algorithms: bench.algos
565+ .map((a) => doc.algorithms.find((d) => d.id === a.id))
566+ .filter((a): a is AlgorithmDef => !!a)
567+ .map((a) => ({ source: a.source })),
568+ runs: bench.runs.map((r) => ({ x: r.x, truth: r.truth, training: r.training! })),
569+ });
570+ const blob = new Blob([zipStore(files) as BlobPart], { type: 'application/zip' });
571+ const a = document.createElement('a');
572+ a.href = URL.createObjectURL(blob);
573+ a.download = 'audio-spike-sorting-runs.zip';
574+ a.click();
575+ URL.revokeObjectURL(a.href);
576+ status(`dataset archive: ${bench.runs.length} runs, ${(blob.size / 1e6).toFixed(1)} MB`);
577+};
578+
540579 // ---- results -------------------------------------------------------------------
541580
542581 function mean(v: number[]): number { return v.length ? v.reduce((a, b) => a + b, 0) / v.length : 0; }
@@ -811,6 +850,7 @@ async function startup(): Promise<void> {
811850 renderAlgos();
812851 renderResults();
813852 populateExplorerControls();
853+ updateDownloadButton();
814854 }
815855
816856 void startup();
src/export.ts →src/project-file.tsrenamed+0−0View file
No changes to the file's content.
src/store.tsmodified+3−0View file
@@ -115,6 +115,9 @@ export interface StoredBenchmark {
115115 seed: number;
116116 x: Float32Array;
117117 truth: { t: number; typeIndex: number; gain: number }[];
118+ /** Per type, concatenated training snippets; absent in benchmarks
119+ * stored before the dataset download existed. */
120+ training?: { data: Float32Array; count: number }[];
118121 }[];
119122 results: RunResult[];
120123 }