Initial Commit
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user