fix: pipeline fingering uses quantiser snap; more run tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
604d1a67af
commit
4c89f0d664
+26
-12
@@ -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
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user