diff --git a/src/__tests__/midi-writer.test.ts b/src/__tests__/midi-writer.test.ts new file mode 100644 index 0000000..c8d3af1 --- /dev/null +++ b/src/__tests__/midi-writer.test.ts @@ -0,0 +1,131 @@ +/** + * Round-trip tests for writeMidiFile. + * + * All tests exercise the writer only through parseChartAndIni: build a + * ParsedChart, write it out as MIDI, re-parse, and assert on the resulting + * ParsedChart. No assertions about the raw MIDI structure (track count, + * track names, note numbers, setTempo microseconds, text-event brackets, + * etc.) — the parser is the source of truth for observable behavior. + */ + +import type { MidiEvent } from '@geomitron/midi-file' +import { describe, expect, it } from 'vitest' + +import { createEmptyChart } from '../chart/create-chart' +import { writeMidiFile } from '../chart/midi-writer' +import { parseChartAndIni, type ParsedChart } from '../chart/parse-chart-and-ini' + +function roundTrip(chart: ParsedChart, iniText?: string): ParsedChart { + const files: { fileName: string; data: Uint8Array }[] = [ + { fileName: 'notes.mid', data: writeMidiFile(chart) }, + ] + if (iniText !== undefined) { + files.push({ fileName: 'song.ini', data: new TextEncoder().encode(iniText) }) + } + const result = parseChartAndIni(files) + if (!result.parsedChart) { + throw new Error(`round-trip produced no parsedChart: ${JSON.stringify(result.chartFolderIssues)}`) + } + return result.parsedChart +} + +describe('writeMidiFile round-trip: resolution + [SyncTrack]', () => { + it('preserves chart resolution', () => { + const re = roundTrip(createEmptyChart({ resolution: 192 })) + expect(re.resolution).toBe(192) + }) + + it('preserves the default tempo on an empty chart', () => { + const re = roundTrip(createEmptyChart({ bpm: 120 })) + expect(re.tempos.map(t => ({ tick: t.tick, bpm: Math.round(t.beatsPerMinute) }))).toEqual([{ tick: 0, bpm: 120 }]) + }) + + it('preserves fractional BPM within microsecondsPerBeat rounding', () => { + const re = roundTrip(createEmptyChart({ bpm: 137.5 })) + // setTempo is stored as integer microseconds/beat, so fractional BPM + // round-trips with tiny quantization — check within 0.01 BPM. + expect(re.tempos[0].beatsPerMinute).toBeCloseTo(137.5, 1) + }) + + it('preserves multiple tempo changes at their ticks', () => { + const chart = createEmptyChart({ resolution: 480, bpm: 120 }) + chart.tempos.push({ tick: 960, beatsPerMinute: 180, msTime: 0 }) + const re = roundTrip(chart) + expect(re.tempos.map(t => ({ tick: t.tick, bpm: Math.round(t.beatsPerMinute) }))).toEqual([ + { tick: 0, bpm: 120 }, + { tick: 960, bpm: 180 }, + ]) + }) + + it('preserves non-4/4 time signatures', () => { + const chart = createEmptyChart({ timeSignature: { numerator: 6, denominator: 8 } }) + expect(roundTrip(chart).timeSignatures[0]).toMatchObject({ numerator: 6, denominator: 8 }) + }) + + it('preserves multiple time-signature changes', () => { + const chart = createEmptyChart({ timeSignature: { numerator: 7, denominator: 8 } }) + chart.timeSignatures.push({ tick: 3840, numerator: 3, denominator: 4, msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + expect(re.timeSignatures.map(ts => ({ t: ts.tick, n: ts.numerator, d: ts.denominator }))).toEqual([ + { t: 0, n: 7, d: 8 }, + { t: 3840, n: 3, d: 4 }, + ]) + }) +}) + +describe('writeMidiFile round-trip: EVENTS track', () => { + it('preserves sections at the right ticks with correct names', () => { + const chart = createEmptyChart() + chart.sections.push({ tick: 0, name: 'Intro', msTime: 0, msLength: 0 }) + chart.sections.push({ tick: 1920, name: 'Verse 1', msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + expect(re.sections.map(s => ({ tick: s.tick, name: s.name }))).toEqual([ + { tick: 0, name: 'Intro' }, + { tick: 1920, name: 'Verse 1' }, + ]) + }) + + it('preserves end events', () => { + const chart = createEmptyChart() + chart.endEvents.push({ tick: 1920, msTime: 0, msLength: 0 }) + expect(roundTrip(chart).endEvents.map(e => e.tick)).toEqual([1920]) + }) + + it('preserves unrecognized EVENTS events on .mid source', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.unrecognizedEventsTrackTextEvents.push({ tick: 480, text: 'crowd_noclap', msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + expect(re.unrecognizedEventsTrackTextEvents.map(e => ({ tick: e.tick, text: e.text }))).toEqual([ + { tick: 480, text: 'crowd_noclap' }, + ]) + }) +}) + +describe('writeMidiFile round-trip: unrecognized MIDI tracks', () => { + it('preserves an unrecognized track by name', () => { + const chart = createEmptyChart() + chart.unrecognizedMidiTracks.push({ + trackName: 'CUSTOM', + events: [ + { deltaTime: 0, meta: true, type: 'trackName', text: 'CUSTOM' } as MidiEvent, + { deltaTime: 240, meta: true, type: 'text', text: 'hello' } as MidiEvent, + { deltaTime: 240, meta: true, type: 'endOfTrack' } as MidiEvent, + ], + }) + expect(roundTrip(chart).unrecognizedMidiTracks.map(t => t.trackName)).toEqual(['CUSTOM']) + }) + + it('preserves multiple unrecognized tracks with the same name', () => { + const chart = createEmptyChart() + for (let i = 0; i < 3; i++) { + chart.unrecognizedMidiTracks.push({ + trackName: 'CUSTOM', + events: [ + { deltaTime: 0, meta: true, type: 'trackName', text: 'CUSTOM' } as MidiEvent, + { deltaTime: 0, meta: true, type: 'endOfTrack' } as MidiEvent, + ], + }) + } + expect(roundTrip(chart).unrecognizedMidiTracks).toHaveLength(3) + }) +}) diff --git a/src/chart/index.ts b/src/chart/index.ts index 4661673..f7761fe 100644 --- a/src/chart/index.ts +++ b/src/chart/index.ts @@ -1,4 +1,5 @@ export * from './chart-scanner' export * from './chart-writer' export * from './create-chart' +export * from './midi-writer' export * from './parse-chart-and-ini' diff --git a/src/chart/midi-writer.ts b/src/chart/midi-writer.ts new file mode 100644 index 0000000..265f599 --- /dev/null +++ b/src/chart/midi-writer.ts @@ -0,0 +1,259 @@ +/** + * MIDI binary writer — serializes a ParsedChart back to a Format-1 `.mid` file. + * + * This PR establishes the writer infrastructure: + * - `writeMidiFile` entry point + * - TEMPO TRACK (tempo + time-signature meta events) + * - EVENTS track (sections + end events + unrecognized global events + coda) + * - Unrecognized MIDI tracks (verbatim pass-through) + * - `finalizeMidiTrack` shared helper (sort + absolute→delta time conversion) + * + * Instrument tracks (PART DRUMS, PART GUITAR, etc.) and vocal tracks + * (PART VOCALS, HARM1/2/3) are emitted by follow-up PRs. + */ + +import type { MidiData, MidiEvent } from '@geomitron/midi-file' +import { writeMidi } from '@geomitron/midi-file' + +import type { ParsedChart } from './parse-chart-and-ini' + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +/** A MIDI event tagged with its absolute tick (for sort-then-delta finalization). */ +export interface AbsoluteEvent { + tick: number + event: MidiEvent + /** Stable sort tiebreaker — preserves source ordering within the same tick. */ + seq?: number +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Serialize a {@link ParsedChart} to `.mid` bytes. + * + * Output track layout: + * 0 — TEMPO TRACK (BPM + time signatures) + * 1 — EVENTS (sections, end events, global events, coda) + * N — Unrecognized MIDI tracks (verbatim pass-through) + * + * Instrument tracks (PART DRUMS, PART GUITAR, …) and vocal tracks + * (PART VOCALS, HARM1/2/3) are emitted by follow-up PRs — this entry point + * currently skips `chart.trackData` and `chart.vocalTracks`. + */ +export function writeMidiFile(chart: ParsedChart): Uint8Array { + const trackMap = new Map() + + trackMap.set('TEMPO TRACK', buildTempoTrack(chart)) + trackMap.set('EVENTS', buildEventsTrack(chart)) + + // Unrecognized whole tracks (VENUE, BEAT, PART REAL_*, custom tracks) are + // round-tripped verbatim. + let dupSuffix = 0 + for (const ut of chart.unrecognizedMidiTracks) { + let mapKey = ut.trackName + while (trackMap.has(mapKey)) mapKey = `${ut.trackName}__dup${dupSuffix++}` + trackMap.set(mapKey, buildUnrecognizedTrack(ut.events)) + } + + const tracks = [...trackMap.values()] + const midiData: MidiData = { + header: { + format: 1, + numTracks: tracks.length, + ticksPerBeat: chart.resolution, + }, + tracks, + } + return new Uint8Array(writeMidi(midiData)) +} + +// --------------------------------------------------------------------------- +// Track builders +// --------------------------------------------------------------------------- + +function buildTempoTrack(chart: ParsedChart): MidiEvent[] { + const events: AbsoluteEvent[] = [] + + events.push({ + tick: 0, + event: { deltaTime: 0, meta: true, type: 'trackName', text: 'TEMPO TRACK' } as MidiEvent, + }) + + for (const tempo of chart.tempos) { + events.push({ + tick: tempo.tick, + event: { + deltaTime: 0, + meta: true, + type: 'setTempo', + microsecondsPerBeat: Math.round(60_000_000 / tempo.beatsPerMinute), + } as MidiEvent, + }) + } + + for (const ts of chart.timeSignatures) { + events.push({ + tick: ts.tick, + event: { + deltaTime: 0, + meta: true, + type: 'timeSignature', + numerator: ts.numerator, + denominator: ts.denominator, + metronome: 24, + thirtyseconds: 8, + } as MidiEvent, + }) + } + + return finalizeMidiTrack(events) +} + +function buildEventsTrack(chart: ParsedChart): MidiEvent[] { + const events: AbsoluteEvent[] = [] + + events.push({ + tick: 0, + event: { deltaTime: 0, meta: true, type: 'trackName', text: 'EVENTS' } as MidiEvent, + }) + + // Sections emit UNWRAPPED as `section name` (not `[section name]`). YARG's + // NormalizeTextEvent strips content between the first `[` and first `]`, + // which would lose data for names that contain `]`. Unwrapped form preserves + // names with `]` and names starting with `[`. The only case that's inherently + // lossy under YARG normalization is names containing both `[` and `]` — + // those can't round-trip regardless of wrapping. + for (const section of chart.sections) { + events.push({ + tick: section.tick, + event: { deltaTime: 0, meta: true, type: 'text', text: `section ${section.name}` } as MidiEvent, + }) + } + + for (const endEvent of chart.endEvents) { + events.push({ + tick: endEvent.tick, + event: { deltaTime: 0, meta: true, type: 'text', text: '[end]' } as MidiEvent, + }) + } + + // Global events (crowd events, music_start/end, coda, etc.). `.chart` + // stores them unwrapped; `.mid` stores them bracket-wrapped. When the + // source was `.chart`, wrap on output so the MIDI output follows convention. + const sourceIsMidi = chart.format === 'mid' + for (const ge of chart.unrecognizedEventsTrackTextEvents) { + let text = ge.text + if (!sourceIsMidi) { + const trimmed = text.trimEnd() + if (!(trimmed.startsWith('[') && trimmed.endsWith(']'))) { + text = `[${text}]` + } + } + events.push({ + tick: ge.tick, + event: { deltaTime: 0, meta: true, type: 'text', text } as MidiEvent, + }) + } + + // Coda events: derive from drumFreestyleSections only if none already in + // unrecognizedEventsTrackTextEvents. The parser splits [coda] into both + // places, but we only need one. + const hasCodaInGlobalEvents = chart.unrecognizedEventsTrackTextEvents.some(ge => { + const trimmed = ge.text.trim() + return trimmed === '[coda]' || trimmed === 'coda' + }) + if (!hasCodaInGlobalEvents) { + const codaTicks = new Set() + for (const track of chart.trackData) { + for (const fs of track.drumFreestyleSections) { + if (fs.isCoda) codaTicks.add(fs.tick) + } + } + for (const tick of codaTicks) { + events.push({ + tick, + event: { deltaTime: 0, meta: true, type: 'text', text: '[coda]' } as MidiEvent, + }) + } + } + + return finalizeMidiTrack(events) +} + +/** + * Re-emit a parsed unrecognized track verbatim. + * + * Events arrive with `deltaTime = absolute tick` (scan-chart's + * `convertToAbsoluteTime` post-processing). midi-file's writer expects delta + * timing, so convert back here. + * + * If the input MIDI was malformed (non-monotonic absolute ticks → negative + * deltas), midi-file's writeVarInt will throw. We let that bubble up so the + * caller can record it as a per-chart failure rather than silently reorder + * events to "fix" the malformed source. + */ +function buildUnrecognizedTrack(events: MidiEvent[]): MidiEvent[] { + let prevTick = 0 + const out: MidiEvent[] = [] + for (const e of events) { + const absTick = e.deltaTime + out.push({ ...e, deltaTime: absTick - prevTick }) + prevTick = absTick + } + return out +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/** + * Sort events by absolute tick (with a type-priority tiebreaker) and convert + * to delta-time encoding. Appends an `endOfTrack` meta event. + * + * Sort priority at the same tick: trackName → timeSignature → setTempo → + * noteOff → sysEx → noteOn → text/lyrics → other → endOfTrack. This matches + * Clone Hero's expected event ordering. Events with an explicit `seq` tag + * sort AFTER untagged events at the same tick (so instrument-track emitters + * can sequence paired events deterministically via `seq`). + */ +export function finalizeMidiTrack(events: AbsoluteEvent[]): MidiEvent[] { + const eventPriority = (e: MidiEvent): number => { + switch (e.type) { + case 'trackName': return 0 + case 'timeSignature': return 1 + case 'setTempo': return 2 + case 'noteOff': return 3 + case 'sysEx': case 'endSysEx': return 4 + case 'noteOn': return 5 + case 'text': case 'lyrics': return 6 + case 'endOfTrack': return 8 + default: return 7 + } + } + events.sort((a, b) => { + if (a.tick !== b.tick) return a.tick - b.tick + const aHasSeq = a.seq !== undefined + const bHasSeq = b.seq !== undefined + if (!aHasSeq && !bHasSeq) return eventPriority(a.event) - eventPriority(b.event) + if (!aHasSeq) return -1 + if (!bHasSeq) return 1 + return (a.seq as number) - (b.seq as number) + }) + + let prevTick = 0 + const midiEvents: MidiEvent[] = [] + for (const { tick, event } of events) { + event.deltaTime = tick - prevTick + prevTick = tick + midiEvents.push(event) + } + + midiEvents.push({ deltaTime: 0, meta: true, type: 'endOfTrack' } as MidiEvent) + return midiEvents +}