Fit flags on precision (<0.6) and chance-corrected lift (<0.25), null with <8 recording onsets; old match rate kept as audibleRate (info). Symbolic-chroma beat/bar-shift check per window. Constant-offset baseline (-2..+2 s) replaces DTW without manual anchors unless DTW precision is better by >=0.02. Stretch warning counts in the fit summary. DTW: slope-limited steps, open begin/end options; per-bar outlier rejection (1/4 beat, Theil-Sen-transported +-3 bar median); no anchor in the final partial bar. bench real: saved/computed source, baseline + method, corrupt alignment.json skipped. Parabolic onset interpolation; warp extrapolation documented; spec amended. Co-Authored-By: Claude Opus 5.5 <[email protected]>
71 lines
3.0 KiB
TypeScript
71 lines
3.0 KiB
TypeScript
import { test, expect } from "bun:test";
|
||
import { features, pickOnsets } from "../../src/align/features.ts";
|
||
import type { Audio } from "../../src/core/types.ts";
|
||
|
||
const SR = 44100;
|
||
function tone(freqs: number[], sec: number): Audio {
|
||
const x = new Float32Array(Math.round(sec * SR));
|
||
for (let i = 0; i < x.length; i++) for (const f of freqs) x[i]! += 0.3 * Math.sin((2 * Math.PI * f * i) / SR);
|
||
return { sampleRate: SR, channels: [x] };
|
||
}
|
||
|
||
test("features rate and length", () => {
|
||
const f = features(tone([440], 3), 10);
|
||
expect(f.rate).toBe(10);
|
||
expect(Math.abs(f.chroma.length - 31)).toBeLessThanOrEqual(1);
|
||
expect(f.onset.length).toBe(f.chroma.length);
|
||
expect(f.chroma[0]!.length).toBe(12);
|
||
const g = features(tone([440], 3), 50);
|
||
expect(Math.abs(g.chroma.length - 151)).toBeLessThanOrEqual(1);
|
||
});
|
||
|
||
test("chroma peaks at the right pitch class and is unit-norm", () => {
|
||
const f = features(tone([440], 2), 10); // A -> pitch class 9
|
||
const c = f.chroma[10]!;
|
||
let best = 0;
|
||
for (let k = 1; k < 12; k++) if (c[k]! > c[best]!) best = k;
|
||
expect(best).toBe(9);
|
||
expect(Math.hypot(...c)).toBeCloseTo(1, 4);
|
||
const fe = features(tone([261.63, 329.63, 392], 2), 10).chroma[10]!; // C E G
|
||
const top = [...fe].map((v, k) => [v, k] as const).sort((a, b) => b[0] - a[0]).slice(0, 3).map((e) => e[1]).sort((a, b) => a - b);
|
||
expect(top).toEqual([0, 4, 7]);
|
||
});
|
||
|
||
test("silent frames stay zero", () => {
|
||
const f = features({ sampleRate: SR, channels: [new Float32Array(SR * 2)] }, 10);
|
||
for (const c of f.chroma) expect(c.every((v) => v === 0)).toBe(true);
|
||
});
|
||
|
||
test("pickOnsets finds a click train within 10 ms", () => {
|
||
const x = new Float32Array(SR * 4);
|
||
const times = [0.5, 1.2, 2.0, 2.7, 3.3];
|
||
for (const t of times) {
|
||
const s = Math.round(t * SR);
|
||
for (let k = 0; k < 200; k++) x[s + k] = Math.sin(k * 0.8) * Math.exp(-k / 40) * 0.8;
|
||
}
|
||
const f = features({ sampleRate: SR, channels: [x] }, 50);
|
||
const got = pickOnsets(f.onset, f.rate);
|
||
expect(got.length).toBe(times.length);
|
||
times.forEach((t, k) => expect(Math.abs(got[k]! - t)).toBeLessThanOrEqual(0.01));
|
||
});
|
||
|
||
test("pickOnsets enforces 50 ms min gap and ignores flat input", () => {
|
||
const o = new Float32Array(100);
|
||
o[20] = 5; o[22] = 6; o[60] = 5;
|
||
expect(pickOnsets(o, 100)).toEqual([0.22, 0.6]);
|
||
expect(pickOnsets(new Float32Array(100), 100)).toEqual([]);
|
||
});
|
||
|
||
test("pickOnsets refines peaks to sub-frame times by parabolic interpolation", () => {
|
||
const o = new Float32Array(100);
|
||
o[30] = 4; o[31] = 8; o[32] = 6; // peak leans right: δ = ½(4 − 6)/(4 − 16 + 6) = 1/6 frame
|
||
const got = pickOnsets(o, 50);
|
||
expect(got).toHaveLength(1);
|
||
expect(got[0]!).toBeCloseTo((31 + 1 / 6) / 50, 9);
|
||
// min-gap competition still compares the peak heights, not the interpolated times
|
||
const p = new Float32Array(100);
|
||
p[40] = 5; p[41] = 3; p[42] = 7; p[43] = 1;
|
||
expect(pickOnsets(p, 100)).toHaveLength(1);
|
||
expect(pickOnsets(p, 100)[0]!).toBeCloseTo((42 + 0.5 * (3 - 1) / (3 - 14 + 1)) / 100, 9);
|
||
});
|