/** * Per-hit features. Two roles are kept apart: `peakDb` is expression (how * hard the hit was; it becomes the note's strength and is deliberately * excluded from classification, so playing softly does not change which * voice a hit lands on), and `vec` is identity (spectral shape, attack, * decay), which is what the classifier compares. Every entry in `vec` is a * log or a ratio, so distances behave across the huge dynamic ranges * involved. */ import { powerSpectrum } from '../audio/fft.ts'; export interface HitFeatures { /** Peak short-term RMS over the hit, dBFS. */ peakDb: number; /** Identity vector; order fixed, see VEC_NAMES. */ vec: number[]; } export const VEC_NAMES = [ 'log centroid', 'log rolloff85', 'band 0-200', 'band 200-800', 'band 800-2500', 'band 2500+', 'zcr kHz', 'log attack ms', 'log decay ms', ]; const SPEC_N = 4096; const ENV_WIN = 256; const ENV_HOP = 128; const EPS = 1e-12; /** Extract features for the hit starting at sample s0; s1 bounds it (the * next onset or the end of the clip). */ export function extractHit(x: Float32Array, sr: number, s0: number, s1: number): HitFeatures { const end = Math.min(x.length, s1, s0 + Math.floor(0.5 * sr)); const seg = x.subarray(s0, Math.max(end, s0 + ENV_WIN)); // Short-term RMS envelope in dB. const nEnv = Math.max(1, Math.floor((seg.length - ENV_WIN) / ENV_HOP) + 1); const envDb = new Float32Array(nEnv); let peakDb = -160, peakI = 0; for (let i = 0; i < nEnv; i++) { const off = i * ENV_HOP; let e = 0; for (let j = 0; j < ENV_WIN && off + j < seg.length; j++) e += seg[off + j] * seg[off + j]; envDb[i] = 10 * Math.log10(e / ENV_WIN + EPS); if (envDb[i] > peakDb) { peakDb = envDb[i]; peakI = i; } } const attackMs = (peakI * ENV_HOP * 1000) / sr; let decayI = nEnv - 1; for (let i = peakI; i < nEnv; i++) if (envDb[i] < peakDb - 25) { decayI = i; break; } const decayMs = ((decayI - peakI) * ENV_HOP * 1000) / sr; // Spectral shape of the attack portion (first ~85 ms), one padded FFT. const spec = powerSpectrum(seg.subarray(0, Math.min(seg.length, SPEC_N)), SPEC_N); const hzPerBin = sr / SPEC_N; const kMax = Math.min(spec.length, Math.floor(9000 / hzPerBin)); let total = 0, weighted = 0; for (let k = 1; k < kMax; k++) { total += spec[k]; weighted += spec[k] * k * hzPerBin; } const centroid = total > 0 ? weighted / total : 0; let acc = 0, rolloff = 0; for (let k = 1; k < kMax; k++) { acc += spec[k]; if (acc >= 0.85 * total) { rolloff = k * hzPerBin; break; } } // Band energies as log ratios with the mean removed, so overall level // cancels and only the balance between bands remains. const edges = [0, 200, 800, 2500, 9000]; const bands: number[] = []; for (let b = 0; b < 4; b++) { const k0 = Math.max(1, Math.floor(edges[b] / hzPerBin)); const k1 = Math.min(spec.length, Math.floor(edges[b + 1] / hzPerBin)); let e = 0; for (let k = k0; k < k1; k++) e += spec[k]; bands.push(Math.log10(e + EPS)); } const bandMean = bands.reduce((a, v) => a + v, 0) / 4; for (let b = 0; b < 4; b++) bands[b] -= bandMean; // Zero-crossing rate over the first 50 ms, in crossings per ms (~kHz). const zN = Math.min(seg.length, Math.floor(0.05 * sr)); let zc = 0; for (let i = 1; i < zN; i++) if ((seg[i] >= 0) !== (seg[i - 1] >= 0)) zc++; const zcr = zN > 1 ? zc / ((zN * 1000) / sr) : 0; return { peakDb, vec: [ Math.log10(centroid + 1), Math.log10(rolloff + 1), bands[0], bands[1], bands[2], bands[3], zcr, Math.log10(attackMs + 1), Math.log10(decayMs + 1), ], }; }