feat: alphaTex writer and GP round trip
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
bac4fbeb1d
commit
ec7cb0e568
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user