feat: quantisation and tab assignment

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:00:20 +01:00
1 parent eab70b24f6
commit bf93a4a32e
4 files changed
+279

No files matched your search

+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 };
}