/** * Onset detection by spectral flux: the half-wave rectified frame-to-frame * increase in magnitude, summed over bins, peaks where a new sound starts * and stays flat through sustains and decays. Peaks are picked against a * local mean plus an absolute floor; the single `sensitivity` control moves * the floor, since a soft "doo" and a sharp desk tap sit at very different * flux levels and no fixed threshold serves both. */ import { fft } from '../audio/fft.ts'; const FRAME = 1024; const HOP = 256; const MIN_GAP_S = 0.05; /** Onset times in seconds, earliest first. sensitivity in [0, 1]. */ export function detectOnsets(x: Float32Array, sr: number, sensitivity: number): number[] { const nFrames = Math.floor((x.length - FRAME) / HOP); if (nFrames < 3) return []; const win = new Float32Array(FRAME); for (let i = 0; i < FRAME; i++) win[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (FRAME - 1)); const re = new Float32Array(FRAME); const im = new Float32Array(FRAME); const prev = new Float32Array(FRAME >> 1); const flux = new Float32Array(nFrames); for (let f = 0; f < nFrames; f++) { const off = f * HOP; for (let i = 0; i < FRAME; i++) { re[i] = x[off + i] * win[i]; im[i] = 0; } fft(re, im); let s = 0; for (let k = 0; k < FRAME >> 1; k++) { const mag = Math.sqrt(re[k] * re[k] + im[k] * im[k]); const d = mag - prev[k]; if (d > 0) s += d; prev[k] = mag; } flux[f] = s; } let max = 0; for (let f = 1; f < nFrames; f++) if (flux[f] > max) max = flux[f]; if (max === 0) return []; for (let f = 0; f < nFrames; f++) flux[f] /= max; flux[0] = 0; // the first frame's flux is the frame itself, not an onset // Threshold: local mean over +-W frames, plus a floor the slider moves. const W = 8; const floor = 0.02 + 0.28 * (1 - sensitivity); const minGap = Math.max(1, Math.round((MIN_GAP_S * sr) / HOP)); const onsets: number[] = []; let last = -minGap; for (let f = 1; f < nFrames - 1; f++) { let mean = 0, cnt = 0; for (let j = Math.max(0, f - W); j <= Math.min(nFrames - 1, f + W); j++) { mean += flux[j]; cnt++; } mean /= cnt; if (flux[f] < mean * 1.3 + floor) continue; if (flux[f] < flux[f - 1] || flux[f] < flux[f + 1]) continue; // local max only if (f - last < minGap) continue; last = f; // The window whose flux peaks started up to a frame before the attack // it saw; report its centre, which sits on the onset to within a hop. onsets.push((f * HOP + FRAME / 2) / sr); } return onsets; }