21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 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
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 6 * "overlapper" is aimed inside the span of an already-placed event. The
7 * coincidence knob then decides what fraction of those overlappers land on
8 * their host's onset rather than at a random offset within its span, within
9 * a window of a few milliseconds, which is the hardest case: two onsets a
10 * detector cannot separate in time and a mixture a classifier must take
11 * apart. Each placed snippet gets a random gain in dB, so level carries no
12 * information about identity. Ground truth is exact by construction.
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 13 */
14import { mulberry32, shuffle, type Rng } from './rng.ts';
15import type { Dataset, GroundTruthEvent, SnippetLibrary, SynthConfig } from './types.ts';
16import { FS } from './types.ts';
18/** Split snippet indices per type into [train, pool]. Guarantees at least
19 * one snippet on each side when a type has two or more. */
20export function splitSnippets(
21 lib: SnippetLibrary,
22 trainFraction: number,
23 rng: Rng,
24): { train: number[][]; pool: number[][] } {
25 const byType: number[][] = lib.names.map(() => []);
26 lib.snippets.forEach((s, i) => byType[s.typeIndex].push(i));
27 const train: number[][] = [];
28 const pool: number[][] = [];
29 for (const idx of byType) {
30 const order = shuffle([...idx], rng);
31 let nTrain = Math.round(order.length * trainFraction);
32 if (order.length >= 2) nTrain = Math.min(Math.max(nTrain, 1), order.length - 1);
33 train.push(order.slice(0, nTrain));
34 pool.push(order.slice(nTrain));
35 }
36 return { train, pool };
37}
39interface Placement { start: number; typeIndex: number; snippetIndex: number; gain: number; }
41export function synthesize(lib: SnippetLibrary, cfg: SynthConfig, runIndex: number): Dataset {
42 const rng = mulberry32(cfg.seed + runIndex);
43 const { train, pool } = splitSnippets(lib, cfg.trainFraction, rng);
44 const snipLen = lib.pre + lib.post;
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 45 const coincidence = cfg.coincidence ?? 0;
46 const coincWin = Math.round(((cfg.coincidenceMs ?? 0) / 1000) * FS);
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 47 const n = Math.round(cfg.durationS * FS);
48 const margin = snipLen; // keep events off the very edges
50 // Build the shuffled list of events to place: (type, snippet from the
51 // placement pool, gain). Pools are sampled with replacement so
52 // eventsPerType does not depend on how many examples were recorded.
53 const wanted: { typeIndex: number; snippetIndex: number; gain: number }[] = [];
54 for (let k = 0; k < lib.names.length; k++) {
55 if (pool[k].length === 0) continue;
56 for (let e = 0; e < cfg.eventsPerType; e++) {
57 const snippetIndex = pool[k][Math.floor(rng() * pool[k].length)];
58 const gainDb = cfg.gainMinDb * rng();
59 wanted.push({ typeIndex: k, snippetIndex, gain: Math.pow(10, gainDb / 20) });
60 }
61 }
62 shuffle(wanted, rng);
64 const placed: Placement[] = [];
65 const maxTries = 200;
66 for (const w of wanted) {
67 const overlapper = placed.length > 0 && rng() < cfg.overlap;
68 let start = -1;
69 if (overlapper) {
70 const host = placed[Math.floor(rng() * placed.length)];
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 71 // A coincident overlapper starts within +/- coincidenceMs of its
72 // host; an ordinary one anywhere inside the host's span. The
73 // coincidence draw is skipped entirely when the knob is off, so a
74 // seed that predates it still produces the same dataset.
75 const coincident = coincidence > 0 && rng() < coincidence;
76 const off = coincident
77 ? Math.round((2 * rng() - 1) * coincWin)
78 : Math.round((rng() - 0.5) * snipLen);
21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 79 const s = host.start + off;
80 if (s >= 0 && s + snipLen <= n) start = s;
81 }
82 if (start < 0) {
83 // Clean placement: rejection-sample a start at least snipLen away
84 // from every placed event (measured start-to-start, which for equal
85 // lengths means no sample overlaps).
86 for (let t = 0; t < maxTries; t++) {
87 const s = margin + Math.floor(rng() * Math.max(1, n - snipLen - 2 * margin));
88 let ok = true;
89 for (const p of placed) {
90 if (Math.abs(p.start - s) < snipLen) { ok = false; break; }
91 }
92 if (ok) { start = s; break; }
93 }
94 }
95 if (start < 0) continue; // timeline too crowded; drop the event
96 placed.push({ start, ...w });
97 }
99 const x = new Float32Array(n);
100 const truth: GroundTruthEvent[] = [];
101 for (const p of placed) {
102 const snip = lib.snippets[p.snippetIndex].samples;
103 for (let i = 0; i < snip.length && p.start + i < n; i++) x[p.start + i] += snip[i] * p.gain;
104 truth.push({ t: (p.start + lib.pre) / FS, typeIndex: p.typeIndex, gain: p.gain });
105 }
106 truth.sort((a, b) => a.t - b.t);
108 // Protect against clipping without rescaling typical data: only scale
109 // down when the mix actually exceeds full scale.
110 let peak = 0;
111 for (let i = 0; i < n; i++) { const a = Math.abs(x[i]); if (a > peak) peak = a; }
112 if (peak > 0.99) { const g = 0.99 / peak; for (let i = 0; i < n; i++) x[i] *= g; }
114 return { x, fs: FS, truth, trainIndices: train };
115}