16328b9Download runs as a .zip for offline development: WAVs, ground truth, training snippets, algorithm sources, and a numbl evaluation harness that reproduces the browser scoresJeremy Magland 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 */
8import { extractFunctionName } from '../bench/source.ts';
9import { wavFloat32 } from './wav.ts';
10import evaluateTpl from './assets/evaluate.m?raw';
11import readmeTpl from './assets/OFFLINE_README.md?raw';
12import loadWavSrc from './assets/load_wav.m?raw';
13import loadCsvSrc from './assets/load_csv.m?raw';
14import scoreSrc from './assets/score_detections.m?raw';
16export 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;
df3f613Add a coincidence knob: a fraction of overlapping events land within a small window of their host's onset, the hardest case for detection and one the scorer's greedy matching handles with a documented caveatJeremy Magland 25 overlap: number; coincidence: number; coincidenceMs: number;
26 trainFraction: number; seed: number;
16328b9Download runs as a .zip for offline development: WAVs, ground truth, training snippets, algorithm sources, and a numbl evaluation harness that reproduces the browser scoresJeremy Magland 27 };
28 algorithms: { source: string }[];
29 runs: {
30 x: Float32Array;
31 truth: { t: number; typeIndex: number; gain: number }[];
32 /** Per type: the run's training snippets, concatenated. */
33 training: { data: Float32Array; count: number }[];
34 }[];
35}
37export interface OfflineFile { name: string; data: Uint8Array; }
39const pad2 = (n: number) => String(n).padStart(2, '0');
40const mstr = (s: string) => `'${s.replace(/'/g, "''")}'`;
42export function buildOfflineFiles(input: OfflineExportInput): OfflineFile[] {
43 const enc = new TextEncoder();
44 const files: OfflineFile[] = [];
45 const snipLen = input.pre + input.post;
47 // algorithms/: file names must match the function each source defines
48 const algoNames: string[] = [];
49 for (const a of input.algorithms) {
50 const fn = extractFunctionName(a.source);
51 if (!fn || algoNames.includes(fn)) continue;
52 algoNames.push(fn);
53 files.push({ name: `algorithms/${fn}.m`, data: enc.encode(a.source) });
54 }
56 // tools/ and the harness
57 files.push({ name: 'tools/load_wav.m', data: enc.encode(loadWavSrc) });
58 files.push({ name: 'tools/load_csv.m', data: enc.encode(loadCsvSrc) });
59 files.push({ name: 'tools/score_detections.m', data: enc.encode(scoreSrc) });
60 files.push({
61 name: 'evaluate.m',
62 data: enc.encode(evaluateTpl.replace('{{ALGORITHM_LIST}}', algoNames.map(mstr).join(', '))),
63 });
64 files.push({
65 name: 'dataset_info.m',
66 data: enc.encode(
67 `function info = dataset_info()\n` +
68 `% Generated by audio-spike-sorting; facts about this export.\n` +
69 `info = struct();\n` +
70 `info.fs = ${input.fs};\n` +
71 `info.pre = ${input.pre};\n` +
72 `info.post = ${input.post};\n` +
73 `info.snip_len = ${snipLen};\n` +
74 `info.n_runs = ${input.runs.length};\n` +
75 `info.tolerance_ms = ${input.toleranceMs};\n` +
76 `info.seed = ${input.config.seed};\n` +
77 `info.names = {${input.names.map(mstr).join(', ')}};\n` +
78 `end\n`,
79 ),
80 });
82 // README with the facts substituted
83 const subs: Record<string, string> = {
84 N_TYPES: String(input.names.length),
85 TYPE_NAMES: input.names.join(', '),
86 FS: String(input.fs),
87 N_RUNS: String(input.runs.length),
88 SEED: String(input.config.seed),
89 EVENTS_PER_TYPE: String(input.config.eventsPerType),
90 PRE_MS: String(Math.round((input.pre / input.fs) * 1000)),
91 POST_MS: String(Math.round((input.post / input.fs) * 1000)),
92 SNIP_LEN: String(snipLen),
93 PRE: String(input.pre),
94 GAIN_MIN_DB: String(input.config.gainMinDb),
95 OVERLAP: String(input.config.overlap),
df3f613Add a coincidence knob: a fraction of overlapping events land within a small window of their host's onset, the hardest case for detection and one the scorer's greedy matching handles with a documented caveatJeremy Magland 96 COINCIDENCE: String(input.config.coincidence),
97 COINCIDENCE_MS: String(input.config.coincidenceMs),
16328b9Download runs as a .zip for offline development: WAVs, ground truth, training snippets, algorithm sources, and a numbl evaluation harness that reproduces the browser scoresJeremy Magland 98 TRAIN_FRACTION: String(input.config.trainFraction),
99 TOLERANCE_MS: String(input.toleranceMs),
100 };
101 files.push({
102 name: 'README.md',
103 data: enc.encode(readmeTpl.replace(/\{\{(\w+)\}\}/g, (_, k: string) => subs[k] ?? `{{${k}}}`)),
104 });
106 // runs/
107 input.runs.forEach((run, r) => {
108 const dir = `runs/run_${pad2(r + 1)}`;
109 files.push({ name: `${dir}/recording.wav`, data: wavFloat32(run.x, input.fs) });
110 const gt = run.truth
111 .map((e) => `${e.t.toFixed(6)},${e.typeIndex + 1},${e.gain.toFixed(6)}`)
112 .join('\n');
113 files.push({ name: `${dir}/ground_truth.csv`, data: enc.encode(gt + '\n') });
114 run.training.forEach((tr, k) => {
115 files.push({
116 name: `${dir}/training_type_${pad2(k + 1)}.wav`,
117 data: wavFloat32(tr.data, input.fs),
118 });
119 });
120 });
122 return files;
123}