feat(align): features and DTW
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b614244e4d
commit
41e38cb03f
4 files changed
+301
No files matched your search
@@ -0,0 +1,45 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { dtw } from "../../src/align/dtw.ts";
|
||||
|
||||
// Ground-truth path: diagonal with a kink (j runs ahead by 10 after i=50).
|
||||
function truthJ(i: number): number { return i < 50 ? i : i + 10; }
|
||||
const N = 100, M = 110;
|
||||
// Path cells cost 0, others grow with distance from the true path.
|
||||
const cost = (i: number, j: number) => {
|
||||
const t = truthJ(i);
|
||||
return Math.min(1, Math.abs(j - t) * 0.2);
|
||||
};
|
||||
|
||||
test("dtw recovers diagonal-with-kink path", () => {
|
||||
const p = dtw(cost, N, M);
|
||||
expect(p[0]).toEqual([0, 0]);
|
||||
expect(p[p.length - 1]).toEqual([N - 1, M - 1]);
|
||||
for (const [i, j] of p) if (i !== 49 && i !== 50) expect(Math.abs(j - truthJ(i))).toBeLessThanOrEqual(1);
|
||||
for (let k = 1; k < p.length; k++) {
|
||||
const di = p[k]![0] - p[k - 1]![0], dj = p[k]![1] - p[k - 1]![1];
|
||||
expect(di >= 0 && di <= 1 && dj >= 0 && dj <= 1 && di + dj >= 1).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("banded dtw equals unbanded when the band covers the path", () => {
|
||||
const full = dtw(cost, N, M);
|
||||
const banded = dtw(cost, N, M, { center: (i) => truthJ(i), width: 15 });
|
||||
expect(banded).toEqual(full);
|
||||
});
|
||||
|
||||
test("banded dtw only visits cells in the band", () => {
|
||||
let calls = 0;
|
||||
const p = dtw((i, j) => { calls++; return Math.abs(i - j) * 0.1; }, 200, 200, { center: (i) => i, width: 5 });
|
||||
expect(calls).toBeLessThanOrEqual(200 * 11);
|
||||
expect(p[p.length - 1]).toEqual([199, 199]);
|
||||
});
|
||||
|
||||
test("3000x3000 unbanded dtw finishes in < 5 s", () => {
|
||||
const n = 3000;
|
||||
const t0 = performance.now();
|
||||
const p = dtw((i, j) => Math.abs(i - j) * 0.001 + ((i * 7 + j * 13) % 5) * 0.0001, n, n);
|
||||
const ms = performance.now() - t0;
|
||||
console.log(`dtw 3000x3000: ${ms.toFixed(0)} ms`);
|
||||
expect(p[p.length - 1]).toEqual([n - 1, n - 1]);
|
||||
expect(ms).toBeLessThan(5000);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
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([]);
|
||||
});
|
||||
Reference in new issue
Block a user