diff --git a/src/bench/cli.ts b/src/bench/cli.ts index 7a2c13b..3ca9069 100644 --- a/src/bench/cli.ts +++ b/src/bench/cli.ts @@ -44,18 +44,18 @@ async function main() { const seeds = parseSeeds(arg(rest, "seeds", "1-5")); const modes = arg(rest, "modes", "full,oracle-stems,oracle-tempo,oracle-notes").split(","); const runId = arg(rest, "run-id", new Date().toISOString().replace(/[:.]/g, "-")); - await runBench({ tiers, seeds, modes, runId, root: arg(rest, "root", "data"), out: arg(rest, "out", "runs") }); + await runBench({ tiers, seeds, modes, runId, root: arg(rest, "root", "data"), out: arg(rest, "out", "runs"), repeatVote: !rest.includes("--no-repeat-vote") }); console.log(`results/${runId}.md`); break; } case "real": { const runId = arg(rest, "run-id", new Date().toISOString().replace(/[:.]/g, "-")); - await runReal({ runId, auto: rest.includes("--auto"), root: arg(rest, "root", "reference"), out: arg(rest, "out", "runs") }); + await runReal({ runId, auto: rest.includes("--auto"), root: arg(rest, "root", "reference"), out: arg(rest, "out", "runs"), repeatVote: !rest.includes("--no-repeat-vote") }); console.log(`results/real-${runId}.md`); break; } default: - console.error("usage: bun run bench gen [--tiers t1a,t1b] [--seeds 1-5] [--root data]\n bun run bench run [--tiers ..] [--seeds ..] [--modes full,oracle-stems,oracle-tempo,oracle-notes] [--run-id id] [--root data] [--out runs]\n bun run bench real [--auto] [--run-id id] [--root reference] [--out runs]"); + console.error("usage: bun run bench gen [--tiers t1a,t1b] [--seeds 1-5] [--root data]\n bun run bench run [--tiers ..] [--seeds ..] [--modes full,oracle-stems,oracle-tempo,oracle-notes] [--run-id id] [--root data] [--out runs] [--no-repeat-vote]\n bun run bench real [--auto] [--run-id id] [--root reference] [--out runs] [--no-repeat-vote]"); process.exit(1); } } diff --git a/src/bench/real.ts b/src/bench/real.ts index d96981e..ce71c89 100644 --- a/src/bench/real.ts +++ b/src/bench/real.ts @@ -36,13 +36,13 @@ const MODELS = { demucs: "models/demucs/htdemucs_6s.onnx", basicPitch: "models/b const SF2 = join(import.meta.dir, "../../node_modules/@coderline/alphatab/dist/soundfont/sonivox.sf2"); const TOL = 0.05, LENIENT_TOL = 0.1; -export async function runReal(opts: { runId: string; auto: boolean; root?: string; out?: string; resultsDir?: string }): Promise { +export async function runReal(opts: { runId: string; auto: boolean; root?: string; out?: string; resultsDir?: string; repeatVote?: boolean }): Promise { const root = opts.root ?? "reference", out = opts.out ?? "runs", rdir = opts.resultsDir ?? "results"; const songs = await readManifest(root); const pipeline = await createPipeline({ demucs: new Uint8Array(await Bun.file(MODELS.demucs).arrayBuffer()), basicPitch: new Uint8Array(await Bun.file(MODELS.basicPitch).arrayBuffer()), - }); + }, { repeatVote: opts.repeatVote ?? true }); const sf2 = opts.auto ? new Uint8Array(await Bun.file(SF2).arrayBuffer()) : null; const results: RealSongResult[] = []; const skipped: SkippedSong[] = []; diff --git a/src/bench/runner.ts b/src/bench/runner.ts index f819a3f..fd5a9cd 100644 --- a/src/bench/runner.ts +++ b/src/bench/runner.ts @@ -25,11 +25,13 @@ async function sha256(bytes: Uint8Array): Promise { export async function runBench(opts: { tiers: TierId[]; seeds: number[]; modes: string[]; runId: string; root: string; out: string; resultsDir?: string; + /** Guitar repetition voting (default on); false = --no-repeat-vote. */ + repeatVote?: boolean; }): Promise { for (const m of opts.modes) if (!Object.hasOwn(MODES, m)) throw new Error(`unknown mode: ${m}`); const demucs = new Uint8Array(await Bun.file(MODEL_PATHS.demucs).arrayBuffer()); const basicPitch = new Uint8Array(await Bun.file(MODEL_PATHS.basicPitch).arrayBuffer()); - const pipeline = await createPipeline({ demucs, basicPitch }); // model load once per run + const pipeline = await createPipeline({ demucs, basicPitch }, { repeatVote: opts.repeatVote ?? true }); // model load once per run const run: BenchRun = { runId: opts.runId, date: new Date().toISOString(), diff --git a/src/pipeline/repeat.ts b/src/pipeline/repeat.ts index 3c8b1eb..ddcddb7 100644 --- a/src/pipeline/repeat.ts +++ b/src/pipeline/repeat.ts @@ -88,7 +88,8 @@ export function barSimilarity( /** Vote notes across repeated bars. Returns a new, onset-sorted note list; input notes are not modified. */ export function repeatVote(notes: TimedNote[], grid: Grid, opts: RepeatOpts = {}): TimedNote[] { const o = { ...REPEAT_DEFAULTS, ...opts }; - if (notes.length === 0 || !(grid.bpm > 0) || !(grid.beatsPerBar > 0)) return notes.slice(); + const finite = Number.isFinite(grid.bpm) && Number.isFinite(grid.firstDownbeat) && Number.isFinite(grid.beatsPerBar); + if (notes.length === 0 || !finite || grid.bpm <= 0 || grid.beatsPerBar <= 0) return notes.slice(); const { byBar, barSec, bar0 } = barsOf(notes, grid); const nB = byBar.length; const { coarse, exact } = barSimilarity(byBar.map((ps) => ps.map((p) => ({ pitch: p.n.pitch, rel: p.rel }))), barSec, o.tol, o.context); @@ -117,11 +118,19 @@ export function repeatVote(notes: TimedNote[], grid: Grid, opts: RepeatOpts = {} if (c.bars.has(j)) continue; c.bars.add(j); c.rels.push(q.rel); c.durs.push(q.n.offset - q.n.onset); } + const added: TimedNote[] = []; + // Near-duplicate guard: no added note within tol of a same-pitch note already in the bar (or its neighbours, + // or already added) - an onset just outside tol in the bar itself would otherwise get a second copy. + const near = (onset: number, pitch: number) => [...(byBar[i - 1] ?? []), ...bar, ...(byBar[i + 1] ?? [])] + .some((q) => q.n.pitch === pitch && Math.abs(q.n.onset - onset) <= o.tol) || + added.some((a) => a.pitch === pitch && Math.abs(a.onset - onset) <= o.tol); for (const c of cands) { if (c.bars.size < o.minAddSupport || c.bars.size / reps.length < o.addFrac) continue; const onset = bar0 + i * barSec + median(c.rels); - if (onset >= 0) out.push({ onset, offset: onset + median(c.durs), pitch: c.pitch }); + if (onset < 0 || near(onset, c.pitch)) continue; + added.push({ onset, offset: onset + median(c.durs), pitch: c.pitch }); } + out.push(...added); } return out.sort((a, b) => a.onset - b.onset || a.pitch - b.pitch); } diff --git a/tests/pipeline/repeat.test.ts b/tests/pipeline/repeat.test.ts index 4b8bce6..12e080c 100644 --- a/tests/pipeline/repeat.test.ts +++ b/tests/pipeline/repeat.test.ts @@ -41,9 +41,53 @@ test("add/drop can be disabled and empty input is handled", () => { 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 })); +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.some((n) => Math.abs(n.onset - 5) < 1e-6 && n.pitch === 55)).toBe(true); + 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); }); diff --git a/tests/pipeline/run.test.ts b/tests/pipeline/run.test.ts index d5c35d8..baffb6d 100644 --- a/tests/pipeline/run.test.ts +++ b/tests/pipeline/run.test.ts @@ -2,6 +2,8 @@ import { test, expect, beforeAll } from "bun:test"; import { join } from "node:path"; import { buildSong, loadSongDir } from "../../src/bench/dataset.ts"; import { createPipeline, copyFingering, sumAudio } from "../../src/pipeline/run.ts"; +import { createTranscriber } from "../../src/pipeline/transcribe.ts"; +import { repeatVote } from "../../src/pipeline/repeat.ts"; import { snapTicks, quantize } from "../../src/pipeline/quantize.ts"; import type { TimedNote } from "../../src/core/types.ts"; import { truthFromGp } from "../../src/score/gp.ts"; @@ -99,3 +101,21 @@ test("sumAudio adds buffers sample-wise", () => { expect([...s.channels[1]!]).toEqual([1, 2]); expect(s.sampleRate).toBe(10); }); + +test("repeatVote option: off = raw transcription, on (default) = repeat-voted", async () => { + // Cheap fixture: oracle stems + oracle tempo (no separation), t1a/1 guitar stem. + const bytes = async (p: string) => new Uint8Array(await Bun.file(join(root, p)).arrayBuffer()); + const off = await createPipeline({ demucs: await bytes("models/demucs/htdemucs_6s.onnx"), basicPitch: await bytes("models/basic-pitch/nmp.onnx") }, { repeatVote: false }); + const transcribe = await createTranscriber(await bytes("models/basic-pitch/nmp.onnx")); + const d = await loadSongDir(join(root, "data/t1a/1")); + const flags = { oracleStems: true, oracleTempo: true, oracleNotes: false }; + const input = { mix: d.mix, oracle: { stems: d.stems, truth: d.truth } }; + const gt = d.truth.tracks.find((t) => t.role === "guitar")!; + const raw = await transcribe(d.stems.get(gt.name)!, { minPitch: 40, maxPitch: 88 }); + const strip = (ns: TimedNote[]) => ns.map((n) => [n.onset, n.offset, n.pitch]); + const offNotes = (await off(input, flags)).output.tracks.find((t) => t.role === "guitar")!.notes; + expect(strip(offNotes)).toEqual(strip(raw)); + const onRes = await run(input, flags); + const onNotes = onRes.output.tracks.find((t) => t.role === "guitar")!.notes; + expect(strip(onNotes)).toEqual(strip(repeatVote(raw, onRes.output.grid))); +}, SLOW);