/** * Onset detection by spectral flux, adapted from instrawave-compose for * the 16 kHz internal rate (shorter frame and hop, so timing stays sharp). * The half-wave rectified frame-to-frame increase in magnitude peaks where * a new sound starts; peaks are picked against a local mean plus a floor * the single `sensitivity` control moves. */ import { fft } from '../audio/fft.ts'; const FRAME = 512; const HOP = 128; 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 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; onsets.push((f * HOP + FRAME / 2) / sr); } return onsets; } /** * Refine a coarse onset to the foot of the energy rise: find the * short-term energy peak within a window after the coarse time, then walk * back to where the envelope first falls below a small fraction of that * peak. This is the project's single definition of "when an event is", * used both when cutting training snippets and (by construction) as the * ground-truth time of a placed snippet, so every algorithm is scored * against a consistent convention. */ export function refineOnset(x: Float32Array, sr: number, tCoarse: number): number { const w = Math.max(1, Math.round(0.002 * sr)); // 2 ms energy window const from = Math.max(0, Math.round((tCoarse - 0.03) * sr)); const to = Math.min(x.length - w, Math.round((tCoarse + 0.05) * sr)); if (to <= from) return Math.round(tCoarse * sr); const env = (s: number) => { let e = 0; for (let i = 0; i < w; i++) e += x[s + i] * x[s + i]; return e; }; let peakS = from, peakE = 0; for (let s = from; s <= to; s += w) { const e = env(s); if (e > peakE) { peakE = e; peakS = s; } } if (peakE === 0) return Math.round(tCoarse * sr); let s = peakS; while (s - w >= from) { const e = env(s - w); if (e < 0.02 * peakE) break; if (e > env(s)) break; // rising again: an earlier, separate bump s -= w; } return s; }