/** * Builds the offline dataset export: every synthesized run with its * ground truth and the exact training snippets it used, the algorithm * sources, and a numbl-runnable evaluation harness whose scoring mirrors * the browser's. Pure of the DOM so the node test exercises the same * files the download button ships. */ import { extractFunctionName } from '../bench/source.ts'; import { wavFloat32 } from './wav.ts'; import evaluateTpl from './assets/evaluate.m?raw'; import readmeTpl from './assets/OFFLINE_README.md?raw'; import loadWavSrc from './assets/load_wav.m?raw'; import loadCsvSrc from './assets/load_csv.m?raw'; import scoreSrc from './assets/score_detections.m?raw'; export interface OfflineExportInput { names: string[]; fs: number; /** Samples before / after the onset in every snippet. */ pre: number; post: number; toleranceMs: number; config: { durationS: number; eventsPerType: number; gainMinDb: number; overlap: number; trainFraction: number; seed: number; }; algorithms: { source: string }[]; runs: { x: Float32Array; truth: { t: number; typeIndex: number; gain: number }[]; /** Per type: the run's training snippets, concatenated. */ training: { data: Float32Array; count: number }[]; }[]; } export interface OfflineFile { name: string; data: Uint8Array; } const pad2 = (n: number) => String(n).padStart(2, '0'); const mstr = (s: string) => `'${s.replace(/'/g, "''")}'`; export function buildOfflineFiles(input: OfflineExportInput): OfflineFile[] { const enc = new TextEncoder(); const files: OfflineFile[] = []; const snipLen = input.pre + input.post; // algorithms/: file names must match the function each source defines const algoNames: string[] = []; for (const a of input.algorithms) { const fn = extractFunctionName(a.source); if (!fn || algoNames.includes(fn)) continue; algoNames.push(fn); files.push({ name: `algorithms/${fn}.m`, data: enc.encode(a.source) }); } // tools/ and the harness files.push({ name: 'tools/load_wav.m', data: enc.encode(loadWavSrc) }); files.push({ name: 'tools/load_csv.m', data: enc.encode(loadCsvSrc) }); files.push({ name: 'tools/score_detections.m', data: enc.encode(scoreSrc) }); files.push({ name: 'evaluate.m', data: enc.encode(evaluateTpl.replace('{{ALGORITHM_LIST}}', algoNames.map(mstr).join(', '))), }); files.push({ name: 'dataset_info.m', data: enc.encode( `function info = dataset_info()\n` + `% Generated by audio-spike-sorting; facts about this export.\n` + `info = struct();\n` + `info.fs = ${input.fs};\n` + `info.pre = ${input.pre};\n` + `info.post = ${input.post};\n` + `info.snip_len = ${snipLen};\n` + `info.n_runs = ${input.runs.length};\n` + `info.tolerance_ms = ${input.toleranceMs};\n` + `info.seed = ${input.config.seed};\n` + `info.names = {${input.names.map(mstr).join(', ')}};\n` + `end\n`, ), }); // README with the facts substituted const subs: Record = { N_TYPES: String(input.names.length), TYPE_NAMES: input.names.join(', '), FS: String(input.fs), N_RUNS: String(input.runs.length), SEED: String(input.config.seed), EVENTS_PER_TYPE: String(input.config.eventsPerType), PRE_MS: String(Math.round((input.pre / input.fs) * 1000)), POST_MS: String(Math.round((input.post / input.fs) * 1000)), SNIP_LEN: String(snipLen), PRE: String(input.pre), GAIN_MIN_DB: String(input.config.gainMinDb), OVERLAP: String(input.config.overlap), TRAIN_FRACTION: String(input.config.trainFraction), TOLERANCE_MS: String(input.toleranceMs), }; files.push({ name: 'README.md', data: enc.encode(readmeTpl.replace(/\{\{(\w+)\}\}/g, (_, k: string) => subs[k] ?? `{{${k}}}`)), }); // runs/ input.runs.forEach((run, r) => { const dir = `runs/run_${pad2(r + 1)}`; files.push({ name: `${dir}/recording.wav`, data: wavFloat32(run.x, input.fs) }); const gt = run.truth .map((e) => `${e.t.toFixed(6)},${e.typeIndex + 1},${e.gain.toFixed(6)}`) .join('\n'); files.push({ name: `${dir}/ground_truth.csv`, data: enc.encode(gt + '\n') }); run.training.forEach((tr, k) => { files.push({ name: `${dir}/training_type_${pad2(k + 1)}.wav`, data: wavFloat32(tr.data, input.fs), }); }); }); return files; }