Files
STSheets/tests/align/features.test.ts
T
Lucas WintherandClaude Opus 5.5 3db87c0317 fix(align): calibrated fit (precision, lift, beat-shift check, baseline) and aligner guards (A7, A8)
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]>
2026-10-30 19:50:48 +01:00

71 lines
3.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});