diff --git a/src/pipeline/quantize.ts b/src/pipeline/quantize.ts index c88c3fd..9d1d2cf 100644 --- a/src/pipeline/quantize.ts +++ b/src/pipeline/quantize.ts @@ -4,6 +4,28 @@ import { secondsToTicks } from "../core/time.ts"; const CHORD_WINDOW_S = 0.035; +/** + * Snapped tick for every input note (index-aligned with `notes`), exactly as quantize() computes it: + * onsets within 35 ms of a cluster's first onset share that onset's snapped step. May be negative (before the downbeat). + */ +export function snapTicks(notes: TimedNote[], grid: Grid, stepsPerBeat = 4): number[] { + const step = TPQ / stepsPerBeat; + const snap = (sec: number) => Math.round(secondsToTicks(sec - grid.firstDownbeat, grid.bpm) / step); + const order = notes.map((_, i) => i).sort((a, b) => notes[a]!.onset - notes[b]!.onset || notes[a]!.pitch - notes[b]!.pitch); + const out = new Array(notes.length); + let anchor = -Infinity; + let anchorStep = 0; + for (const i of order) { + const n = notes[i]!; + if (n.onset - anchor > CHORD_WINDOW_S) { + anchor = n.onset; + anchorStep = snap(n.onset); + } + out[i] = anchorStep * step; + } + return out; +} + /** * Snap timed notes to a step grid (default 16ths) relative to grid.firstDownbeat. * Notes starting within 35 ms of a cluster's first onset share one tick; every note in a @@ -14,18 +36,10 @@ export function quantize(notes: TimedNote[], grid: Grid, stepsPerBeat = 4): Scor const step = TPQ / stepsPerBeat; const snap = (sec: number) => Math.round(secondsToTicks(sec - grid.firstDownbeat, grid.bpm) / step); - const sorted = [...notes].sort((a, b) => a.onset - b.onset || a.pitch - b.pitch); - // Cluster onsets (anchored at the cluster's first onset so clusters cannot chain). - const clusterStep: number[] = []; - let anchor = -Infinity; - let anchorStep = 0; - for (const n of sorted) { - if (n.onset - anchor > CHORD_WINDOW_S) { - anchor = n.onset; - anchorStep = snap(n.onset); - } - clusterStep.push(anchorStep); - } + const ticks = snapTicks(notes, grid, stepsPerBeat); + const order = notes.map((_, i) => i).sort((x, y) => notes[x]!.onset - notes[y]!.onset || notes[x]!.pitch - notes[y]!.pitch); + const sorted = order.map((i) => notes[i]!); + const clusterStep = order.map((i) => ticks[i]! / step); type Q = { step: number; durSteps: number; pitch: number }; const qs: Q[] = []; diff --git a/src/pipeline/run.ts b/src/pipeline/run.ts index 1ef3e3d..1a9bdf1 100644 --- a/src/pipeline/run.ts +++ b/src/pipeline/run.ts @@ -2,14 +2,13 @@ // Pure: no file I/O; callers pass model bytes. import type { Audio, Grid, Role, ScoreNote, Song, TimedNote, Track, TruthTrack } from "../core/types.ts"; import { BASS_STANDARD, GUITAR_STANDARD, TPQ } from "../core/types.ts"; -import { secondsToTicks } from "../core/time.ts"; import type { OracleFlags, OutTrack, PipelineOutput } from "../eval/score.ts"; import { songToGp } from "../score/gp.ts"; import { createSeparator } from "./separate.ts"; import { createTranscriber, type TranscribeOpts } from "./transcribe.ts"; import { transcribeDrums } from "./drums.ts"; import { estimateGrid, gridFromTruth } from "./tempo.ts"; -import { quantize } from "./quantize.ts"; +import { quantize, snapTicks } from "./quantize.ts"; import { assignTab } from "./tab.ts"; export interface PipelineInput { @@ -26,28 +25,19 @@ const RANGE: Record, TranscribeOpts> = { vocals: { minPitch: 48, maxPitch: 84, monophonic: true }, }; const TUNING: Partial> = { guitar: GUITAR_STANDARD, bass: BASS_STANDARD }; -const STEP = TPQ / 4; const DEFAULT_GRID: Grid = { bpm: 120, firstDownbeat: 0, beatsPerBar: 4 }; interface Job { name: string; role: Role; audio: Audio | null; truthNotes: TimedNote[] | null } -/** Copy string/fret from tab-assigned score notes onto the timed notes they came from (same pitch, nearest tick). */ -function copyFingering(timed: TimedNote[], assigned: ScoreNote[], grid: Grid): void { - const used = new Set(); - for (const n of timed) { - const tick = secondsToTicks(n.onset - grid.firstDownbeat, grid.bpm); - let best = -1, bestD = Infinity; - assigned.forEach((a, i) => { - if (used.has(i) || a.pitch !== n.pitch) return; - const d = Math.abs(a.tick - tick); - if (d < bestD) { bestD = d; best = i; } - }); - if (best >= 0 && bestD <= STEP) { - used.add(best); - n.string = assigned[best]!.string; - n.fret = assigned[best]!.fret; - } - } +/** Copy string/fret from tab-assigned score notes onto the timed notes they came from (same snapped tick + pitch). */ +export function copyFingering(timed: TimedNote[], assigned: ScoreNote[], grid: Grid): void { + const byKey = new Map(); + for (const a of assigned) byKey.set(`${a.tick}:${a.pitch}`, a); + const ticks = snapTicks(timed, grid); + timed.forEach((n, i) => { + const a = byKey.get(`${ticks[i]}:${n.pitch}`); + if (a) { n.string = a.string; n.fret = a.fret; } + }); } export async function createPipeline( @@ -117,7 +107,7 @@ export async function createPipeline( } const barTicks = TPQ * 4; - const endTick = Math.max(0, ...songTracks.flatMap((t) => t.notes.map((n) => n.tick + n.dur))); + const endTick = songTracks.reduce((m, t) => t.notes.reduce((mm, n) => Math.max(mm, n.tick + n.dur), m), 0); const song: Song = { title: "STSheets output", tier: "pipeline", seed: 0, bpm: grid.bpm, timeSig: [4, 4], bars: Math.max(1, Math.ceil(endTick / barTicks)), key: "C", tracks: songTracks, diff --git a/tests/pipeline/quantize.test.ts b/tests/pipeline/quantize.test.ts index 19fa048..9ea4ea1 100644 --- a/tests/pipeline/quantize.test.ts +++ b/tests/pipeline/quantize.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "bun:test"; -import { quantize } from "../../src/pipeline/quantize.ts"; +import { quantize, snapTicks } from "../../src/pipeline/quantize.ts"; import type { TimedNote } from "../../src/core/types.ts"; const t = (onset: number, offset: number, pitch: number): TimedNote => ({ onset, offset, pitch }); @@ -33,3 +33,10 @@ test("collapses exact duplicates, enforces min duration", () => { } }); + +test("snapTicks matches quantize ticks, incl. chord clusters and adjacent repeated pitches", () => { + // two same-pitch notes 20 ms apart merge; the next repeat is on the adjacent 16th (125 ms at 120 bpm) + const notes = [t(0.1, 0.2, 60), t(0.12, 0.2, 60), t(0.225, 0.3, 60)]; + expect(snapTicks(notes, g)).toEqual([0, 0, 240]); + expect(quantize(notes, g).map((n) => n.tick)).toEqual([0, 240]); +}); diff --git a/tests/pipeline/run.test.ts b/tests/pipeline/run.test.ts index 90170fd..aab9904 100644 --- a/tests/pipeline/run.test.ts +++ b/tests/pipeline/run.test.ts @@ -1,7 +1,9 @@ import { test, expect, beforeAll } from "bun:test"; import { join } from "node:path"; import { buildSong, loadSongDir } from "../../src/bench/dataset.ts"; -import { createPipeline } from "../../src/pipeline/run.ts"; +import { createPipeline, copyFingering } from "../../src/pipeline/run.ts"; +import { snapTicks, quantize } from "../../src/pipeline/quantize.ts"; +import type { TimedNote } from "../../src/core/types.ts"; import { truthFromGp } from "../../src/score/gp.ts"; import { scoreSong } from "../../src/eval/score.ts"; @@ -19,10 +21,9 @@ beforeAll(async () => { test("t1a/1 oracle stems+tempo: guitar track, GP re-imports", async () => { const d = await loadSongDir(join(root, "data/t1a/1")); const flags = { oracleStems: true, oracleTempo: true, oracleNotes: false }; - const t0 = performance.now(); const { output, gp } = await run({ mix: d.mix, oracle: { stems: d.stems, truth: d.truth } }, flags); const s = scoreSong(d.truth, output, flags); - console.log(`t1a oracleNotes=false ${(performance.now() - t0).toFixed(0)}ms F1=${s.guitarMerged.f1} str=${s.stringAgreement}`); + expect(s.guitarMerged.f1!).toBeGreaterThan(0.7); const g = output.tracks.filter((t) => t.role === "guitar"); expect(g.length).toBe(1); expect(g[0]!.notes.length).toBeGreaterThan(0); @@ -34,7 +35,6 @@ test("t1a/1 oracle notes: merged guitar F1 === 1, stringAgreement is a number", const flags = { oracleStems: true, oracleTempo: true, oracleNotes: true }; const { output } = await run({ mix: d.mix, oracle: { stems: d.stems, truth: d.truth } }, flags); const s = scoreSong(d.truth, output, flags); - console.log(`t1a oracleNotes=true F1=${s.guitarMerged.f1} str=${s.stringAgreement}`); expect(s.guitarMerged.f1).toBe(1); expect(typeof s.stringAgreement).toBe("number"); }, SLOW); @@ -42,11 +42,51 @@ test("t1a/1 oracle notes: merged guitar F1 === 1, stringAgreement is a number", test("t1c/1 full pipeline yields all roles", async () => { const d = await loadSongDir(join(root, "data/t1c/1")); const flags = { oracleStems: false, oracleTempo: false, oracleNotes: false }; - const t0 = performance.now(); const { output, gp, warnings } = await run({ mix: d.mix }, flags); const s = scoreSong(d.truth, output, flags); - console.log(`t1c full ${(performance.now() - t0).toFixed(0)}ms bpm=${output.grid.bpm} (true ${d.truth.bpm}) F1=${s.guitarMerged.f1} warnings=${JSON.stringify(warnings)} rhythm=${JSON.stringify(s.rhythm)}`); const roles = new Set(output.tracks.map((t) => t.role)); for (const r of ["guitar", "bass", "keys", "vocals", "drums"]) expect(roles.has(r as never)).toBe(true); expect(gp.length).toBeGreaterThan(0); + expect(warnings.some((w) => w.includes("other"))).toBe(true); + expect(Math.abs(output.grid.bpm - d.truth.bpm)).toBeLessThan(2); + expect(s.guitarMerged.f1!).toBeGreaterThan(0.3); }, SLOW); + +test("t1d/1 all oracle flags: two guitar tracks, partSplit and merged F1 === 1", async () => { + await buildSong("t1d", 1); + const d = await loadSongDir(join(root, "data/t1d/1")); + const flags = { oracleStems: true, oracleTempo: true, oracleNotes: true }; + const { output } = await run({ mix: d.mix, oracle: { stems: d.stems, truth: d.truth } }, flags); + const s = scoreSong(d.truth, output, flags); + const names = output.tracks.filter((t) => t.role === "guitar").map((t) => t.name).sort(); + expect(names).toEqual(d.truth.tracks.filter((t) => t.role === "guitar").map((t) => t.name).sort()); + expect(names.length).toBe(2); + expect(s.partSplit).not.toBeNull(); + expect(s.partSplit!.f1).toBe(1); + expect(s.guitarMerged.f1).toBe(1); +}, SLOW); + +test("oracle flag without input.oracle throws", async () => { + const d = await loadSongDir(join(root, "data/t1a/1")); + await expect(run({ mix: d.mix }, { oracleStems: false, oracleTempo: true, oracleNotes: false })).rejects.toThrow(/oracle/); +}); + +test("silent mix with oracle stems: estimateGrid fallback warns and does not crash", async () => { + const d = await loadSongDir(join(root, "data/t1a/1")); + const silent = { sampleRate: 44100, channels: [new Float32Array(44100 * 3)] }; + const flags = { oracleStems: true, oracleTempo: false, oracleNotes: true }; + const { output, warnings } = await run({ mix: silent, oracle: { stems: d.stems, truth: d.truth } }, flags); + expect(output.grid).toEqual({ bpm: 120, firstDownbeat: 0, beatsPerBar: 4 }); + expect(warnings.some((w) => w.includes("estimateGrid failed"))).toBe(true); +}); + +test("copyFingering matches by snapped tick + pitch (merged cluster, adjacent repeats)", () => { + const grid = { bpm: 120, firstDownbeat: 0.1, beatsPerBar: 4 }; + const n = (onset: number, pitch: number): TimedNote => ({ onset, offset: onset + 0.1, pitch }); + // a,b same pitch 20 ms apart (merge into one tick 0); c same pitch on the adjacent 16th (tick 240); d another pitch at 0 + const timed = [n(0.1, 60), n(0.12, 60), n(0.225, 60), n(0.1, 64)]; + expect(snapTicks(timed, grid)).toEqual([0, 0, 240, 0]); + const assigned = quantize(timed, grid).map((q) => ({ ...q, string: q.pitch === 60 ? (q.tick === 0 ? 2 : 3) : 1, fret: q.tick })); + copyFingering(timed, assigned, grid); + expect(timed.map((x) => [x.string, x.fret])).toEqual([[2, 0], [2, 0], [3, 240], [1, 0]]); +});