Bars whose transcriptions are similar (16th-slot occupancy cosine >= 0.6, diagonal-smoothed, and exact pitch/onset match >= 0.3) are treated as repeats. Notes recurring in < 15% of a bar's repeats are dropped; notes in >= 25% and >= 3 repeats but missing from the bar are added. Default on for guitar. Synthetic median guitar F1 t1a 0.866->0.885, t1b 0.814->0.840, t1c 0.734->0.752, t1d 0.721->0.737; lnoe 0.403->0.507. Co-Authored-By: Claude Opus 5.5 <[email protected]>
50 lines
2.2 KiB
TypeScript
50 lines
2.2 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import type { Grid, TimedNote } from "../../src/core/types.ts";
|
|
import { repeatVote } from "../../src/pipeline/repeat.ts";
|
|
|
|
const grid: Grid = { bpm: 120, firstDownbeat: 0, beatsPerBar: 4 }; // 2 s bars
|
|
const riff: [number, number][] = [[0, 52], [0.5, 55], [1, 57], [1.5, 59], [1.75, 60]]; // [pos s, pitch]
|
|
|
|
function bars(n: number, f: (bar: number, pos: number, pitch: number) => boolean = () => true): TimedNote[] {
|
|
const out: TimedNote[] = [];
|
|
for (let b = 0; b < n; b++) for (const [pos, pitch] of riff) {
|
|
if (f(b, pos, pitch)) out.push({ onset: b * 2 + pos, offset: b * 2 + pos + 0.2, pitch });
|
|
}
|
|
return out;
|
|
}
|
|
const key = (n: TimedNote) => `${n.onset.toFixed(3)}:${n.pitch}`;
|
|
|
|
test("adds a note missing from one repeat", () => {
|
|
const notes = bars(6, (b, pos) => !(b === 3 && pos === 1));
|
|
const out = repeatVote(notes, grid);
|
|
expect(out.length).toBe(30);
|
|
const added = out.find((n) => Math.abs(n.onset - 7) < 1e-6 && n.pitch === 57);
|
|
expect(added).toBeDefined();
|
|
expect(added!.offset - added!.onset).toBeCloseTo(0.2, 6);
|
|
});
|
|
|
|
test("drops a note that never recurs in a bar's repeats", () => {
|
|
const notes = [...bars(6), { onset: 4.25, offset: 4.4, pitch: 70 }];
|
|
const out = repeatVote(notes, grid);
|
|
expect(out.map(key)).toEqual(bars(6).map(key));
|
|
});
|
|
|
|
test("leaves non-repeating material unchanged", () => {
|
|
const notes: TimedNote[] = [];
|
|
for (let b = 0; b < 6; b++) notes.push({ onset: b * 2 + 0.3 * (b % 4), offset: b * 2 + 1, pitch: 45 + 3 * b });
|
|
expect(repeatVote(notes, grid).map(key)).toEqual(notes.map(key));
|
|
});
|
|
|
|
test("add/drop can be disabled and empty input is handled", () => {
|
|
const notes = [...bars(6, (b, pos) => !(b === 3 && pos === 1)), { onset: 4.25, offset: 4.4, pitch: 70 }];
|
|
expect(repeatVote(notes, grid, { add: false, drop: false }).length).toBe(notes.length);
|
|
expect(repeatVote([], grid)).toEqual([]);
|
|
});
|
|
|
|
test("bars before a late first downbeat are handled", () => {
|
|
const g: Grid = { ...grid, firstDownbeat: 0.5 };
|
|
const notes = bars(6, (b, pos) => !(b === 2 && pos === 0.5)).map((n) => ({ ...n, onset: n.onset + 0.5, offset: n.offset + 0.5 }));
|
|
const out = repeatVote(notes, g);
|
|
expect(out.some((n) => Math.abs(n.onset - 5) < 1e-6 && n.pitch === 55)).toBe(true);
|
|
});
|