fix: pipeline fingering uses quantiser snap; more run tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 05:11:19 +01:00
co-authored by Claude Sonnet 5.5
parent 604d1a67af
commit 4c89f0d664
4 changed files with 91 additions and 40 deletions
+26 -12
View File
@@ -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<number>(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[] = [];
+11 -21
View File
@@ -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<Exclude<Role, "drums">, TranscribeOpts> = {
vocals: { minPitch: 48, maxPitch: 84, monophonic: true },
};
const TUNING: Partial<Record<Role, number[]>> = { 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<number>();
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<string, ScoreNote>();
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,
+8 -1
View File
@@ -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]);
});
+46 -6
View File
@@ -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]]);
});