feat: alphaTex writer and GP round trip

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 02:40:46 +01:00
co-authored by Claude Sonnet 5.5
parent bac4fbeb1d
commit ec7cb0e568
3 changed files with 276 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
import { TPQ, type ScoreNote, type Song, type Track } from "../core/types.ts";
const NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
export const noteName = (midi: number): string => `${NAMES[midi % 12]}${Math.floor(midi / 12) - 1}`;
// Duration values in ticks (plain, dotted), largest first. Smallest supported unit is a 32nd (120 ticks).
const DURS: { div: number; ticks: number; dotted: boolean }[] = [];
for (const div of [1, 2, 4, 8, 16, 32]) {
const plain = (TPQ * 4) / div;
DURS.push({ div, ticks: plain * 1.5, dotted: true }, { div, ticks: plain, dotted: false });
}
DURS.sort((a, b) => b.ticks - a.ticks);
const GRID = (TPQ * 4) / 32;
interface Beat { start: number; len: number; notes: ScoreNote[] } // notes empty = rest
function q(s: string): string {
return `"${s.replace(/["\\]/g, "")}"`;
}
function checkGrid(v: number, what: string): void {
if (!Number.isInteger(v) || v % GRID !== 0) throw new Error(`${what}=${v} is not on the 32nd-note grid (${GRID} ticks)`);
}
/** Group by onset; length = shortest dur in group clipped to next onset; gaps become rests; padded to totalTicks. */
function buildBeats(notes: ScoreNote[], totalTicks: number): Beat[] {
const groups = new Map<number, ScoreNote[]>();
for (const n of notes) {
checkGrid(n.tick, "tick");
checkGrid(n.dur, "dur");
if (n.dur <= 0) throw new Error("note dur must be > 0");
if (n.tick + 0 >= totalTicks) throw new Error(`note at tick ${n.tick} beyond song end ${totalTicks}`);
(groups.get(n.tick) ?? groups.set(n.tick, []).get(n.tick)!).push(n);
}
const ticks = [...groups.keys()].sort((a, b) => a - b);
const beats: Beat[] = [];
let cursor = 0;
ticks.forEach((tick, i) => {
if (tick > cursor) beats.push({ start: cursor, len: tick - cursor, notes: [] });
const g = groups.get(tick)!;
const next = i + 1 < ticks.length ? ticks[i + 1]! : totalTicks;
const len = Math.min(Math.min(...g.map((n) => n.dur)), next - tick, totalTicks - tick);
beats.push({ start: tick, len, notes: g });
cursor = tick + len;
});
if (cursor < totalTicks) beats.push({ start: cursor, len: totalTicks - cursor, notes: [] });
return beats;
}
function decompose(len: number): { div: number; dotted: boolean }[] {
const out: { div: number; dotted: boolean }[] = [];
let rem = len;
while (rem > 0) {
const d = DURS.find((x) => x.ticks <= rem);
if (!d) throw new Error(`cannot decompose ${rem} ticks`);
out.push(d);
rem -= d.ticks;
}
return out;
}
function noteText(track: Track, n: ScoreNote, tie: boolean): string {
const t = tie ? "{t}" : "";
if (track.role === "drums") return `${n.pitch}`;
if (track.role === "guitar" || track.role === "bass") {
if (n.string === undefined || n.fret === undefined) throw new Error(`${track.name}: fretted note needs string+fret`);
return `${n.fret}.${n.string}${t}`;
}
return `${noteName(n.pitch)}${t}`;
}
function trackToTex(track: Track, barTicks: number, bars: number): string {
const total = barTicks * bars;
const drums = track.role === "drums";
const fretted = track.role === "guitar" || track.role === "bass";
let head = `\\track (${q(track.name)} ${q(track.role)})\n`;
if (drums) head += `\\instrument percussion \\articulation defaults \\staff {score}\n`;
else if (fretted) {
const tuning = track.tuning;
if (!tuning) throw new Error(`${track.name}: fretted track needs tuning`);
head += `\\staff {tabs} \\tuning (${tuning.map(noteName).join(" ")}) \\instrument (${track.program})\n`;
} else head += `\\staff {score} \\instrument (${track.program})\n`;
const perBar: string[][] = Array.from({ length: bars }, () => []);
for (const beat of buildBeats(track.notes, total)) {
// split at barlines
let pos = beat.start;
let first = true;
const end = beat.start + beat.len;
while (pos < end) {
const barEnd = (Math.floor(pos / barTicks) + 1) * barTicks;
const segEnd = Math.min(end, barEnd);
for (const piece of decompose(segEnd - pos)) {
const tie = !first && !drums && beat.notes.length > 0;
const body = beat.notes.length === 0
? "r"
: beat.notes.length === 1 ? noteText(track, beat.notes[0]!, tie) : `(${beat.notes.map((n) => noteText(track, n, tie)).join(" ")})`;
perBar[Math.floor(pos / barTicks)]!.push(`:${piece.div} ${body}${piece.dotted ? " {d}" : ""}`);
first = false;
}
pos = segEnd;
}
}
return head + perBar.map((b) => b.join(" ")).join(" |\n") + "\n";
}
export function songToAlphaTex(song: Song): string {
const [n, d] = song.timeSig;
const barTicks = (TPQ * 4 * n) / d;
let out = `\\title ${q(song.title)}\n\\tempo (${song.bpm})\n\\ts (${n} ${d})\n`;
for (const t of song.tracks) out += trackToTex(t, barTicks, song.bars);
return out;
}
+81
View File
@@ -0,0 +1,81 @@
import * as at from "@coderline/alphatab";
import type { Role, Song, TimedNote, TruthTrack } from "../core/types.ts";
import { ticksToSeconds } from "../core/time.ts";
import { songToAlphaTex } from "./alphatex.ts";
/** Parse alphaTex; returns all diagnostics (warnings and errors) as strings. Throws never on bad tex. */
export function alphaTexDiagnostics(tex: string): string[] {
const imp = new at.importer.AlphaTexImporter();
imp.logErrors = false;
imp.initFromString(tex, new at.Settings());
const out: string[] = [];
try {
imp.readScore();
} catch (e) {
out.push(`THROWN: ${String(e)}`);
}
for (const bag of [imp.lexerDiagnostics, imp.parserDiagnostics, imp.semanticDiagnostics]) {
for (const d of bag.items) {
if (d.severity >= 1 /* Warning=1, Error=2 */) out.push(`${d.code}@${d.start?.line}:${d.start?.col}: ${d.message}`);
}
}
return out;
}
export function songToScore(song: Song): at.model.Score {
const tex = songToAlphaTex(song);
const diags = alphaTexDiagnostics(tex);
if (diags.length) throw new Error(`alphaTex diagnostics:\n${diags.join("\n")}`);
return at.importer.ScoreLoader.loadAlphaTex(tex, new at.Settings());
}
export function songToGp(song: Song): Uint8Array {
return new at.exporter.Gp7Exporter().export(songToScore(song), new at.Settings());
}
export function songToMidi(song: Song): at.midi.MidiFile {
const settings = new at.Settings();
const score = songToScore(song);
const midi = new at.midi.MidiFile();
new at.midi.MidiFileGenerator(score, settings, new at.midi.AlphaSynthMidiFileHandler(midi, true)).generate();
return midi;
}
export function truthFromGp(bytes: Uint8Array): { bpm: number; timeSig: [number, number]; tracks: TruthTrack[] } {
const score = at.importer.ScoreLoader.loadScoreFromBytes(bytes, new at.Settings());
const bpm = score.tempo;
const mb0 = score.masterBars[0]!;
const tracks: TruthTrack[] = score.tracks.map((track) => {
const staff = track.staves[0]!;
const stringCount = staff.tuning.length;
const notes: TimedNote[] = [];
staff.bars.forEach((bar, bi) => {
const mbStart = score.masterBars[bi]!.start;
for (const beat of bar.voices[0]!.beats) {
const startTick = mbStart + beat.playbackStart;
const endTick = startTick + beat.playbackDuration;
for (const n of beat.notes) {
const onset = ticksToSeconds(startTick, bpm);
const offset = ticksToSeconds(endTick, bpm);
const pitch = track.isPercussion
? track.percussionArticulations[n.percussionArticulation]!.outputMidiNumber
: n.realValue;
if (n.isTieDestination) {
const prev = [...notes].reverse().find((p) => p.pitch === pitch && Math.abs(p.offset - onset) < 1e-6);
if (prev) { prev.offset = offset; continue; }
}
const t: TimedNote = { onset, offset, pitch };
if (!track.isPercussion) {
t.string = stringCount - n.string + 1;
t.fret = n.fret;
}
notes.push(t);
}
}
});
const tr: TruthTrack = { name: track.name, role: track.shortName as Role, notes };
if (!track.isPercussion) tr.tuning = [...staff.tuning];
return tr;
});
return { bpm, timeSig: [mb0.timeSignatureNumerator, mb0.timeSignatureDenominator], tracks };
}
+82
View File
@@ -0,0 +1,82 @@
import { test, expect } from "bun:test";
import { songToAlphaTex } from "../../src/score/alphatex.ts";
import { songToGp, songToMidi, truthFromGp, alphaTexDiagnostics } from "../../src/score/gp.ts";
import { ticksToSeconds } from "../../src/core/time.ts";
import { GUITAR_STANDARD, type Song, type TimedNote } from "../../src/core/types.ts";
const Q = 960;
const S = 240;
const BPM = 90;
const gn = (tick: number, dur: number, string: number, fret: number) => ({
tick, dur, string, fret, pitch: GUITAR_STANDARD[string - 1]! + fret,
});
const song: Song = {
title: 'Round "Trip"', tier: "t1", seed: 1, bpm: BPM, timeSig: [4, 4], bars: 3, key: "C",
tracks: [
{
name: "Lead", role: "guitar", program: 25, tuning: GUITAR_STANDARD, pan: 0, fx: "none", gainDb: 0,
notes: [
// C major chord, eighth
gn(0, Q / 2, 5, 3), gn(0, Q / 2, 4, 2), gn(0, Q / 2, 3, 0), gn(0, Q / 2, 2, 1), gn(0, Q / 2, 1, 0),
// 5 sixteenths
gn(Q / 2, 5 * S, 3, 2),
// whole note from beat 3 of bar 1 (crosses barline)
gn(2 * Q, 4 * Q, 2, 3),
],
},
{
name: "Vox", role: "vocals", program: 53, pan: 0, fx: "none", gainDb: 0,
notes: [
{ tick: 0, dur: Q, pitch: 60 },
{ tick: 3 * Q, dur: Q, pitch: 61 },
{ tick: 4 * Q + 2 * Q, dur: 2 * Q, pitch: 64 },
],
},
{
name: "Kit", role: "drums", program: 0, pan: 0, fx: "none", gainDb: 0,
notes: [
{ tick: 0, dur: S, pitch: 36 }, { tick: 0, dur: S, pitch: 42 }, { tick: Q, dur: S, pitch: 38 },
],
},
],
};
const round3 = (x: number) => Math.round(x * 1000) / 1000;
const sortNotes = (ns: TimedNote[]) => [...ns].sort((a, b) => a.onset - b.onset || a.pitch - b.pitch);
const t = (tick: number) => ticksToSeconds(tick, BPM);
test("alphaTex has no diagnostics", () => {
const tex = songToAlphaTex(song);
expect(alphaTexDiagnostics(tex)).toEqual([]);
});
test("GP round trip preserves notes, ties, strings, roles", () => {
const truth = truthFromGp(songToGp(song));
expect(truth.bpm).toBe(BPM);
expect(truth.timeSig).toEqual([4, 4]);
const g = truth.tracks[0]!;
const expectedGuitar = song.tracks[0]!.notes
.map((n) => [n.pitch, round3(t(n.tick))] as [number, number])
.sort((a, b) => a[1] - b[1] || a[0] - b[0]);
expect(sortNotes(g.notes).map((n) => [n.pitch, round3(n.onset)])).toEqual(expectedGuitar);
const tiedPitch = GUITAR_STANDARD[1]! + 3;
const tied = g.notes.find((n) => n.pitch === tiedPitch)!;
expect(tied.offset - tied.onset).toBeCloseTo((4 * 60) / 90, 3);
const five = g.notes.find((n) => n.pitch === GUITAR_STANDARD[2]! + 2)!;
expect(five.offset - five.onset).toBeCloseTo(t(5 * S), 3);
expect(g.notes.every((n) => n.string! >= 1 && n.string! <= 6)).toBe(true);
expect(g.notes.find((n) => n.pitch === 64)!.string).toBe(1);
expect(g.tuning).toEqual(GUITAR_STANDARD);
const vox = truth.tracks[1]!;
expect(sortNotes(vox.notes).map((n) => [n.pitch, round3(n.onset)])).toEqual([
[60, 0], [61, round3(t(3 * Q))], [64, round3(t(6 * Q))],
]);
expect(truth.tracks.find((x) => x.role === "drums")!.notes.map((n) => n.pitch).sort()).toEqual([36, 38, 42]);
expect(truth.tracks.map((x) => x.role)).toEqual(["guitar", "vocals", "drums"]);
expect(truth.tracks.map((x) => x.name)).toEqual(["Lead", "Vox", "Kit"]);
});
test("songToMidi generates events", () => {
expect(songToMidi(song).tracks.length).toBeGreaterThan(0);
});