diff --git a/src/__tests__/tick-zero-trackname.test.ts b/src/__tests__/tick-zero-trackname.test.ts new file mode 100644 index 0000000..434c615 --- /dev/null +++ b/src/__tests__/tick-zero-trackname.test.ts @@ -0,0 +1,124 @@ +/** + * Tests for the MIDI track-name discovery in `getTracks`. Some charts emit + * multiple `trackName` events at tick 0 — typically a bogus or descriptive + * name first (e.g. `[ENHANCED_OPENS]`, `TEMPO TRACK`, the song title) + * followed by the real instrument name (e.g. `PART BASS`). YARG.Core takes + * only the first tick-0 trackName and drops the track if it isn't a known + * instrument; scan-chart intentionally does NOT match that behavior. We + * walk all tick-0 trackName events and accept the first one that matches a + * known instrument so these charts stay playable. + * + * Real-world charts that hit this path: + * - "Culture Killer - Blindfolded Death" (PART BASS, PART GUITAR) + * - "Periphery - Ji" (PART DRUMS) + * - "school food punishment - close, down, back to" (PART DRUMS) + */ + +import { describe, it, expect } from 'vitest' +import { writeMidi, MidiData } from 'midi-file' +import { parseNotesFromMidi } from '../chart/midi-parser' +import { defaultIniChartModifiers } from '../chart/note-parsing-interfaces' + +function buildMidi(ticksPerBeat: number, tracks: MidiData['tracks']): Uint8Array { + const data: MidiData = { + header: { format: 1, numTracks: tracks.length, ticksPerBeat }, + tracks, + } + return new Uint8Array(writeMidi(data)) +} + +function tempoTrack(): MidiData['tracks'][number] { + return [ + { deltaTime: 0, type: 'trackName', text: '' }, + { deltaTime: 0, type: 'setTempo', microsecondsPerBeat: 500000 }, + { deltaTime: 0, type: 'timeSignature', numerator: 4, denominator: 4, metronome: 24, thirtyseconds: 8 }, + { deltaTime: 0, type: 'endOfTrack' }, + ] +} + +/** Build an instrument track whose tick-0 events start with `leadingNames` + * (zero or more trackName events) followed by a single noteOn/noteOff pair. */ +function trackWithLeadingNames( + leadingNames: string[], + noteNumber: number, + noteLength = 480, +): MidiData['tracks'][number] { + const events: MidiData['tracks'][number] = [] + for (const text of leadingNames) { + events.push({ deltaTime: 0, type: 'trackName', text }) + } + events.push({ deltaTime: 0, type: 'noteOn', channel: 0, noteNumber, velocity: 100 }) + events.push({ deltaTime: noteLength, type: 'noteOff', channel: 0, noteNumber, velocity: 0 }) + events.push({ deltaTime: 0, type: 'endOfTrack' }) + return events +} + +describe('MIDI: tick-0 trackName resolution', () => { + it('accepts a recognized instrument trackName even when a bogus name comes first at tick 0', () => { + // PART BASS expert kick is MIDI note 95. (Same difficulty layout as guitar.) + const bass = trackWithLeadingNames(['[ENHANCED_OPENS]', 'PART BASS'], 96) + const midi = buildMidi(480, [tempoTrack(), bass]) + const result = parseNotesFromMidi(midi, defaultIniChartModifiers) + + const bassTracks = result.trackData.filter(t => t.instrument === 'bass') + expect(bassTracks.length).toBeGreaterThan(0) + expect(result.unrecognizedMidiTracks).toHaveLength(0) + }) + + it('accepts PART DRUMS even when a descriptive name (e.g. song title) comes first at tick 0', () => { + // PART DRUMS expert red drum is MIDI note 97. + const drums = trackWithLeadingNames(['school food punishment - close, down, back to', 'PART DRUMS'], 97) + const midi = buildMidi(480, [tempoTrack(), drums]) + const result = parseNotesFromMidi(midi, defaultIniChartModifiers) + + const drumTracks = result.trackData.filter(t => t.instrument === 'drums') + expect(drumTracks.length).toBeGreaterThan(0) + expect(result.unrecognizedMidiTracks).toHaveLength(0) + }) + + it('takes the first MATCHING name when multiple recognized names appear at tick 0', () => { + // If two known instrument names somehow appear at tick 0 on the same + // track, the first one wins. (Pathological but well-defined.) + const ambiguous = trackWithLeadingNames(['PART BASS', 'PART GUITAR'], 96) + const midi = buildMidi(480, [tempoTrack(), ambiguous]) + const result = parseNotesFromMidi(midi, defaultIniChartModifiers) + + expect(result.trackData.some(t => t.instrument === 'bass')).toBe(true) + expect(result.trackData.some(t => t.instrument === 'guitar')).toBe(false) + }) + + it('captures the first tick-0 trackName as unrecognized when no known instrument matches', () => { + // VENUE isn't a recognized instrument track, so it's stored verbatim + // in unrecognizedMidiTracks for round-trip. The captured trackName + // should be the first one we saw at tick 0. + const venue: MidiData['tracks'][number] = [ + { deltaTime: 0, type: 'trackName', text: 'CUSTOM VENUE LEAD' }, + { deltaTime: 0, type: 'trackName', text: 'VENUE' }, + { deltaTime: 480, type: 'noteOn', channel: 0, noteNumber: 96, velocity: 100 }, + { deltaTime: 480, type: 'noteOff', channel: 0, noteNumber: 96, velocity: 0 }, + { deltaTime: 0, type: 'endOfTrack' }, + ] + const midi = buildMidi(480, [tempoTrack(), venue]) + const result = parseNotesFromMidi(midi, defaultIniChartModifiers) + + expect(result.trackData).toHaveLength(0) + expect(result.unrecognizedMidiTracks).toHaveLength(1) + expect(result.unrecognizedMidiTracks[0].trackName).toBe('CUSTOM VENUE LEAD') + }) + + it('ignores trackName events past tick 0', () => { + // Only tick-0 trackNames count. A trackName event later in the file + // must not be picked up. + const malformed: MidiData['tracks'][number] = [ + { deltaTime: 0, type: 'trackName', text: 'NOT_A_REAL_NAME' }, + { deltaTime: 1, type: 'trackName', text: 'PART BASS' }, + { deltaTime: 480, type: 'endOfTrack' }, + ] + const midi = buildMidi(480, [tempoTrack(), malformed]) + const result = parseNotesFromMidi(midi, defaultIniChartModifiers) + + expect(result.trackData.some(t => t.instrument === 'bass')).toBe(false) + expect(result.unrecognizedMidiTracks).toHaveLength(1) + expect(result.unrecognizedMidiTracks[0].trackName).toBe('NOT_A_REAL_NAME') + }) +}) diff --git a/src/chart/midi-parser.ts b/src/chart/midi-parser.ts index a6e71e8..386cd16 100644 --- a/src/chart/midi-parser.ts +++ b/src/chart/midi-parser.ts @@ -337,34 +337,47 @@ function getTracks(midiData: MidiData) { const unrecognizedMidiTracks: { trackName: string; events: MidiEvent[] }[] = [] for (const [i, track] of midiData.tracks.entries()) { - // Match YARG.Core's MidiExtensions.GetTrackName: return the FIRST - // `SequenceTrackName` event (FF 03) seen at tick 0, even if it - // doesn't match any recognized instrument track. The early `break` - // below ensures we don't continue scanning for a "better" name. - // Some charts (e.g. "Culture Killer - Blindfolded Death") have a - // bogus leading trackname like `[ENHANCED_OPENS]` followed by the - // real `PART BASS` — YARG honors the first and skips the track. - let trackName: string | null = null + // We intentionally do NOT match YARG.Core's GetTrackName here, which + // returns the FIRST tick-0 trackName even if it isn't a recognized + // instrument. Some real charts (e.g. "Culture Killer - Blindfolded + // Death", "Periphery - Ji", "school food punishment - close, down, + // back to") emit a bogus leading trackName at tick 0 immediately + // followed by the real instrument trackName, also at tick 0: + // + // [ENHANCED_OPENS] → PART BASS (Culture Killer) + // TEMPO TRACK → PART DRUMS (Periphery - Ji) + // → PART DRUMS (school food punishment) + // + // YARG drops these tracks; we keep them. Walk every tick-0 trackName + // and accept the first one that matches a recognized instrument. + // Capture the first tick-0 trackName for unrecognized-track + // round-trip purposes regardless of whether anything matched. + let firstTickZeroTrackName: string | null = null + let recognizedTrackName: TrackName | null = null for (const event of track) { if (event.deltaTime !== 0) { break } if (event.type === 'trackName') { - trackName = event.text - break + if (firstTickZeroTrackName === null) { + firstTickZeroTrackName = event.text + } + if (recognizedTrackName === null && trackNames.includes(event.text as TrackName)) { + recognizedTrackName = event.text as TrackName + } } } - if (trackName !== null && trackNames.includes(trackName as TrackName)) { + if (recognizedTrackName !== null) { tracks.push({ - trackName: trackName as TrackName, + trackName: recognizedTrackName, trackEvents: track, }) } else if (i !== 0) { // Track 0 is the conductor track (tempo/timeSignature) and isn't a // musical track — skip it from unrecognized capture. unrecognizedMidiTracks.push({ - trackName: trackName ?? '', + trackName: firstTickZeroTrackName ?? '', events: track, }) }