diff --git a/src/score/alphatex.ts b/src/score/alphatex.ts index 5cb566a..2de2b93 100644 --- a/src/score/alphatex.ts +++ b/src/score/alphatex.ts @@ -25,11 +25,15 @@ function checkGrid(v: number, what: string): void { /** 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(); + const seen = new Set(); for (const n of notes) { + const key = `${n.tick}:${n.pitch}`; + if (seen.has(key)) continue; // exact duplicate (same tick+pitch) + seen.add(key); 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}`); + if (n.tick >= 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); @@ -92,7 +96,7 @@ function trackToTex(track: Track, barTicks: number, bars: number): string { 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 + const body = beat.notes.length === 0 || (drums && !first) ? "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}" : ""}`); diff --git a/src/score/gp.ts b/src/score/gp.ts index 3691877..1fd73b1 100644 --- a/src/score/gp.ts +++ b/src/score/gp.ts @@ -41,6 +41,8 @@ export function songToMidi(song: Song): at.midi.MidiFile { return midi; } +const ROLES: readonly string[] = ["guitar", "bass", "keys", "vocals", "drums"]; + 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; @@ -73,6 +75,7 @@ export function truthFromGp(bytes: Uint8Array): { bpm: number; timeSig: [number, } } }); + if (!ROLES.includes(track.shortName as Role)) throw new Error(`track "${track.name}": shortName "${track.shortName}" is not a valid Role`); const tr: TruthTrack = { name: track.name, role: track.shortName as Role, notes }; if (!track.isPercussion) tr.tuning = [...staff.tuning]; return tr; diff --git a/tests/score/roundtrip.test.ts b/tests/score/roundtrip.test.ts index c2f28ff..871b86a 100644 --- a/tests/score/roundtrip.test.ts +++ b/tests/score/roundtrip.test.ts @@ -80,3 +80,34 @@ test("GP round trip preserves notes, ties, strings, roles", () => { 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/); +});