21abc12audio-spike-sorting: benchmark framework for audio event detection and classificationJeremy Magland 1/**
2 * Onset detection by spectral flux, adapted from instrawave-compose for
3 * the 16 kHz internal rate (shorter frame and hop, so timing stays sharp).
4 * The half-wave rectified frame-to-frame increase in magnitude peaks where
5 * a new sound starts; peaks are picked against a local mean plus a floor
6 * the single `sensitivity` control moves.
7 */
8import { fft } from '../audio/fft.ts';
10const FRAME = 512;
11const HOP = 128;
12const MIN_GAP_S = 0.05;
14/** Onset times in seconds, earliest first. sensitivity in [0, 1]. */
15export function detectOnsets(x: Float32Array, sr: number, sensitivity: number): number[] {
16 const nFrames = Math.floor((x.length - FRAME) / HOP);
17 if (nFrames < 3) return [];
19 const win = new Float32Array(FRAME);
20 for (let i = 0; i < FRAME; i++) win[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (FRAME - 1));
21 const re = new Float32Array(FRAME);
22 const im = new Float32Array(FRAME);
23 const prev = new Float32Array(FRAME >> 1);
24 const flux = new Float32Array(nFrames);
26 for (let f = 0; f < nFrames; f++) {
27 const off = f * HOP;
28 for (let i = 0; i < FRAME; i++) { re[i] = x[off + i] * win[i]; im[i] = 0; }
29 fft(re, im);
30 let s = 0;
31 for (let k = 0; k < FRAME >> 1; k++) {
32 const mag = Math.sqrt(re[k] * re[k] + im[k] * im[k]);
33 const d = mag - prev[k];
34 if (d > 0) s += d;
35 prev[k] = mag;
36 }
37 flux[f] = s;
38 }
40 let max = 0;
41 for (let f = 1; f < nFrames; f++) if (flux[f] > max) max = flux[f];
42 if (max === 0) return [];
43 for (let f = 0; f < nFrames; f++) flux[f] /= max;
44 flux[0] = 0; // the first frame's flux is the frame itself, not an onset
46 const W = 8;
47 const floor = 0.02 + 0.28 * (1 - sensitivity);
48 const minGap = Math.max(1, Math.round((MIN_GAP_S * sr) / HOP));
49 const onsets: number[] = [];
50 let last = -minGap;
51 for (let f = 1; f < nFrames - 1; f++) {
52 let mean = 0, cnt = 0;
53 for (let j = Math.max(0, f - W); j <= Math.min(nFrames - 1, f + W); j++) { mean += flux[j]; cnt++; }
54 mean /= cnt;
55 if (flux[f] < mean * 1.3 + floor) continue;
56 if (flux[f] < flux[f - 1] || flux[f] < flux[f + 1]) continue; // local max only
57 if (f - last < minGap) continue;
58 last = f;
59 onsets.push((f * HOP + FRAME / 2) / sr);
60 }
61 return onsets;
62}
64/**
65 * Refine a coarse onset to the foot of the energy rise: find the
66 * short-term energy peak within a window after the coarse time, then walk
67 * back to where the envelope first falls below a small fraction of that
68 * peak. This is the project's single definition of "when an event is",
69 * used both when cutting training snippets and (by construction) as the
70 * ground-truth time of a placed snippet, so every algorithm is scored
71 * against a consistent convention.
72 */
73export function refineOnset(x: Float32Array, sr: number, tCoarse: number): number {
74 const w = Math.max(1, Math.round(0.002 * sr)); // 2 ms energy window
75 const from = Math.max(0, Math.round((tCoarse - 0.03) * sr));
76 const to = Math.min(x.length - w, Math.round((tCoarse + 0.05) * sr));
77 if (to <= from) return Math.round(tCoarse * sr);
79 const env = (s: number) => {
80 let e = 0;
81 for (let i = 0; i < w; i++) e += x[s + i] * x[s + i];
82 return e;
83 };
84 let peakS = from, peakE = 0;
85 for (let s = from; s <= to; s += w) {
86 const e = env(s);
87 if (e > peakE) { peakE = e; peakS = s; }
88 }
89 if (peakE === 0) return Math.round(tCoarse * sr);
90 let s = peakS;
91 while (s - w >= from) {
92 const e = env(s - w);
93 if (e < 0.02 * peakE) break;
94 if (e > env(s)) break; // rising again: an earlier, separate bump
95 s -= w;
96 }
97 return s;
98}