140 lines
5.2 KiB
TypeScript
140 lines
5.2 KiB
TypeScript
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import {
|
|
type FrameChord,
|
|
matchChord,
|
|
mergeRuns,
|
|
smoothLabels,
|
|
} from "./chord-matching.js";
|
|
|
|
function chromaFor(pcs: number[]): Float32Array {
|
|
const v = new Float32Array(12);
|
|
for (const pc of pcs) v[pc] = 1;
|
|
return v;
|
|
}
|
|
|
|
describe("matchChord", () => {
|
|
it("matches a C major triad to C major", { timeout: 5000 }, () => {
|
|
const r = matchChord(chromaFor([0, 4, 7]));
|
|
expect(r.root).toBe("C");
|
|
expect(r.quality).toBe("major");
|
|
expect(r.confidence).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("matches an A minor triad to A minor", { timeout: 5000 }, () => {
|
|
const r = matchChord(chromaFor([9, 0, 4]));
|
|
expect(r.root).toBe("A");
|
|
expect(r.quality).toBe("minor");
|
|
});
|
|
|
|
it("matches a diminished triad", { timeout: 5000 }, () => {
|
|
const r = matchChord(chromaFor([0, 3, 6]));
|
|
expect(r.quality).toBe("dim");
|
|
});
|
|
|
|
it("matches an augmented triad", { timeout: 5000 }, () => {
|
|
const r = matchChord(chromaFor([0, 4, 8]));
|
|
expect(r.quality).toBe("aug");
|
|
});
|
|
|
|
it("zero chroma returns the default with zero confidence", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const r = matchChord(new Float32Array(12));
|
|
expect(r.confidence).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("smoothLabels", () => {
|
|
it("collapses a single odd label between two identical neighbours", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const c: FrameChord[] = [
|
|
{ root: "C", quality: "major", confidence: 0.9 },
|
|
{ root: "G", quality: "major", confidence: 0.4 },
|
|
{ root: "C", quality: "major", confidence: 0.9 },
|
|
];
|
|
const s = smoothLabels(c);
|
|
expect(s[1]?.root).toBe("C");
|
|
});
|
|
|
|
it("leaves stable runs unchanged", { timeout: 5000 }, () => {
|
|
const c: FrameChord[] = [
|
|
{ root: "C", quality: "major", confidence: 0.9 },
|
|
{ root: "C", quality: "major", confidence: 0.9 },
|
|
];
|
|
expect(smoothLabels(c)).toEqual(c);
|
|
});
|
|
});
|
|
|
|
describe("mergeRuns", () => {
|
|
it("merges adjacent identical labels into single chord records", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const labels: FrameChord[] = [
|
|
{ root: "C", quality: "major", confidence: 0.9 },
|
|
{ root: "C", quality: "major", confidence: 0.95 },
|
|
{ root: "G", quality: "major", confidence: 0.8 },
|
|
{ root: "G", quality: "major", confidence: 0.85 },
|
|
];
|
|
const merged = mergeRuns(labels, { hopSeconds: 0.1, minDuration: 0 });
|
|
expect(merged.length).toBe(2);
|
|
expect(merged[0]?.root).toBe("C");
|
|
expect(merged[1]?.root).toBe("G");
|
|
expect(merged[0]?.timestamp).toBeLessThan(merged[1]?.timestamp ?? 0);
|
|
});
|
|
|
|
it("drops runs shorter than minDuration", { timeout: 5000 }, () => {
|
|
const labels: FrameChord[] = [
|
|
{ root: "C", quality: "major", confidence: 0.9 },
|
|
{ root: "G", quality: "major", confidence: 0.4 },
|
|
{ root: "G", quality: "major", confidence: 0.4 },
|
|
{ root: "C", quality: "major", confidence: 0.9 },
|
|
];
|
|
// Smoothing keeps middle G run (length 2), C bookends not — but
|
|
// minDuration=0.3 with hopSeconds=0.1 drops the single-frame C bookends.
|
|
const merged = mergeRuns(labels, { hopSeconds: 0.1, minDuration: 0.3 });
|
|
// Each pure run is too short — all dropped or none survive.
|
|
expect(merged.every((c) => c.duration >= 0.3)).toBe(true);
|
|
});
|
|
|
|
it("returns empty array for empty labels", { timeout: 5000 }, () => {
|
|
expect(mergeRuns([], { hopSeconds: 0.1, minDuration: 0 })).toEqual([]);
|
|
});
|
|
|
|
it("emits a time-ordered smoothed chord progression", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const labels: FrameChord[] = [
|
|
{ root: "C", quality: "major", confidence: 0.9 },
|
|
{ root: "C", quality: "major", confidence: 0.9 },
|
|
{ root: "C", quality: "major", confidence: 0.9 },
|
|
{ root: "G", quality: "major", confidence: 0.9 },
|
|
{ root: "G", quality: "major", confidence: 0.9 },
|
|
{ root: "G", quality: "major", confidence: 0.9 },
|
|
];
|
|
const merged = mergeRuns(labels, { hopSeconds: 0.1, minDuration: 0 });
|
|
expect(merged.map((c) => c.root)).toEqual(["C", "G"]);
|
|
for (let i = 1; i < merged.length; i++) {
|
|
const prev = merged[i - 1];
|
|
const cur = merged[i];
|
|
expect((cur?.timestamp ?? 0) > (prev?.timestamp ?? 0)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("coalesces non-adjacent identical labels merged by smoothing", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const labels: FrameChord[] = [
|
|
{ root: "C", quality: "major", confidence: 0.5 },
|
|
{ root: "C", quality: "major", confidence: 0.5 },
|
|
{ root: "G", quality: "major", confidence: 0.4 },
|
|
{ root: "C", quality: "major", confidence: 0.5 },
|
|
{ root: "C", quality: "major", confidence: 0.5 },
|
|
];
|
|
const merged = mergeRuns(labels, { hopSeconds: 0.1, minDuration: 0 });
|
|
// smoothing converts the lone G to a C — should coalesce to a single C run.
|
|
expect(merged.length).toBe(1);
|
|
expect(merged[0]?.root).toBe("C");
|
|
});
|
|
});
|