From bf93a4a32ee39e3f7b27d67138a969a4b67fb370 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 05:00:20 +0100 Subject: [PATCH] feat: quantisation and tab assignment Co-Authored-By: Claude Sonnet 5.5 --- src/pipeline/quantize.ts | 55 +++++++++++++ src/pipeline/tab.ts | 139 ++++++++++++++++++++++++++++++++ tests/pipeline/quantize.test.ts | 35 ++++++++ tests/pipeline/tab.test.ts | 50 ++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 src/pipeline/quantize.ts create mode 100644 src/pipeline/tab.ts create mode 100644 tests/pipeline/quantize.test.ts create mode 100644 tests/pipeline/tab.test.ts diff --git a/src/pipeline/quantize.ts b/src/pipeline/quantize.ts new file mode 100644 index 0000000..c88c3fd --- /dev/null +++ b/src/pipeline/quantize.ts @@ -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(); + 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; +} diff --git a/src/pipeline/tab.ts b/src/pipeline/tab.ts new file mode 100644 index 0000000..9400e8b --- /dev/null +++ b/src/pipeline/tab.ts @@ -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(); // 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(); + 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 }; +} diff --git a/tests/pipeline/quantize.test.ts b/tests/pipeline/quantize.test.ts new file mode 100644 index 0000000..19fa048 --- /dev/null +++ b/tests/pipeline/quantize.test.ts @@ -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); + } +}); + diff --git a/tests/pipeline/tab.test.ts b/tests/pipeline/tab.test.ts new file mode 100644 index 0000000..f2dbbd6 --- /dev/null +++ b/tests/pipeline/tab.test.ts @@ -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); +});