Files
STSheets/tests/score/roundtrip.test.ts
T
2026-10-30 02:42:35 +01:00

114 lines
4.7 KiB
TypeScript

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/);
});