Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
108 lines
5.6 KiB
TypeScript
108 lines
5.6 KiB
TypeScript
import { test, expect, beforeAll, afterAll } from "bun:test";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { buildSong, loadSongDir } from "../../src/bench/dataset.ts";
|
|
import { estimateGrid, gridFromTruth } from "../../src/pipeline/tempo.ts";
|
|
import type { TierId } from "../../src/gen/tiers.ts";
|
|
import type { Audio } from "../../src/core/types.ts";
|
|
|
|
type Row = { id: string; trueBpm: number; estBpm: number; factor: number; beatErrMs: number; barErrMs: number };
|
|
const rows: Row[] = [];
|
|
let root = "";
|
|
|
|
function circ(x: number, period: number): number {
|
|
const m = ((x % period) + period) % period;
|
|
return Math.min(m, period - m);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
root = await mkdtemp(join(tmpdir(), "stsheets-tempo-"));
|
|
for (const tier of ["t1b", "t1c"] as TierId[]) {
|
|
for (let seed = 1; seed <= 5; seed++) {
|
|
const { song, mix } = await loadSongDir(await buildSong(tier, seed, root));
|
|
const g = estimateGrid(mix);
|
|
const f = [0.5, 1, 2].find((c) => Math.abs(g.bpm / song.bpm - c) / c < 0.02) ?? 0;
|
|
const estBeat = 60 / g.bpm; // judged on the estimated beat so a 2x/0.5x grid is compared on its own beat
|
|
rows.push({
|
|
id: `${tier}-${seed}`, trueBpm: song.bpm, estBpm: g.bpm, factor: f,
|
|
beatErrMs: circ(g.firstDownbeat, estBeat) * 1000,
|
|
barErrMs: circ(g.firstDownbeat, estBeat * g.beatsPerBar) * 1000,
|
|
});
|
|
}
|
|
}
|
|
console.log(rows.map((r) => `${r.id} true=${r.trueBpm} est=${r.estBpm.toFixed(2)} f=${r.factor} beatErr=${r.beatErrMs.toFixed(1)}ms barErr=${r.barErrMs.toFixed(1)}ms`).join("\n"));
|
|
}, 600_000);
|
|
|
|
afterAll(async () => { if (root) await rm(root, { recursive: true, force: true }); });
|
|
|
|
// ---- synthetic drum/chord patterns -------------------------------------------------------------------------------
|
|
const SR = 44100;
|
|
function synth(opts: { bpm: number; bars: number; lead: number; pickupBeats?: number; kicks: number[]; snares: number[]; eighthHats: boolean }): Audio {
|
|
const beat = 60 / opts.bpm;
|
|
const total = opts.lead + ((opts.pickupBeats ?? 0) + opts.bars * 4) * beat + 0.5;
|
|
const x = new Float32Array(Math.floor(total * SR));
|
|
let seed = 12345;
|
|
const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 2 ** 32) * 2 - 1;
|
|
const add = (t: number, len: number, f: (i: number, tt: number) => number) => {
|
|
const s = Math.floor(t * SR);
|
|
for (let i = 0; i < len * SR && s + i < x.length; i++) x[s + i]! += f(i, i / SR);
|
|
};
|
|
const kick = (t: number) => add(t, 0.25, (_, tt) => 0.9 * Math.sin(2 * Math.PI * (50 + 80 * Math.exp(-tt * 40)) * tt) * Math.exp(-tt * 14) + 1.2 * rnd() * Math.exp(-tt * 150));
|
|
const snare = (t: number) => add(t, 0.15, (_, tt) => 1.5 * rnd() * Math.exp(-tt * 18));
|
|
const hat = (t: number, a: number) => add(t, 0.04, (_, tt) => a * rnd() * Math.exp(-tt * 120));
|
|
const t0 = opts.lead + (opts.pickupBeats ?? 0) * beat; // true first downbeat
|
|
for (let p = 0; p < (opts.pickupBeats ?? 0); p++) hat(opts.lead + p * beat, 0.2);
|
|
for (let b = 0; b < opts.bars; b++) {
|
|
const barT = t0 + b * 4 * beat;
|
|
// alternating two-chord progression: a sustained chord per bar (harmonic change on the bar line)
|
|
const chord = b % 2 === 0 ? [220, 277.2, 329.6] : [196, 246.9, 293.7];
|
|
add(barT, 4 * beat, (_, tt) => chord.reduce((s, f) => s + 0.04 * Math.sin(2 * Math.PI * f * tt), 0) * Math.min(1, tt * 4));
|
|
for (const k of opts.kicks) kick(barT + k * beat);
|
|
for (const k of opts.snares) snare(barT + k * beat);
|
|
for (let h = 0; h < (opts.eighthHats ? 8 : 4); h++) hat(barT + (h * beat) / (opts.eighthHats ? 2 : 1), 0.12);
|
|
}
|
|
return { sampleRate: SR, channels: [x] };
|
|
}
|
|
|
|
test("gridFromTruth is the oracle grid", () => {
|
|
expect(gridFromTruth(80, 4)).toEqual({ bpm: 80, firstDownbeat: 0, beatsPerBar: 4 });
|
|
});
|
|
|
|
test("leading silence: downbeat found at the true downbeat, not at the first beat after t=0", () => {
|
|
const g = estimateGrid(synth({ bpm: 90, bars: 8, lead: 0.7, kicks: [0], snares: [], eighthHats: false }));
|
|
expect(Math.abs(g.bpm / 90 - 1)).toBeLessThan(0.02);
|
|
expect(circ(g.firstDownbeat - 0.7, (60 / g.bpm) * 4) * 1000).toBeLessThan(50);
|
|
});
|
|
|
|
test("one-beat pickup: bar phase is correct", () => {
|
|
const beat = 60 / 90;
|
|
const g = estimateGrid(synth({ bpm: 90, bars: 8, lead: 0, pickupBeats: 1, kicks: [0], snares: [], eighthHats: false }));
|
|
expect(Math.abs(g.bpm / 90 - 1)).toBeLessThan(0.02);
|
|
expect(circ(g.firstDownbeat - beat, (60 / g.bpm) * 4) * 1000).toBeLessThan(50);
|
|
});
|
|
|
|
test("genuine fast tempo (140 bpm kick/snare/8th-hat rock) is not halved", () => {
|
|
const g = estimateGrid(synth({ bpm: 140, bars: 12, lead: 0, kicks: [0, 2], snares: [1, 3], eighthHats: true }));
|
|
expect(Math.abs(g.bpm / 140 - 1)).toBeLessThan(0.02);
|
|
});
|
|
|
|
test("rejects too-short and silent input", () => {
|
|
expect(() => estimateGrid({ sampleRate: SR, channels: [new Float32Array(100)] })).toThrow(/too short/);
|
|
expect(() => estimateGrid({ sampleRate: SR, channels: [new Float32Array(SR * 5)] })).toThrow(/silent/);
|
|
});
|
|
|
|
// ---- tier-1 mixes ------------------------------------------------------------------------------------------------
|
|
test("tempo within 2% (allowing 0.5x/1x/2x) in >= 8 of 10 songs", () => {
|
|
expect(rows.filter((r) => r.factor !== 0).length).toBeGreaterThanOrEqual(8);
|
|
});
|
|
|
|
test("beat phase within 50 ms of 0 in >= 8 of 10 songs", () => {
|
|
expect(rows.filter((r) => r.beatErrMs <= 50).length).toBeGreaterThanOrEqual(8);
|
|
});
|
|
|
|
test("exact 1x tempo and correct bar phase in >= 8 of 10 songs", () => {
|
|
expect(rows.filter((r) => r.factor === 1).length).toBeGreaterThanOrEqual(8);
|
|
expect(rows.filter((r) => r.factor === 1 && r.barErrMs <= 50).length).toBeGreaterThanOrEqual(8);
|
|
});
|