Files
hyperframes/packages/core/src/audioResample.ts
Xuanru Li 695110e46b feat(core): signal rhythm is rate-independent, refuses grids in the tracker, follows chords (#5644)
* fix(core): signal beats read the same at any decode rate and refuse a grid in the tracker

The tracker resamples every channel to 22.05 kHz and averages their band power, so the
CLI's native-rate decode and Studio's AudioContext decode get the same beats, and a mono
file and the same sound on two channels get identical ones.

The no-steady-beat floor moves from analyzeMusicFromSignal into the tracker, so
hyperframes audio, grep_audio and beats share one floor. It is 0.15: above every one of
66 synthetic ambient, speech, scattered-effect and noise clips (max 0.122), where the
wrapper's 0.2 also refused a third of GTZAN's produced music.

* feat(core): signal downbeats follow chord changes, and sections hear harmony

The tracker now keeps a 10 fps chroma beside its band energies. Downbeats weigh where the
chords change, over one beat and over two, with the low-end hit, instead of the low end
alone. Sections compare timbre and harmony equally.

Tuned on GTZAN's dev half: the tempo prior narrows to 0.8 octave, the local tempo stays
within 10% of the global one, and beats sit 15 ms after the onset peak, where annotators
put them.

* chore(core): trim redundant audio analysis comments

* refactor(core): keep the rhythm files under their comment share

Drops doc comments whose function or field name already says the same thing.
2026-10-15 07:31:04 +00:00

39 lines
1.4 KiB
TypeScript

/** Band-limited resampling by windowed-sinc interpolation, so analysis reads the same at any decode rate. */
const ZERO_CROSSINGS = 8;
const STEPS_PER_CROSSING = 256;
/** Cutoff as a share of the lower Nyquist, leaving the window's transition band below it. */
const CUTOFF = 0.95;
const KERNEL = Float64Array.from({ length: ZERO_CROSSINGS * STEPS_PER_CROSSING + 2 }, (_, i) => {
const x = i / STEPS_PER_CROSSING;
if (x >= ZERO_CROSSINGS) return 0;
const sinc = x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);
return sinc * (0.5 + 0.5 * Math.cos((Math.PI * x) / ZERO_CROSSINGS));
});
function kernelAt(x: number): number {
const at = Math.abs(x) * STEPS_PER_CROSSING;
const i = Math.floor(at);
if (i + 1 >= KERNEL.length) return 0;
const frac = at - i;
return KERNEL[i]! * (1 - frac) + KERNEL[i + 1]! * frac;
}
export function resample(samples: Float32Array, from: number, to: number): Float32Array {
if (from === to) return samples;
const ratio = to / from;
const scale = Math.min(1, ratio) * CUTOFF;
const reach = ZERO_CROSSINGS / scale;
const out = new Float32Array(Math.floor(samples.length * ratio));
for (let n = 0; n < out.length; n++) {
const t = n / ratio;
const last = Math.min(samples.length - 1, Math.floor(t + reach));
let sum = 0;
for (let k = Math.max(0, Math.ceil(t - reach)); k <= last; k++)
sum += samples[k]! * kernelAt((t - k) * scale);
out[n] = sum * scale;
}
return out;
}