tutts
v0.1.0
Published
Renderer-neutral MIDI to guitar-tab fingering engine (TypeScript port of tuttut).
Downloads
52
Maintainers
Readme
tutts
Renderer-neutral MIDI→guitar-tab fingering engine. A from-scratch, idiomatic TypeScript port of tuttut's algorithm: it frames fingering as a Hidden Markov Model (fretboard positions = hidden states, note onsets = observations) and uses Viterbi to pick the sequence that is easiest to play under a fret-height/span/string-change cost metric.
The core is pure (zero runtime dependencies), ESM-only, and speaks Ableton's beat-based note model. It emits renderer-neutral structured tab data plus a built-in ASCII renderer; a thin adapter converts the structured data to a specific tablature renderer's format.
Install
pnpm add tuttsUsage
import { generateTab, Tuning } from "tutts";
const tab = generateTab({
notes: [
{ midi: 64, startBeats: 0, durationBeats: 1 }, // E4
{ midi: 60, startBeats: 1, durationBeats: 1 }, // C4
],
tuning: Tuning.standardGuitar(),
// timeSignatures defaults to a single 4/4 at beat 0
});
console.log(tab.toAscii()); // ASCII tab as one string
tab.toLines(); // string[]: one line per string (blank line between systems)
tab.toSystems(); // string[][]: one block of string-lines per system
tab.data; // TabData: the renderer-neutral resultNoteModel is { midi, startBeats, durationBeats } (C4 = 60, time in quarter-note beats): the Ableton-native projection of the Extensions SDK's note shape, so notes from an Ableton clip drop in with no adapter.
Input options
generateTab(input) accepts:
| Option | Type | Default | Effect |
| --- | --- | --- | --- |
| notes | NoteModel[] | required | Unmuted notes, any order (sorted internally). |
| tuning | Tuning | required | E.g. Tuning.standardGuitar(), Tuning.ukulele(), or new Tuning(["D4", "A3", ...], nfrets). |
| timeSignatures | TimeSignatureChange[] | one 4/4 at beat 0 | Piecewise measure grid. Notes before the first entry's startBeats are ignored (the grid starts there). |
| weights | DifficultyWeights | all 1 | Cost-metric weights. b is the Laplace scale and must be greater than 0 (validated; throws otherwise). |
| tempo | number | unset | BPM. Only used to emit an optional seconds field on each event. |
| quantizeGrid | "1/4" \| "1/8" \| "1/16" \| "1/32" | unset | Snap note onsets to the grid before chord grouping, so near-simultaneous notes merge into one chord. |
ASCII rendering
toAscii(), toLines(), and toSystems() all take an optional AsciiRenderOptions:
tab.toAscii({ maxWidth: 80, timeSignature: true });maxWidth: wrap the tab into stacked systems no wider than this many columns. Each system repeats the string-name header. A measure is never split: a lone measure wider thanmaxWidthoverflows rather than breaking. Omitted or<= 0produces a single unwrapped system.timeSignature: render the time signature (stacked numerator over denominator on the center rows; inline4/4on a 1-string tuning) in the measure where it takes effect, including every mid-tab change. Defaultfalse.
Fret cells use a uniform width (the widest fret number in the tab), so multi-digit frets never skew the beat grid. renderAsciiTab(data, tuning, opts) is also exported for rendering a TabData you already have.
Output: TabData
tab.data is renderer-neutral and carries everything a tablature renderer needs:
interface TabData {
tuning: number[]; // string pitches, thin → thick
measures: {
events: {
beats: number;
measureTiming: number; // [0, 1) within the measure
seconds?: number; // present iff `tempo` was supplied
timeSignatureChange?: [number, number];
notes?: {
string: number; // 0 = thinnest string
fret: number;
midi: number; // sounding pitch
durationBeats: number; // drives note values & rests downstream
degree: string; // e.g. "F#"
octave: string; // e.g. "4"
}[];
}[];
}[];
}An event whose chord has no physically possible fingering carries notes: [] (see divergence 2 below). durationBeats is passed through from the input untouched, including zero or negative values.
Feeding a tablature renderer
The core stops at renderer-neutral positions plus rhythmic onsets and durations; turning those into a specific renderer's format is adapter work (the tutts-alphatab sibling maps TabData to alphaTex). The adapter owns two renderer-specific concerns:
- String numbering. This core indexes string
0as the thinnest; alphaTex/Guitar Pro use a 1-based numbering where string 1 is the highest-pitched. The adapter reconciles the two. - Note values & rests.
durationBeatsis a beat count; the adapter decomposes it into the renderer's note-value tokens and inserts rests, beams, and ties.
Everything the adapter needs is on TabData: per note string, fret, midi, durationBeats; per event the onset beats; plus tuning, time signature(s), and optional tempo.
Divergences from tuttut
This port reproduces tuttut's algorithm, but deliberately fixes three reference behaviors:
- Notes at the clip end are never dropped. tuttut derives the clip length from raw note end times, so a zero-duration note at the end of the clip (or a note whose onset quantization pushes past the last note's end) silently vanishes. This port computes the clip end from snapped onsets and guarantees every onset lands in a measure.
- Unplayable chords render as empty events, not phantom notes. In the reference, a chord with no possible fingering keeps a
-1observation, which numpy's negative indexing resolves to the last chord's emission column: the output then contains notes borrowed from an unrelated chord. This port skips unplayable chords in the Viterbi observation sequence and emits the event withnotes: []; adjacent playable chords transition directly across the gap. - The first chord gets the easiest fingering, not the hardest. tuttut seeds the initial Viterbi state distribution with isolated difficulty (the inverse of easiness) where every later transition is weighted by easiness. The result is that the hardest fingering of the first chord is the most probable start (a melody of repeated open-string E4s begins at the 19th fret). This is a confirmed inversion bug in the reference; this port seeds the initial distribution with easiness.
Other reference quirks are kept faithfully: open strings are costless transitions, and note naming follows pretty_midi (including the low C-1 spelling).
Notes on fidelity and performance
A dev-only parity harness (parity/, not shipped) diffs output against the Python reference. Behavioral equivalence outside the divergences above is the bar; where multiple fingerings tie on cost, the chosen one may differ from Python without being wrong. The harness is expected to report diffs on the first chord of each case (divergence 3) and on any case involving unplayable chords or notes at the exact clip end (divergences 1 and 2).
Decoding cost scales as O(T·F²), where T is the number of onsets and F the total fingering count across distinct chords (the transition matrix is dense, F×F). Melodic clips stay small (F in the low hundreds). Chord-dense clips with many distinct chords grow quadratically and can reach seconds of compute; if you feed long, harmonically dense material, consider splitting it into sections.
License
MIT
