114 lines
4.7 KiB
TypeScript
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/);
|
|
});
|