diff --git a/src/score/alphatex.ts b/src/score/alphatex.ts new file mode 100644 index 0000000..5cb566a --- /dev/null +++ b/src/score/alphatex.ts @@ -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(); + 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; +} diff --git a/src/score/gp.ts b/src/score/gp.ts new file mode 100644 index 0000000..3691877 --- /dev/null +++ b/src/score/gp.ts @@ -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 }; +} diff --git a/tests/score/roundtrip.test.ts b/tests/score/roundtrip.test.ts new file mode 100644 index 0000000..c2f28ff --- /dev/null +++ b/tests/score/roundtrip.test.ts @@ -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); +});