feat: alphaTex writer and GP round trip
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user