Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions src/__tests__/midi-writer.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
1 change: 1 addition & 0 deletions src/chart/index.ts
Original file line number Diff line number Diff line change
@@ -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'
259 changes: 259 additions & 0 deletions src/chart/midi-writer.ts
Original file line number Diff line number Diff line change
@@ -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<string, MidiEvent[]>()

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<number>()
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
}