fix: drum transcriber noise gate, kick+snare, test floors

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 04:55:06 +01:00
co-authored by Claude Sonnet 5.5
parent 9bad6490eb
commit eab70b24f6
2 changed files with 75 additions and 22 deletions
+23 -11
View File
@@ -11,9 +11,14 @@ const K_MAD = 6; // threshold = median + K_MAD * MAD
const ABS_FLOOR = 0.4; // absolute flux floor (log-compressed units); keeps silence/noise floor silent
const CYMBAL_DECAY = 0.3; // s: hat-band energy that persists this long marks a cymbal
const REL = 0.3; // also >= REL * 99th-percentile flux;
const CK = 1, CS = 1; // log-compression gain on peak-normalised magnitudes (low gain keeps transient leakage out of the kick band)
const KICK_BLEED = 0.15;
const REL = 0.3; // threshold also >= REL * 99th-percentile flux
const MIN_OVER_MED = 3;
// log-compression gain on peak-normalised magnitudes (low gain keeps transient leakage out of the kick/snare bands)
const GAIN = 1;
const KICK_NEAR = 0.08; // s
const SNARE_RATIO = 0.82;
const SNARE_TAIL = 0.12; // s
const MIN_PEAK = 3e-3; // absolute gate (~-50 dBFS): quieter stems are treated as silence
const KICK = 36, SNARE = 38, HAT = 42, CYMBAL = 49;
/** Log-compressed half-wave-rectified flux summed over bins [lo, hi). */
@@ -42,10 +47,10 @@ function pickPeaks(env: Float64Array, k: number, floor: number, minGap: number):
const mad = median(env.map((v) => Math.abs(v - med)));
const sorted = Array.from(env).sort((a, b) => a - b);
const p99 = sorted[Math.floor(sorted.length * 0.99)] ?? 0;
const thr = Math.max(med + k * mad, floor, REL * p99);
const thr = Math.max(med + k * mad, floor, REL * p99, MIN_OVER_MED * med); // stationary noise has max ~2x median
const peaks: number[] = [];
const w = 2;
for (let i = 0; i < env.length; i++) {
for (let i = 2; i < env.length; i++) { // frames 0-1 only see the reflect-padded edge: flux there is an artefact
const v = env[i]!;
if (v < thr) continue;
let isMax = true;
@@ -64,12 +69,12 @@ function pickPeaks(env: Float64Array, k: number, floor: number, minGap: number):
}
export function transcribeDrums(audio: Audio): TimedNote[] {
if ((audio.channels[0]?.length ?? 0) <= N_FFT) return [];
const x = toMono(audio);
let peak = 0;
for (let i = 0; i < x.length; i++) peak = Math.max(peak, Math.abs(x[i]!));
if (peak < 1e-4) return [];
if (peak < MIN_PEAK) return [];
for (let i = 0; i < x.length; i++) x[i]! /= peak; // level-invariant thresholds
if (x.length <= N_FFT) return [];
const sr = audio.sampleRate;
const spec = stft(x, N_FFT, HOP);
const bins = N_FFT / 2 + 1;
@@ -82,9 +87,9 @@ export function transcribeDrums(audio: Audio): TimedNote[] {
const dt = HOP / sr;
const minGap = Math.max(1, Math.round(MIN_IOI / dt));
const kickEnv = bandFlux(mags, 1, bin(150), CK);
const snareLow = bandFlux(mags, bin(150), bin(2000), CS);
const snareHigh = bandFlux(mags, bin(2000), bin(7000), CS);
const kickEnv = bandFlux(mags, 1, bin(150), GAIN);
const snareLow = bandFlux(mags, bin(150), bin(2000), GAIN);
const snareHigh = bandFlux(mags, bin(2000), bin(7000), GAIN);
const hatEnv = bandFlux(mags, bin(7000), bins);
const snareEnv = snareLow.map((v, i) => Math.min(v, 2 * snareHigh[i]!)); // needs broadband noise, not just low tonal energy
@@ -102,7 +107,6 @@ export function transcribeDrums(audio: Audio): TimedNote[] {
};
const kicks = pickPeaks(kickEnv, K_MAD, ABS_FLOOR, minGap);
const nearMax = (e: Float64Array, f: number) => Math.max(e[f - 1] ?? 0, e[f]!, e[f + 1] ?? 0);
const snares = pickPeaks(snareEnv, K_MAD, ABS_FLOOR, minGap).filter((f) => nearMax(kickEnv, f) < KICK_BLEED * nearMax(snareEnv, f)); // kick clicks are broadband too: drop snare candidates carried by low-band flux
const hatPeaks = pickPeaks(hatEnv, K_MAD, ABS_FLOOR, minGap);
const hats: number[] = [], cymbals: number[] = [];
@@ -113,6 +117,14 @@ export function transcribeDrums(audio: Audio): TimedNote[] {
const after = hatLevel[Math.min(mags.length - 1, f + decayFrames)]!;
(peak > 0 && after / peak > 0.25 ? cymbals : hats).push(f);
}
// Snare candidates near a kick hit (kick clicks and kick+hat are broadband too) must be mid-band dominated like a snare
// (low/high flux ratio), not carried by 2-7 kHz hat energy; an isolated candidate needs no such check.
const kickNear = (f: number) => kicks.some((k) => Math.abs(k - f) * dt <= KICK_NEAR);
const cands = pickPeaks(snareEnv, K_MAD, ABS_FLOOR, minGap).filter(
(f) => !kickNear(f) || (snareLow[f]! >= SNARE_RATIO * snareHigh[f]! && !cymbals.some((c) => Math.abs(c - f) * dt <= 0.03)), // a sustained crash under a kick is not a snare
);
// drop weak tails: a candidate within SNARE_TAIL of a much stronger one is ringing, not a new hit
const snares = cands.filter((f) => !cands.some((g) => g < f && (f - g) * dt <= SNARE_TAIL && snareEnv[f]! < 0.3 * snareEnv[g]!));
add(kicks, KICK, 0.1);
add(snares, SNARE, 0.1);
add(hats, HAT, 0.05);