/ proof-of-concept / instrawave-compose
Sign in
proof-of-concept / instrawave-compose
instrawave-compose / src / analysis / onsets.ts
66 lines · 2.5 KBCodeBlameHistory
2 * Onset detection by spectral flux: the half-wave rectified frame-to-frame
3 * increase in magnitude, summed over bins, peaks where a new sound starts
4 * and stays flat through sustains and decays. Peaks are picked against a
5 * local mean plus an absolute floor; the single `sensitivity` control moves
6 * the floor, since a soft "doo" and a sharp desk tap sit at very different
7 * flux levels and no fixed threshold serves both.
8 */
9import { fft } from '../audio/fft.ts';
11const FRAME = 1024;
12const HOP = 256;
13const MIN_GAP_S = 0.05;
15/** Onset times in seconds, earliest first. sensitivity in [0, 1]. */
16export function detectOnsets(x: Float32Array, sr: number, sensitivity: number): number[] {
17 const nFrames = Math.floor((x.length - FRAME) / HOP);
18 if (nFrames < 3) return [];
20 const win = new Float32Array(FRAME);
21 for (let i = 0; i < FRAME; i++) win[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (FRAME - 1));
22 const re = new Float32Array(FRAME);
23 const im = new Float32Array(FRAME);
24 const prev = new Float32Array(FRAME >> 1);
25 const flux = new Float32Array(nFrames);
27 for (let f = 0; f < nFrames; f++) {
28 const off = f * HOP;
29 for (let i = 0; i < FRAME; i++) { re[i] = x[off + i] * win[i]; im[i] = 0; }
30 fft(re, im);
31 let s = 0;
32 for (let k = 0; k < FRAME >> 1; k++) {
33 const mag = Math.sqrt(re[k] * re[k] + im[k] * im[k]);
34 const d = mag - prev[k];
35 if (d > 0) s += d;
36 prev[k] = mag;
37 }
38 flux[f] = s;
39 }
41 let max = 0;
42 for (let f = 1; f < nFrames; f++) if (flux[f] > max) max = flux[f];
43 if (max === 0) return [];
44 for (let f = 0; f < nFrames; f++) flux[f] /= max;
45 flux[0] = 0; // the first frame's flux is the frame itself, not an onset
47 // Threshold: local mean over +-W frames, plus a floor the slider moves.
48 const W = 8;
49 const floor = 0.02 + 0.28 * (1 - sensitivity);
50 const minGap = Math.max(1, Math.round((MIN_GAP_S * sr) / HOP));
51 const onsets: number[] = [];
52 let last = -minGap;
53 for (let f = 1; f < nFrames - 1; f++) {
54 let mean = 0, cnt = 0;
55 for (let j = Math.max(0, f - W); j <= Math.min(nFrames - 1, f + W); j++) { mean += flux[j]; cnt++; }
56 mean /= cnt;
57 if (flux[f] < mean * 1.3 + floor) continue;
58 if (flux[f] < flux[f - 1] || flux[f] < flux[f + 1]) continue; // local max only
59 if (f - last < minGap) continue;
60 last = f;
61 // The window whose flux peaks started up to a frame before the attack
62 // it saw; report its centre, which sits on the onset to within a hop.
63 onsets.push((f * HOP + FRAME / 2) / sr);
64 }
65 return onsets;
↑↓ move↵openescclose