fix: drum single hit, dedupe notes, validate role

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 02:42:35 +01:00
co-authored by Claude Sonnet 5.5
parent ec7cb0e568
commit 470e089f92
3 changed files with 40 additions and 2 deletions
+6 -2
View File
@@ -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<number, ScoreNote[]>();
const seen = new Set<string>();
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}" : ""}`);
+3
View File
@@ -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;
+31
View File
@@ -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/);
});