fix: tempo octave choice, start-of-music anchoring, mean-normalised combs, pure toMono
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
4ab92d4a86
commit
1d490f6897
+1
-10
@@ -67,16 +67,7 @@ export async function writeWav(path: string, a: Audio): Promise<void> {
|
||||
await Bun.write(path, out);
|
||||
}
|
||||
|
||||
/** Average all channels into one mono signal. */
|
||||
export function toMono(a: Audio): Float32Array {
|
||||
const n = a.channels[0]?.length ?? 0;
|
||||
if (a.channels.length === 1) return a.channels[0]!.slice();
|
||||
const out = new Float32Array(n);
|
||||
for (const ch of a.channels) for (let i = 0; i < n; i++) out[i]! += ch[i]!;
|
||||
const k = 1 / a.channels.length;
|
||||
for (let i = 0; i < n; i++) out[i]! *= k;
|
||||
return out;
|
||||
}
|
||||
export { toMono } from "../dsp/mono.ts";
|
||||
|
||||
const sinc = (x: number) => (x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x));
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Pure channel downmix. Re-exported from src/audio/wav.ts for existing importers.
|
||||
import type { Audio } from "../core/types.ts";
|
||||
|
||||
/** Average all channels into one mono signal. */
|
||||
export function toMono(a: Audio): Float32Array {
|
||||
const n = a.channels[0]?.length ?? 0;
|
||||
if (a.channels.length === 1) return a.channels[0]!.slice();
|
||||
const out = new Float32Array(n);
|
||||
for (const ch of a.channels) for (let i = 0; i < n; i++) out[i]! += ch[i]!;
|
||||
const k = 1 / a.channels.length;
|
||||
for (let i = 0; i < n; i++) out[i]! *= k;
|
||||
return out;
|
||||
}
|
||||
+118
-74
@@ -1,7 +1,7 @@
|
||||
// Tempo and downbeat estimation for constant-tempo material. Pure: no I/O, browser-portable.
|
||||
import type { Audio, Grid } from "../core/types.ts";
|
||||
import { stft } from "../dsp/fft.ts";
|
||||
import { toMono } from "../audio/wav.ts";
|
||||
import { toMono } from "../dsp/mono.ts";
|
||||
|
||||
export interface GridOpts { minBpm?: number; maxBpm?: number; beatsPerBar?: number }
|
||||
|
||||
@@ -14,6 +14,9 @@ const N_FFT = 2048;
|
||||
const PRIOR_CENTRE_BPM = 100;
|
||||
const PRIOR_SIGMA_OCT = 0.7;
|
||||
const LOW_HZ = 150;
|
||||
const TIE_RATIO = 0.95;
|
||||
const WEAK_PULSE_RATIO = 0.93;
|
||||
const START_TIEBREAK = 0.1;
|
||||
|
||||
/** Half-wave rectified log-magnitude spectral flux: full band and low band (< 150 Hz). */
|
||||
function onsetEnvelopes(x: Float32Array, sr: number, hop: number): { all: Float64Array; low: Float64Array; tonal: Float64Array[] } {
|
||||
@@ -60,80 +63,18 @@ function detrend(e: Float64Array): Float64Array {
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
function sampleAt(e: Float64Array, pos: number): number {
|
||||
if (pos < 0 || pos > e.length - 1) return 0;
|
||||
const i = Math.floor(pos), fr = pos - i;
|
||||
return e[i]! * (1 - fr) + (i + 1 < e.length ? e[i + 1]! : e[i]!) * fr;
|
||||
}
|
||||
|
||||
/** Sum of envelope on the comb t = phase + n * period (all in frames, fractional). */
|
||||
function combSum(e: Float64Array, period: number, phase: number, stride = 1): number {
|
||||
let s = 0;
|
||||
for (let t = phase; t <= e.length - 1; t += period * stride) s += sampleAt(e, t);
|
||||
return s;
|
||||
}
|
||||
|
||||
export function estimateGrid(audio: Audio, opts: GridOpts = {}): Grid {
|
||||
const minBpm = opts.minBpm ?? 60;
|
||||
const maxBpm = opts.maxBpm ?? 180;
|
||||
const beatsPerBar = opts.beatsPerBar ?? 4;
|
||||
const sr = audio.sampleRate;
|
||||
const hop = Math.max(1, Math.round((512 * sr) / 44100));
|
||||
const fps = sr / hop;
|
||||
const x = toMono(audio);
|
||||
const { all, low, tonal } = onsetEnvelopes(x, sr, hop);
|
||||
const env = detrend(all);
|
||||
const lowEnv = detrend(low);
|
||||
|
||||
// Tempo: autocorrelation of the envelope weighted by a log-Gaussian prior.
|
||||
const n = env.length;
|
||||
const acf = (lag: number): number => {
|
||||
let s = 0;
|
||||
const l = Math.round(lag);
|
||||
for (let i = 0; i + l < n; i++) s += env[i]! * env[i + l]!;
|
||||
return s / Math.max(1, n - l);
|
||||
};
|
||||
const prior = (bpm: number) => Math.exp(-0.5 * (Math.log2(bpm / PRIOR_CENTRE_BPM) / PRIOR_SIGMA_OCT) ** 2);
|
||||
let bestBpm = PRIOR_CENTRE_BPM, bestScore = -Infinity;
|
||||
for (let bpm = minBpm; bpm <= maxBpm; bpm += 0.25) {
|
||||
const lag = (60 * fps) / bpm;
|
||||
// Reward harmonic support at 2 and 4 beats (bar structure).
|
||||
const s = (acf(lag) + 0.5 * acf(2 * lag) + 0.25 * acf(4 * lag)) * prior(bpm);
|
||||
if (s > bestScore) { bestScore = s; bestBpm = bpm; }
|
||||
}
|
||||
const scoreAt = (bpm: number): number => { const lag = (60 * fps) / bpm; return acf(lag) + 0.5 * acf(2 * lag) + 0.25 * acf(4 * lag); };
|
||||
// Octave rule for slow pop: a fast peak whose half-tempo peak is nearly as strong is taken as double-time.
|
||||
if (bestBpm > 120 && bestBpm / 2 >= minBpm && scoreAt(bestBpm / 2) >= 0.9 * scoreAt(bestBpm)) bestBpm /= 2;
|
||||
|
||||
// Joint refinement of tempo and phase on the comb (fine grid, +-4% around the coarse peak).
|
||||
let rb = bestBpm, rp = 0, rs = -Infinity;
|
||||
for (let bpm = bestBpm * 0.96; bpm <= bestBpm * 1.04; bpm += bestBpm * 0.001) {
|
||||
const period = (60 * fps) / bpm;
|
||||
const steps = Math.ceil(period);
|
||||
for (let k = 0; k < steps; k++) {
|
||||
const phase = (k * period) / steps;
|
||||
const s = combSum(env, period, phase);
|
||||
if (s > rs) { rs = s; rb = bpm; rp = phase; }
|
||||
}
|
||||
}
|
||||
|
||||
// Downbeat: the beat phase whose bar-spaced comb has the most low-frequency flux.
|
||||
const period = (60 * fps) / rb;
|
||||
// Score each candidate by low-band flux plus harmonic novelty (chords change on bar lines), each normalised,
|
||||
// with a small prior for the first candidate (music tends to start on a downbeat).
|
||||
const lowS: number[] = [], novS: number[] = [];
|
||||
for (let k = 0; k < beatsPerBar; k++) {
|
||||
lowS.push(combSum(lowEnv, period, rp + k * period, beatsPerBar));
|
||||
novS.push(noveltyComb(tonal, period, rp + k * period, beatsPerBar));
|
||||
}
|
||||
const lowMax = Math.max(...lowS) || 1, novMax = Math.max(...novS) || 1;
|
||||
let bestK = 0, bestLow = -Infinity;
|
||||
for (let k = 0; k < beatsPerBar; k++) {
|
||||
const s = lowS[k]! / lowMax + novS[k]! / novMax + (k === 0 ? 0.2 : 0);
|
||||
if (s > bestLow) { bestLow = s; bestK = k; }
|
||||
}
|
||||
const down = (rp + bestK * period) / fps;
|
||||
return { bpm: rb, firstDownbeat: down, beatsPerBar };
|
||||
/** Mean envelope value on the comb t = phase + n * period * stride (frames, fractional). */
|
||||
function combMean(e: Float64Array, period: number, phase: number, stride = 1): number {
|
||||
let s = 0, count = 0;
|
||||
for (let t = phase; t <= e.length - 1; t += period * stride) { s += sampleAt(e, t); count++; }
|
||||
return count ? s / count : 0;
|
||||
}
|
||||
|
||||
/** Mean tonal spectrum over frames [a, b), L2-normalised. */
|
||||
@@ -150,17 +91,120 @@ function meanSpec(tonal: Float64Array[], a: number, b: number): Float64Array | n
|
||||
}
|
||||
|
||||
/** Harmonic novelty at time t (frames): 1 - cosine between the spectrum before and after, skipping the attack. */
|
||||
function harmonicNovelty(tonal: Float64Array[], t: number, period: number): number {
|
||||
function harmonicNovelty(tonal: Float64Array[], t: number, period: number): number | null {
|
||||
const a = meanSpec(tonal, t - 0.9 * period, t - 0.1 * period);
|
||||
const b = meanSpec(tonal, t + 0.25 * period, t + 0.9 * period);
|
||||
if (!a || !b) return 0;
|
||||
if (!a || !b) return null;
|
||||
let dot = 0;
|
||||
for (let k = 0; k < a.length; k++) dot += a[k]! * b[k]!;
|
||||
return 1 - dot;
|
||||
}
|
||||
|
||||
/** Mean harmonic novelty over the valid comb positions. */
|
||||
function noveltyComb(tonal: Float64Array[], period: number, phase: number, stride: number): number {
|
||||
let s = 0;
|
||||
for (let t = phase; t < tonal.length; t += period * stride) s += harmonicNovelty(tonal, t, period);
|
||||
return s;
|
||||
let s = 0, count = 0;
|
||||
for (let t = phase; t < tonal.length; t += period * stride) {
|
||||
const v = harmonicNovelty(tonal, t, period);
|
||||
if (v !== null) { s += v; count++; }
|
||||
}
|
||||
return count ? s / count : 0;
|
||||
}
|
||||
|
||||
export function estimateGrid(audio: Audio, opts: GridOpts = {}): Grid {
|
||||
const minBpm = opts.minBpm ?? 60;
|
||||
const maxBpm = opts.maxBpm ?? 180;
|
||||
const beatsPerBar = opts.beatsPerBar ?? 4;
|
||||
const sr = audio.sampleRate;
|
||||
const hop = Math.max(1, Math.round((512 * sr) / 44100));
|
||||
const fps = sr / hop;
|
||||
const x = toMono(audio);
|
||||
if (x.length <= N_FFT) throw new Error(`estimateGrid: audio too short (${x.length} samples, need > ${N_FFT})`);
|
||||
let peak = 0;
|
||||
for (let i = 0; i < x.length; i++) peak = Math.max(peak, Math.abs(x[i]!));
|
||||
if (peak < 1e-6) throw new Error("estimateGrid: silent input");
|
||||
const { all, low, tonal } = onsetEnvelopes(x, sr, hop);
|
||||
const env = detrend(all);
|
||||
const lowEnv = detrend(low);
|
||||
|
||||
// Tempo: autocorrelation of the envelope (beat, 2-beat and bar-ish lags) weighted by a log-Gaussian prior.
|
||||
const n = env.length;
|
||||
const acf = (lag: number): number => {
|
||||
let s = 0;
|
||||
const l = Math.round(lag);
|
||||
for (let i = 0; i + l < n; i++) s += env[i]! * env[i + l]!;
|
||||
return s / Math.max(1, n - l);
|
||||
};
|
||||
const scoreAt = (bpm: number): number => {
|
||||
const lag = (60 * fps) / bpm;
|
||||
return acf(lag) + 0.5 * acf(2 * lag) + 0.25 * acf(4 * lag);
|
||||
};
|
||||
const prior = (bpm: number) => Math.exp(-0.5 * (Math.log2(bpm / PRIOR_CENTRE_BPM) / PRIOR_SIGMA_OCT) ** 2);
|
||||
let coarse = PRIOR_CENTRE_BPM, bestScore = -Infinity;
|
||||
for (let bpm = minBpm; bpm <= maxBpm; bpm += 0.25) {
|
||||
const s = scoreAt(bpm) * prior(bpm);
|
||||
if (s > bestScore) { bestScore = s; coarse = bpm; }
|
||||
}
|
||||
|
||||
// Joint refinement of tempo and phase on the beat comb (fine grid, +-4% around the coarse peak).
|
||||
const refine = (centre: number) => {
|
||||
let rb = centre, rp = 0, rs = -Infinity;
|
||||
for (let bpm = centre * 0.96; bpm <= centre * 1.04; bpm += centre * 0.001) {
|
||||
const period = (60 * fps) / bpm;
|
||||
const steps = Math.ceil(period);
|
||||
for (let k = 0; k < steps; k++) {
|
||||
const phase = (k * period) / steps;
|
||||
const s = combMean(env, period, phase);
|
||||
if (s > rs) { rs = s; rb = bpm; rp = phase; }
|
||||
}
|
||||
}
|
||||
return { bpm: rb, phase: rp };
|
||||
};
|
||||
let fit = refine(coarse);
|
||||
|
||||
// Octave choice (prior-driven): a fast candidate is taken as double-time only when the data barely prefers it
|
||||
// (autocorrelation within 5% of the half tempo) AND its pulses are clearly weaker than the half-tempo pulses
|
||||
// (offbeat subdivisions rather than beats). Clear data preference, or equally strong pulses, keeps the fast tempo.
|
||||
if (coarse / 2 >= minBpm && scoreAt(coarse / 2) >= TIE_RATIO * scoreAt(coarse)) {
|
||||
const slow = refine(coarse / 2);
|
||||
const pf = pulseMean(env, (60 * fps) / fit.bpm, fit.phase);
|
||||
const ps = pulseMean(env, (60 * fps) / slow.bpm, slow.phase);
|
||||
if (pf < WEAK_PULSE_RATIO * ps) fit = slow;
|
||||
}
|
||||
const { bpm, phase } = fit;
|
||||
const period = (60 * fps) / bpm;
|
||||
|
||||
// Downbeat: score each of the beatsPerBar candidate phases by mean low-band flux plus mean harmonic novelty
|
||||
// (chords change on bar lines), each normalised to the best candidate. A small tie-break favours the
|
||||
// candidate nearest the first onset above the noise floor (the start of the music).
|
||||
const envMax = Math.max(...env);
|
||||
let firstOnset = 0;
|
||||
while (firstOnset < n && env[firstOnset]! < 0.05 * envMax) firstOnset++;
|
||||
const barFrames = period * beatsPerBar;
|
||||
const lowS: number[] = [], novS: number[] = [], near: number[] = [];
|
||||
for (let k = 0; k < beatsPerBar; k++) {
|
||||
const t = phase + k * period;
|
||||
lowS.push(combMean(lowEnv, period, t, beatsPerBar));
|
||||
novS.push(noveltyComb(tonal, period, t, beatsPerBar));
|
||||
const d = (((firstOnset - t) % barFrames) + barFrames) % barFrames;
|
||||
near.push(Math.min(d, barFrames - d));
|
||||
}
|
||||
const lowMax = Math.max(...lowS) || 1, novMax = Math.max(...novS) || 1;
|
||||
const nearest = near.indexOf(Math.min(...near));
|
||||
let bestK = 0, bestS = -Infinity;
|
||||
for (let k = 0; k < beatsPerBar; k++) {
|
||||
const s = lowS[k]! / lowMax + novS[k]! / novMax + (k === nearest ? START_TIEBREAK : 0);
|
||||
if (s > bestS) { bestS = s; bestK = k; }
|
||||
}
|
||||
return { bpm, firstDownbeat: (phase + bestK * period) / fps, beatsPerBar };
|
||||
}
|
||||
|
||||
/** Like combMean but each pulse takes the local maximum within +-1 frame (robust to sub-frame timing jitter). */
|
||||
function pulseMean(e: Float64Array, period: number, phase: number, stride = 1): number {
|
||||
let s = 0, count = 0;
|
||||
for (let t = phase; t <= e.length - 1; t += period * stride) {
|
||||
const c = Math.round(t);
|
||||
s += Math.max(e[Math.max(0, c - 1)]!, e[Math.min(e.length - 1, c)]!, e[Math.min(e.length - 1, c + 1)]!);
|
||||
count++;
|
||||
}
|
||||
return count ? s / count : 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user