feat: quantisation and tab assignment

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 05:00:20 +01:00
co-authored by Claude Sonnet 5.5
parent eab70b24f6
commit bf93a4a32e
4 changed files with 279 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
import type { Grid, ScoreNote, TimedNote } from "../core/types.ts";
import { TPQ } from "../core/types.ts";
import { secondsToTicks } from "../core/time.ts";
const CHORD_WINDOW_S = 0.035;
/**
* 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
* tick group gets the group's shortest duration (clipping to the next onset is left to the score writer); exact
* duplicates (same tick + pitch) are dropped; dur is always >= 1 step.
*/
export function quantize(notes: TimedNote[], grid: Grid, stepsPerBeat = 4): ScoreNote[] {
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);
}
type Q = { step: number; durSteps: number; pitch: number };
const qs: Q[] = [];
sorted.forEach((n, i) => {
const s0 = clusterStep[i]!;
if (s0 < 0) return;
const s1 = snap(n.offset);
qs.push({ step: s0, durSteps: Math.max(1, s1 - s0), pitch: n.pitch });
});
// Group by step; dedupe by pitch; chord duration = shortest.
const groups = new Map<number, Q[]>();
for (const q of qs) {
const g = groups.get(q.step);
if (!g) groups.set(q.step, [q]);
else if (!g.some((x) => x.pitch === q.pitch)) g.push(q);
}
const steps = [...groups.keys()].sort((a, b) => a - b);
const out: ScoreNote[] = [];
steps.forEach((s) => {
const g = groups.get(s)!;
let d = Math.min(...g.map((x) => x.durSteps));
d = Math.max(1, d);
for (const x of g.sort((a, b) => a.pitch - b.pitch)) out.push({ tick: s * step, dur: d * step, pitch: x.pitch });
});
return out;
}
+139
View File
@@ -0,0 +1,139 @@
import type { ScoreNote } from "../core/types.ts";
const SPAN_FREE = 4;
const SPAN_PENALTY = 2; // per fret beyond SPAN_FREE
const MOVE_WEIGHT = 0.5;
const LOW_FRET_WEIGHT = 0.1;
const OPEN_BONUS = 0.2;
const SKIP_COST = 100; // only used when a chord cannot be fully placed
interface Cand {
/** string (1-based) per note in the chord, or 0 if skipped */
strings: number[];
cost: number;
/** mean non-zero fret, or null if all open */
pos: number | null;
}
function chordCandidates(pitches: number[], tuning: number[], maxFret: number): Cand[] {
const n = pitches.length;
const all: { strings: number[]; placed: number }[] = [];
const cur: number[] = new Array(n).fill(0);
const used = new Array(tuning.length).fill(false);
const rec = (i: number, placed: number) => {
if (i === n) {
all.push({ strings: [...cur], placed });
return;
}
for (let s = 0; s < tuning.length; s++) {
if (used[s]) continue;
const fret = pitches[i]! - tuning[s]!;
if (fret < 0 || fret > maxFret) continue;
used[s] = true;
cur[i] = s + 1;
rec(i + 1, placed + 1);
used[s] = false;
cur[i] = 0;
}
rec(i + 1, placed); // skip this note
};
rec(0, 0);
const best = Math.max(...all.map((a) => a.placed));
return all
.filter((a) => a.placed === best)
.map((a) => {
const frets: number[] = [];
a.strings.forEach((s, i) => {
if (s) frets.push(pitches[i]! - tuning[s - 1]!);
});
const fretted = frets.filter((f) => f > 0);
const span = fretted.length ? Math.max(...fretted) - Math.min(...fretted) : 0;
const mean = frets.length ? frets.reduce((x, y) => x + y, 0) / frets.length : 0;
const opens = frets.length - fretted.length;
const cost =
span + SPAN_PENALTY * Math.max(0, span - SPAN_FREE) + LOW_FRET_WEIGHT * mean - OPEN_BONUS * opens + SKIP_COST * (n - best);
return { strings: a.strings, cost, pos: fretted.length ? fretted.reduce((x, y) => x + y, 0) / fretted.length : null };
});
}
/**
* Assign string/fret to each note (string 1 = highest). Chords are grouped by tick and
* decoded with Viterbi over per-chord candidate fingerings. Unplayable notes and chord
* overflow (keeping the highest pitches) are dropped and counted.
*/
export function assignTab(notes: ScoreNote[], tuning: number[], maxFret = 20): { notes: ScoreNote[]; dropped: number } {
let dropped = 0;
const byTick = new Map<number, number[]>(); // tick -> note indices
notes.forEach((n, i) => {
const playable = tuning.some((open) => n.pitch - open >= 0 && n.pitch - open <= maxFret);
if (!playable) {
dropped++;
return;
}
const g = byTick.get(n.tick);
if (g) g.push(i);
else byTick.set(n.tick, [i]);
});
const ticks = [...byTick.keys()].sort((a, b) => a - b);
const chords = ticks.map((t) => {
let idx = byTick.get(t)!;
if (idx.length > tuning.length) {
idx = [...idx].sort((a, b) => notes[b]!.pitch - notes[a]!.pitch).slice(0, tuning.length);
dropped += byTick.get(t)!.length - idx.length;
}
return idx.sort((a, b) => notes[b]!.pitch - notes[a]!.pitch);
});
// Viterbi. State carries a held hand position (kept through all-open chords).
type State = { cand: Cand; total: number; held: number | null; back: number };
const layers: State[][] = [];
chords.forEach((idx, ci) => {
const cands = chordCandidates(idx.map((i) => notes[i]!.pitch), tuning, maxFret);
const prev = ci > 0 ? layers[ci - 1]! : null;
layers.push(
cands.map((cand) => {
if (!prev) return { cand, total: cand.cost, held: cand.pos, back: -1 };
let bestTotal = Infinity;
let bestBack = 0;
let bestHeld: number | null = null;
prev.forEach((p, pi) => {
const move = cand.pos !== null && p.held !== null ? MOVE_WEIGHT * Math.abs(cand.pos - p.held) : 0;
const total = p.total + move + cand.cost;
if (total < bestTotal) {
bestTotal = total;
bestBack = pi;
bestHeld = cand.pos ?? p.held;
}
});
return { cand, total: bestTotal, held: bestHeld, back: bestBack };
}),
);
});
const chosen = new Map<number, { string: number; fret: number }>();
let si = -1;
for (let ci = chords.length - 1; ci >= 0; ci--) {
const layer = layers[ci]!;
if (si < 0) {
si = 0;
layer.forEach((s, k) => {
if (s.total < layer[si]!.total) si = k;
});
}
const st = layer[si]!;
chords[ci]!.forEach((noteIdx, k) => {
const s = st.cand.strings[k]!;
if (s) chosen.set(noteIdx, { string: s, fret: notes[noteIdx]!.pitch - tuning[s - 1]! });
else dropped++;
});
si = st.back;
}
const out: ScoreNote[] = [];
notes.forEach((n, i) => {
const c = chosen.get(i);
if (c) out.push({ ...n, string: c.string, fret: c.fret });
});
return { notes: out, dropped };
}
+35
View File
@@ -0,0 +1,35 @@
import { test, expect } from "bun:test";
import { quantize } 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 });
const g = { bpm: 120, firstDownbeat: 0.1, beatsPerBar: 4 };
test("snaps onsets and durations to the 16th grid relative to the first downbeat", () => {
const q = quantize([t(0.1 + 0.5 + 0.02, 0.1 + 1.0, 60), t(0.1 + 0.75 - 0.015, 0.1 + 1.0, 64)], g);
expect(q.map((n) => [n.tick, n.dur, n.pitch])).toEqual([[960, 960, 60], [1440, 480, 64]]);
});
test("drops notes before the first downbeat", () => {
const q = quantize([t(0.0, 0.3, 60), t(0.1, 0.4, 62)], g);
expect(q.map((n) => n.pitch)).toEqual([62]);
});
test("merges chord notes within 35 ms, with one consistent duration", () => {
const q = quantize([t(0.6, 1.6, 60), t(0.625, 1.1, 64), t(0.63, 1.6, 67)], g);
expect(new Set(q.map((n) => n.tick)).size).toBe(1);
expect(new Set(q.map((n) => n.dur)).size).toBe(1);
expect(q).toHaveLength(3);
});
test("collapses exact duplicates, enforces min duration", () => {
const q = quantize([t(0.1, 0.101, 60), t(0.1, 0.101, 60), t(0.225, 3, 62)], g);
expect(q.map((n) => [n.tick, n.pitch])).toEqual([[0, 60], [240, 62]]);
expect(q[0]!.dur).toBe(240);
for (const n of q) {
expect(n.tick % 240).toBe(0);
expect(n.dur % 240).toBe(0);
expect(n.dur).toBeGreaterThanOrEqual(240);
}
});
+50
View File
@@ -0,0 +1,50 @@
import { test, expect } from "bun:test";
import { assignTab } from "../../src/pipeline/tab.ts";
import { GUITAR_STANDARD, BASS_STANDARD } from "../../src/core/types.ts";
import type { ScoreNote } from "../../src/core/types.ts";
const sn = (tick: number, dur: number, pitch: number): ScoreNote => ({ tick, dur, pitch });
test("chord gets playable, injective tab", () => {
const chordC = [48, 52, 55, 60, 64].map((p) => sn(0, 960, p));
const a = assignTab(chordC, GUITAR_STANDARD);
expect(a.dropped).toBe(0);
for (const n of a.notes) expect(GUITAR_STANDARD[n.string! - 1]! + n.fret!).toBe(n.pitch);
expect(new Set(a.notes.map((n) => n.string)).size).toBe(5);
expect(Math.max(...a.notes.map((n) => n.fret!))).toBeLessThanOrEqual(3);
});
test("drops unplayable notes", () => {
expect(assignTab([sn(0, 960, 30)], GUITAR_STANDARD)).toEqual({ notes: [], dropped: 1 });
expect(assignTab([sn(0, 960, 90)], GUITAR_STANDARD)).toEqual({ notes: [], dropped: 1 });
});
test("keeps highest notes when chord exceeds string count", () => {
const a = assignTab([40, 45, 50, 55, 59, 64, 67].map((p) => sn(0, 960, p)), GUITAR_STANDARD);
expect(a.dropped).toBe(1);
expect(a.notes.map((n) => n.pitch).sort()).toEqual([45, 50, 55, 59, 64, 67]);
});
test("a scale run stays in a low, smoothly moving position", () => {
const run = [60, 62, 64, 65, 67, 69, 71, 72].map((p, i) => sn(i * 240, 240, p));
const a = assignTab(run, GUITAR_STANDARD);
expect(a.dropped).toBe(0);
const frets = a.notes.map((n) => n.fret!);
expect(Math.max(...frets)).toBeLessThanOrEqual(9);
for (let i = 1; i < frets.length; i++) expect(Math.abs(frets[i]! - frets[i - 1]!)).toBeLessThanOrEqual(4);
});
test("bass notes are valid and respect maxFret", () => {
const a = assignTab([28, 33, 38, 43, 80].map((p, i) => sn(i * 240, 240, p)), BASS_STANDARD, 20);
for (const n of a.notes) {
expect(BASS_STANDARD[n.string! - 1]! + n.fret!).toBe(n.pitch);
expect(n.fret!).toBeLessThanOrEqual(20);
}
expect(a.dropped).toBe(1);
});
test("two notes that only fit the low E string: one dropped", () => {
const a = assignTab([sn(0, 240, 40), sn(0, 240, 41)], GUITAR_STANDARD, 20);
expect(a.notes.length).toBe(1);
expect(a.dropped).toBe(1);
});