Files
hyperframes/packages/core/src/audioResample.test.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

36 lines
1.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { resample } from "./audioResample";
const tone = (hz: number, rate: number, seconds: number) =>
Float32Array.from({ length: seconds * rate }, (_, i) => Math.sin((2 * Math.PI * hz * i) / rate));
const rms = (x: Float32Array) => Math.sqrt(x.reduce((a, v) => a + v * v, 0) / x.length);
/** Upward zero crossings per second, over the middle of the signal. */
function frequency(x: Float32Array, rate: number): number {
const from = Math.floor(x.length / 4);
const to = Math.floor((3 * x.length) / 4);
let crossings = 0;
for (let i = from + 1; i < to; i++) if (x[i - 1]! < 0 && x[i]! >= 0) crossings++;
return (crossings * rate) / (to - from);
}
describe("resample", () => {
it("keeps a tone's pitch and level from 48 kHz to 22.05 kHz", () => {
const out = resample(tone(1000, 48_000, 2), 48_000, 22_050);
expect(out.length).toBe(44_100);
expect(frequency(out, 22_050)).toBeCloseTo(1000, -1);
expect(rms(out.subarray(1000, -1000))).toBeCloseTo(Math.SQRT1_2, 2);
});
it("removes what the lower rate cannot hold instead of folding it down", () => {
const out = resample(tone(15_000, 48_000, 1), 48_000, 22_050);
expect(rms(out.subarray(1000, -1000))).toBeLessThan(0.01);
});
it("returns the samples untouched at the same rate", () => {
const x = tone(440, 22_050, 1);
expect(resample(x, 22_050, 22_050)).toBe(x);
});
});