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 WintherandClaude Sonnet 5.5 committed 2026-10-30 05:11:19 +01:00
1 parent 604d1a67af
commit 4c89f0d664
4 files changed
+91 -40

No files matched your search

+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,