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); }); function drumSong(notes: { tick: number; dur: number; pitch: number }[]): Song { return { title: "d", tier: "t1", seed: 1, bpm: BPM, timeSig: [4, 4], bars: 2, key: "C", tracks: [{ name: "Kit", role: "drums", program: 0, pan: 0, fx: "none", gainDb: 0, notes }], }; } test("drum note with multi-piece duration yields exactly one hit", () => { const truth = truthFromGp(songToGp(drumSong([{ tick: 0, dur: 5 * S, pitch: 38 }]))); expect(truth.tracks[0]!.notes.map((n) => [n.pitch, n.onset])).toEqual([[38, 0]]); }); test("drum note crossing a barline yields exactly one hit", () => { const truth = truthFromGp(songToGp(drumSong([{ tick: 3 * Q, dur: 2 * Q, pitch: 36 }]))); expect(truth.tracks[0]!.notes.map((n) => [n.pitch, round3(n.onset)])).toEqual([[36, round3(t(3 * Q))]]); }); test("exact duplicate notes are de-duplicated", () => { const dup = { tick: Q, dur: Q, pitch: 60 }; const s = drumSong([]); s.tracks = [{ name: "V", role: "vocals", program: 53, pan: 0, fx: "none", gainDb: 0, notes: [dup, { ...dup }] }]; const truth = truthFromGp(songToGp(s)); expect(truth.tracks[0]!.notes.length).toBe(1); }); test("truthFromGp rejects an invalid role short name", () => { const s = drumSong([{ tick: 0, dur: Q, pitch: 36 }]); (s.tracks[0] as any).role = "banjo"; expect(() => truthFromGp(songToGp(s))).toThrow(/not a valid Role/); });