feat: alphaTex writer and GP round trip
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bac4fbeb1d
commit
ec7cb0e568
3 files changed
+276
No files matched your search
@@ -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