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;
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}
36export interface OfflineFile { name: string; data: Uint8Array; }
38const pad2 = (n: number) => String(n).padStart(2, '0');
39const mstr = (s: string) => `'${s.replace(/'/g, "''")}'`;
41export function buildOfflineFiles(input: OfflineExportInput): OfflineFile[] {
42 const enc = new TextEncoder();
43 const files: OfflineFile[] = [];
44 const snipLen = input.pre + input.post;
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 }
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 });
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 });
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 });
119 return files;
120}