Files
STSheets/tests/pipeline/repeat.test.ts
T
Lucas WintherandClaude Opus 5.5 48ae9780a6 fix(pipeline): repeat-vote review fixes
- Return an unchanged copy for a non-finite/zero grid (NaN downbeat used to
  delete every guitar note).
- Skip an added note when a same-pitch note (bar, neighbouring bars or already
  added) lies within tol; avoids near-duplicates across bar lines.
- bench run/real: --no-repeat-vote passes { repeatVote: false } to the pipeline.
- Tests: notes before a late downbeat, bpm 0 / NaN / Infinity, trailing partial
  bar, half-tempo grid, neighbour-bar duplicate, pipeline-level on/off.

Eval unchanged: synthetic t1a 0.885 t1b 0.840 t1c 0.752 t1d 0.737, lnoe 0.507.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:21:00 +01:00

94 lines
4.7 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("notes before a late first downbeat are voted too", () => {
// Grid downbeat at 4.3 s; the riff starts at 0.3 s, so bars -2 and -1 precede the first downbeat.
const g: Grid = { ...grid, firstDownbeat: 4.3 };
const notes = bars(6, (b, pos) => !(b === 0 && pos === 0.5)).map((n) => ({ ...n, onset: n.onset + 0.3, offset: n.offset + 0.3 }));
expect(notes[0]!.onset).toBeLessThan(g.firstDownbeat);
const out = repeatVote(notes, g);
expect(out.length).toBe(30);
expect(out.some((n) => Math.abs(n.onset - 0.8) < 1e-6 && n.pitch === 55)).toBe(true);
});
test("non-finite or zero grid returns an unchanged copy", () => {
const notes = bars(6, (b, pos) => !(b === 3 && pos === 1));
for (const g of [
{ ...grid, bpm: 0 }, { ...grid, bpm: NaN }, { ...grid, bpm: Infinity },
{ ...grid, firstDownbeat: NaN }, { ...grid, firstDownbeat: -Infinity }, { ...grid, beatsPerBar: NaN },
]) {
const out = repeatVote(notes, g);
expect(out).toEqual(notes);
expect(out).not.toBe(notes);
}
});
test("notes after the last full bar are kept and voted", () => {
// 6 full bars plus a trailing partial bar holding only the riff's first note.
const notes = [...bars(6), { onset: 12, offset: 12.2, pitch: 52 }];
const out = repeatVote(notes, grid);
// Every input note survives (the trailing one is supported by the riff's downbeat note in all repeats) ...
for (const n of notes) expect(out.some((m) => Math.abs(m.onset - n.onset) < 1e-6 && m.pitch === n.pitch)).toBe(true);
// ... and anything added there stays inside that bar.
expect(out.every((n) => n.onset < 14)).toBe(true);
});
test("half-tempo grid (riff twice per bar) still fills a missing note", () => {
const g: Grid = { ...grid, bpm: 60 }; // 4 s bars
const notes = bars(8, (b, pos) => !(b === 5 && pos === 1));
const out = repeatVote(notes, g);
expect(out.some((n) => Math.abs(n.onset - 11) < 1e-6 && n.pitch === 57)).toBe(true);
});
test("added notes do not duplicate a same-pitch note within tol in a neighbouring bar", () => {
// The riff ends with a note 80 ms before the next downbeat. In bar 3 that note is 40 ms late, which moves it
// across the bar line into bar 4: bar 3 looks like it misses the note, but adding it would duplicate the late one.
const notes = bars(6).flatMap((n) => n.pitch === 52 ? [n, { onset: n.onset + 1.92, offset: n.onset + 2, pitch: 64 }] : [n])
.map((n) => (n.pitch === 64 && Math.abs(n.onset - 7.92) < 1e-9 ? { ...n, onset: 7.96, offset: 8.04 } : n));
const out = repeatVote(notes, grid, { drop: false });
expect(out.filter((n) => n.pitch === 64 && Math.abs(n.onset - 7.94) < 0.1).length).toBe(1);
// Without the late note, the gap is filled.
const gap = notes.filter((n) => !(n.pitch === 64 && n.onset === 7.96));
expect(repeatVote(gap, grid, { drop: false }).some((n) => n.pitch === 64 && Math.abs(n.onset - 7.92) < 1e-6)).toBe(true);
});