diff --git a/patches/midi-file+1.2.4.patch b/patches/midi-file+1.2.4.patch index 853a0b3..29dd96e 100644 --- a/patches/midi-file+1.2.4.patch +++ b/patches/midi-file+1.2.4.patch @@ -1,86 +1,55 @@ diff --git a/node_modules/midi-file/lib/midi-parser.js b/node_modules/midi-file/lib/midi-parser.js -index 50ed069..93868ee 100644 +index 50ed069..f6828a5 100644 --- a/node_modules/midi-file/lib/midi-parser.js +++ b/node_modules/midi-file/lib/midi-parser.js -@@ -308,9 +308,47 @@ Parser.prototype.readBytes = function(len) { - return bytes +@@ -309,8 +309,15 @@ Parser.prototype.readBytes = function(len) { } - -+var sharedUtf8Decoder = new TextDecoder('utf-8') -+ + Parser.prototype.readString = function(len) { -- var bytes = this.readBytes(len) -- return String.fromCharCode.apply(null, bytes) + // Strings can be multibyte-encoded or not. -+ // Fast path: all ASCII (bytes < 0x80) -> fromCharCode directly, no TextDecoder. -+ // Slow path: try UTF-8, fall back to Latin-1 if UTF-8 produces replacement chars. -+ var start = this.pos -+ var end = start + len -+ var buffer = this.buffer -+ var allAscii = true -+ for (var i = start; i < end; i++) { -+ if (buffer[i] >= 0x80) { allAscii = false; break } -+ } -+ this.pos = end -+ if (allAscii) { -+ // fromCharCode.apply is the fastest path for small ASCII strings. -+ // For very long strings, the stack can blow up — chunk it. -+ var CHUNK = 0x8000 -+ if (len <= CHUNK) { -+ // Use subarray (no copy) when available (Uint8Array); fall back to slice for plain arrays. -+ var view = buffer.subarray ? buffer.subarray(start, end) : buffer.slice(start, end) -+ return String.fromCharCode.apply(null, view) -+ } -+ var out = '' -+ for (var j = start; j < end; j += CHUNK) { -+ var chunkEnd = j + CHUNK < end ? j + CHUNK : end -+ var chunk = buffer.subarray ? buffer.subarray(j, chunkEnd) : buffer.slice(j, chunkEnd) -+ out += String.fromCharCode.apply(null, chunk) -+ } -+ return out -+ } -+ // Non-ASCII: try UTF-8 decode; fall back to Latin-1 (fromCharCode per byte) if -+ // UTF-8 produced replacement chars or didn't shorten the string. -+ var bytes = buffer.subarray ? buffer.subarray(start, end) : buffer.slice(start, end) -+ var multibyteString = sharedUtf8Decoder.decode(bytes) -+ // Latin-1 interpretation: each byte → one codepoint. Length equals `len`. -+ if (multibyteString.length < len && multibyteString.indexOf('\uFFFD') === -1) { ++ // Try UTF-8 first; fall back to Latin-1 if UTF-8 produces replacement chars. + var bytes = this.readBytes(len) +- return String.fromCharCode.apply(null, bytes) ++ var multibyteString = new TextDecoder().decode(bytes) ++ var singlebyteString = String.fromCharCode.apply(null, bytes) ++ if (singlebyteString.length > multibyteString.length && !multibyteString.includes('\uFFFD')) { + return multibyteString + } -+ // Build Latin-1 string via fromCharCode on the byte values. -+ return String.fromCharCode.apply(null, bytes) ++ return singlebyteString } - - Parser.prototype.readVarInt = function() { -@@ -321,14 +359,19 @@ Parser.prototype.readBytes = function(len) { - + Parser.prototype.readVarInt = function() { - var result = 0 -- while (!this.eof()) { -- var b = this.readUInt8() -+ var buffer = this.buffer -+ var pos = this.pos -+ var bufferLen = this.bufferLen -+ while (pos < bufferLen) { -+ var b = buffer[pos++] - if (b & 0x80) { - result += (b & 0x7f) - result <<= 7 - } else { - // b is last byte -+ this.pos = pos - return result + b - } - } - // premature eof -+ this.pos = pos - return result - } - diff --git a/node_modules/midi-file/lib/midi-writer.js b/node_modules/midi-file/lib/midi-writer.js -index c1a438d..cbd1a73 100644 +index c1a438d..4dad1e1 100644 --- a/node_modules/midi-file/lib/midi-writer.js +++ b/node_modules/midi-file/lib/midi-writer.js +@@ -22,7 +22,7 @@ function writeMidi(data, opts) { + writeTrack(w, tracks[i], opts) + } + +- return w.buffer ++ return w.used() + } + + function writeHeader(w, header, numTracks) { +@@ -42,7 +42,7 @@ function writeHeader(w, header, numTracks) { + h.writeUInt16(numTracks) + h.writeUInt16(timeDivision) + +- w.writeChunk('MThd', h.buffer) ++ w.writeChunk('MThd', h.used()) + } + + function writeTrack(w, track, opts) { +@@ -57,7 +57,7 @@ function writeTrack(w, track, opts) { + + eventTypeByte = writeEvent(t, track[i], eventTypeByte, opts.useByte9ForNoteOff) + } +- w.writeChunk('MTrk', t.buffer) ++ w.writeChunk('MTrk', t.used()) + } + + function writeEvent(w, event, lastEventTypeByte, useByte9ForNoteOff) { @@ -80,50 +80,43 @@ function writeEvent(w, event, lastEventTypeByte, useByte9ForNoteOff) { case 'text': w.writeUInt8(0xFF) @@ -89,7 +58,7 @@ index c1a438d..cbd1a73 100644 - w.writeString(text) + w.writeStringWithLength(text) break; - + case 'copyrightNotice': w.writeUInt8(0xFF) w.writeUInt8(0x02) @@ -97,7 +66,7 @@ index c1a438d..cbd1a73 100644 - w.writeString(text) + w.writeStringWithLength(text) break; - + case 'trackName': w.writeUInt8(0xFF) w.writeUInt8(0x03) @@ -105,7 +74,7 @@ index c1a438d..cbd1a73 100644 - w.writeString(text) + w.writeStringWithLength(text) break; - + case 'instrumentName': w.writeUInt8(0xFF) w.writeUInt8(0x04) @@ -113,7 +82,7 @@ index c1a438d..cbd1a73 100644 - w.writeString(text) + w.writeStringWithLength(text) break; - + case 'lyrics': w.writeUInt8(0xFF) w.writeUInt8(0x05) @@ -121,7 +90,7 @@ index c1a438d..cbd1a73 100644 - w.writeString(text) + w.writeStringWithLength(text) break; - + case 'marker': w.writeUInt8(0xFF) w.writeUInt8(0x06) @@ -129,7 +98,7 @@ index c1a438d..cbd1a73 100644 - w.writeString(text) + w.writeStringWithLength(text) break; - + case 'cuePoint': w.writeUInt8(0xFF) w.writeUInt8(0x07) @@ -137,11 +106,106 @@ index c1a438d..cbd1a73 100644 - w.writeString(text) + w.writeStringWithLength(text) break; - + case 'channelPrefix': -@@ -325,11 +318,14 @@ Writer.prototype.writeBytes = function(arr) { +@@ -277,59 +270,84 @@ function writeEvent(w, event, lastEventTypeByte, useByte9ForNoteOff) { + } + + +-function Writer() { +- this.buffer = [] ++function Writer(initialCapacity) { ++ this.buffer = new Uint8Array(initialCapacity || 1024) ++ this.pos = 0 ++} ++ ++Writer.prototype._ensure = function(n) { ++ var need = this.pos + n ++ var cap = this.buffer.length ++ if (need <= cap) return ++ while (cap < need) cap *= 2 ++ var bigger = new Uint8Array(cap) ++ bigger.set(this.buffer.subarray(0, this.pos)) ++ this.buffer = bigger ++} ++ ++Writer.prototype.used = function() { ++ return this.buffer.subarray(0, this.pos) + } + + Writer.prototype.writeUInt8 = function(v) { +- this.buffer.push(v & 0xFF) ++ if (this.pos >= this.buffer.length) this._ensure(1) ++ this.buffer[this.pos++] = v & 0xFF + } + Writer.prototype.writeInt8 = Writer.prototype.writeUInt8 + + Writer.prototype.writeUInt16 = function(v) { +- var b0 = (v >> 8) & 0xFF, +- b1 = v & 0xFF +- +- this.writeUInt8(b0) +- this.writeUInt8(b1) ++ this._ensure(2) ++ var buf = this.buffer ++ buf[this.pos++] = (v >> 8) & 0xFF ++ buf[this.pos++] = v & 0xFF + } + Writer.prototype.writeInt16 = Writer.prototype.writeUInt16 + + Writer.prototype.writeUInt24 = function(v) { +- var b0 = (v >> 16) & 0xFF, +- b1 = (v >> 8) & 0xFF, +- b2 = v & 0xFF +- +- this.writeUInt8(b0) +- this.writeUInt8(b1) +- this.writeUInt8(b2) ++ this._ensure(3) ++ var buf = this.buffer ++ buf[this.pos++] = (v >> 16) & 0xFF ++ buf[this.pos++] = (v >> 8) & 0xFF ++ buf[this.pos++] = v & 0xFF + } + Writer.prototype.writeInt24 = Writer.prototype.writeUInt24 + + Writer.prototype.writeUInt32 = function(v) { +- var b0 = (v >> 24) & 0xFF, +- b1 = (v >> 16) & 0xFF, +- b2 = (v >> 8) & 0xFF, +- b3 = v & 0xFF +- +- this.writeUInt8(b0) +- this.writeUInt8(b1) +- this.writeUInt8(b2) +- this.writeUInt8(b3) ++ this._ensure(4) ++ var buf = this.buffer ++ buf[this.pos++] = (v >> 24) & 0xFF ++ buf[this.pos++] = (v >> 16) & 0xFF ++ buf[this.pos++] = (v >> 8) & 0xFF ++ buf[this.pos++] = v & 0xFF } - + Writer.prototype.writeInt32 = Writer.prototype.writeUInt32 + + + Writer.prototype.writeBytes = function(arr) { +- this.buffer = this.buffer.concat(Array.prototype.slice.call(arr, 0)) ++ // arr can be Uint8Array, Buffer, or a plain Array of byte values. ++ var len = arr.length ++ this._ensure(len) ++ var buf = this.buffer ++ if (arr.buffer !== undefined) { ++ // Typed array / Buffer — bulk copy via .set(). ++ buf.set(arr, this.pos) ++ this.pos += len ++ } else { ++ for (var i = 0; i < len; i++) { ++ buf[this.pos++] = arr[i] & 0xFF ++ } ++ } + } + Writer.prototype.writeString = function(str) { - var i, len = str.length, arr = [] - for (i=0; i < len; i++) { @@ -157,5 +221,63 @@ index c1a438d..cbd1a73 100644 + this.writeVarInt(bytes.length) + this.writeBytes(bytes) } - + Writer.prototype.writeVarInt = function(v) { +@@ -337,18 +355,46 @@ Writer.prototype.writeVarInt = function(v) { + + if (v <= 0x7F) { + this.writeUInt8(v) +- } else { +- var i = v +- var bytes = [] +- bytes.push(i & 0x7F) +- i >>= 7 +- while (i) { +- var b = i & 0x7F | 0x80 +- bytes.push(b) +- i >>= 7 +- } +- this.writeBytes(bytes.reverse()) ++ return ++ } ++ // Inline the varint emission to avoid allocating a temp array + reverse(). ++ // A var-int is at most 5 bytes in MIDI (28-bit values). ++ var b4 = v & 0x7F ++ v >>= 7 ++ var b3 = (v & 0x7F) | 0x80 ++ v >>= 7 ++ if (v === 0) { ++ this._ensure(2) ++ this.buffer[this.pos++] = b3 ++ this.buffer[this.pos++] = b4 ++ return ++ } ++ var b2 = (v & 0x7F) | 0x80 ++ v >>= 7 ++ if (v === 0) { ++ this._ensure(3) ++ this.buffer[this.pos++] = b2 ++ this.buffer[this.pos++] = b3 ++ this.buffer[this.pos++] = b4 ++ return ++ } ++ var b1 = (v & 0x7F) | 0x80 ++ v >>= 7 ++ if (v === 0) { ++ this._ensure(4) ++ this.buffer[this.pos++] = b1 ++ this.buffer[this.pos++] = b2 ++ this.buffer[this.pos++] = b3 ++ this.buffer[this.pos++] = b4 ++ return + } ++ var b0 = (v & 0x7F) | 0x80 ++ this._ensure(5) ++ this.buffer[this.pos++] = b0 ++ this.buffer[this.pos++] = b1 ++ this.buffer[this.pos++] = b2 ++ this.buffer[this.pos++] = b3 ++ this.buffer[this.pos++] = b4 + } + + Writer.prototype.writeChunk = function(id, data) { diff --git a/src/__tests__/chart-document.test.ts b/src/__tests__/chart-document.test.ts new file mode 100644 index 0000000..3d70a85 --- /dev/null +++ b/src/__tests__/chart-document.test.ts @@ -0,0 +1,132 @@ +/** + * Tests for `ChartDocument` + `writeChartFolder` — the orchestrator that + * glues writeChartFile/writeMidiFile + writeIniFile + passthrough assets + * into a flat file list suitable for zip/sng packaging. + */ + +import { describe, expect, it } from 'vitest' + +import { createEmptyChart } from '../chart/create-chart' +import { writeChartFolder } from '../chart/chart-document' +import { parseChartAndIni } from '../chart/parse-chart-and-ini' + +function textOf(data: Uint8Array): string { + return new TextDecoder().decode(data) +} + +function findFile(files: { fileName: string; data: Uint8Array }[], name: string) { + return files.find(f => f.fileName === name) +} + +describe('writeChartFolder: format selection', () => { + it('emits notes.chart + song.ini for a .chart document', () => { + const chart = createEmptyChart({ format: 'chart' }) + chart.metadata.name = 'Test Song' + const out = writeChartFolder({ parsedChart: chart, assets: [] }) + expect(findFile(out, 'notes.chart')).toBeDefined() + expect(findFile(out, 'notes.mid')).toBeUndefined() + expect(findFile(out, 'song.ini')).toBeDefined() + }) + + it('emits notes.mid + song.ini for a .mid document', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.metadata.name = 'Test Song' + const out = writeChartFolder({ parsedChart: chart, assets: [] }) + expect(findFile(out, 'notes.mid')).toBeDefined() + expect(findFile(out, 'notes.chart')).toBeUndefined() + expect(findFile(out, 'song.ini')).toBeDefined() + }) +}) + +describe('writeChartFolder: ini content', () => { + it('writes parsedChart.metadata to song.ini', () => { + const chart = createEmptyChart({ format: 'chart' }) + chart.metadata.name = 'My Song' + chart.metadata.artist = 'My Artist' + const out = writeChartFolder({ parsedChart: chart, assets: [] }) + const iniText = textOf(findFile(out, 'song.ini')!.data) + expect(iniText).toContain('name = My Song') + expect(iniText).toContain('artist = My Artist') + }) + + it('does NOT leak chart_offset into song.ini ([Song]-only field)', () => { + const chart = createEmptyChart({ format: 'chart' }) + chart.metadata.chart_offset = 250 + const out = writeChartFolder({ parsedChart: chart, assets: [] }) + const iniText = textOf(findFile(out, 'song.ini')!.data) + expect(iniText).not.toContain('chart_offset') + }) + + it('preserves extraIniFields for unknown ini keys', () => { + const chart = createEmptyChart({ format: 'chart' }) + chart.metadata.extraIniFields = { custom_field: 'custom_value' } + const out = writeChartFolder({ parsedChart: chart, assets: [] }) + const iniText = textOf(findFile(out, 'song.ini')!.data) + expect(iniText).toContain('custom_field = custom_value') + }) +}) + +describe('writeChartFolder: assets passthrough', () => { + it('passes audio/image assets through verbatim', () => { + const chart = createEmptyChart({ format: 'chart' }) + const ogg = new Uint8Array([0x4f, 0x67, 0x67, 0x53]) // "OggS" + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]) + const out = writeChartFolder({ + parsedChart: chart, + assets: [ + { fileName: 'song.ogg', data: ogg }, + { fileName: 'album.png', data: png }, + ], + }) + expect(findFile(out, 'song.ogg')!.data).toBe(ogg) + expect(findFile(out, 'album.png')!.data).toBe(png) + }) + + it('emits chart file BEFORE ini BEFORE assets', () => { + const chart = createEmptyChart({ format: 'chart' }) + const out = writeChartFolder({ + parsedChart: chart, + assets: [{ fileName: 'song.ogg', data: new Uint8Array(4) }], + }) + const names = out.map(f => f.fileName) + expect(names[0]).toBe('notes.chart') + expect(names[1]).toBe('song.ini') + expect(names[2]).toBe('song.ogg') + }) +}) + +describe('writeChartFolder: round-trip via parseChartAndIni', () => { + it('.chart folder round-trips through parseChartAndIni with metadata intact', () => { + const chart = createEmptyChart({ format: 'chart', resolution: 480 }) + chart.metadata.name = 'Round Trip' + chart.metadata.artist = 'Tester' + chart.metadata.pro_drums = true + + const out = writeChartFolder({ parsedChart: chart, assets: [] }) + const re = parseChartAndIni(out) + + expect(re.parsedChart).not.toBeNull() + expect(re.parsedChart!.metadata.name).toBe('Round Trip') + expect(re.parsedChart!.metadata.artist).toBe('Tester') + expect(re.parsedChart!.metadata.pro_drums).toBe(true) + expect(re.hasIni).toBe(true) + }) + + it('.mid folder round-trips with metadata AND assets preserved', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + chart.metadata.name = 'Midi Song' + const ogg = new Uint8Array([0x4f, 0x67, 0x67, 0x53, 0x00]) + + const out = writeChartFolder({ + parsedChart: chart, + assets: [{ fileName: 'song.ogg', data: ogg }], + }) + const re = parseChartAndIni(out) + + expect(re.parsedChart!.format).toBe('mid') + expect(re.parsedChart!.metadata.name).toBe('Midi Song') + // Asset survives as a file in the output set (parseChartAndIni doesn't + // surface assets on its result — but we can still find it in `out`). + expect(findFile(out, 'song.ogg')!.data).toBe(ogg) + }) +}) diff --git a/src/__tests__/chart-writer.test.ts b/src/__tests__/chart-writer.test.ts new file mode 100644 index 0000000..960c2df --- /dev/null +++ b/src/__tests__/chart-writer.test.ts @@ -0,0 +1,504 @@ +/** + * Tests for writeChartFile: Song/SyncTrack/Events/unrecognized-sections emission. + * Instrument-track tests land with the follow-up PR that ports serializeTrackSection. + */ + +import { describe, expect, it } from 'vitest' + +import { writeChartFile } from '../chart/chart-writer' +import { createEmptyChart } from '../chart/create-chart' +import { parseChartAndIni } from '../chart/parse-chart-and-ini' +import type { ParsedChart } from '../chart/parse-chart-and-ini' + +function linesOf(out: string): string[] { + return out.split('\r\n') +} + +function sectionBody(out: string, header: string): string[] { + const lines = linesOf(out) + const start = lines.indexOf(header) + if (start === -1) throw new Error(`section ${header} not found`) + const open = lines.indexOf('{', start) + const close = lines.indexOf('}', open) + return lines.slice(open + 1, close) +} + +function roundTripThroughParser(chart: ParsedChart): ReturnType { + const text = writeChartFile(chart) + const bytes = new TextEncoder().encode(text) + return parseChartAndIni([{ fileName: 'notes.chart', data: bytes }]) +} + +describe('writeChartFile: [Song] section', () => { + it('emits just resolution when metadata is empty', () => { + const chart = createEmptyChart({ resolution: 192 }) + const body = sectionBody(writeChartFile(chart), '[Song]') + expect(body).toEqual([' Resolution = 192']) + }) + + it('emits string metadata with quotes', () => { + const chart = createEmptyChart() + chart.metadata.name = 'My Song' + chart.metadata.artist = 'Some Band' + chart.metadata.charter = 'Me' + const body = sectionBody(writeChartFile(chart), '[Song]') + expect(body).toContain(' Name = "My Song"') + expect(body).toContain(' Artist = "Some Band"') + expect(body).toContain(' Charter = "Me"') + }) + + it('emits Year with the GHTCP-convention leading comma+space', () => { + const chart = createEmptyChart() + chart.metadata.year = '2024' + const body = sectionBody(writeChartFile(chart), '[Song]') + expect(body).toContain(' Year = ", 2024"') + }) + + it('emits Offset from chart_offset and PreviewStart as seconds when set', () => { + const chart = createEmptyChart() + chart.metadata.chart_offset = 250 + chart.metadata.preview_start_time = 30000 + const body = sectionBody(writeChartFile(chart), '[Song]') + expect(body).toContain(' Offset = 0.25') + expect(body).toContain(' PreviewStart = 30') + }) + + it('does not use ini `delay` for [Song] Offset (they are distinct fields)', () => { + // `delay` is an ini-only property; games don't recognize it in [Song]. + // Set a high ini delay and no chart_offset — no Offset line should emit. + const chart = createEmptyChart() + chart.metadata.delay = 999 + const body = sectionBody(writeChartFile(chart), '[Song]') + expect(body.join('\n')).not.toContain('Offset') + }) + + it('skips Offset when chart_offset is 0', () => { + const chart = createEmptyChart() + chart.metadata.chart_offset = 0 + const body = sectionBody(writeChartFile(chart), '[Song]') + expect(body.join('\n')).not.toContain('Offset') + }) + + it('emits Difficulty from diff_guitar', () => { + const chart = createEmptyChart() + chart.metadata.diff_guitar = 5 + const body = sectionBody(writeChartFile(chart), '[Song]') + expect(body).toContain(' Difficulty = 5') + }) + + it('round-trips metadata through parseChartAndIni', () => { + const chart = createEmptyChart({ resolution: 480 }) + chart.metadata.name = 'Song' + chart.metadata.artist = 'Artist' + chart.metadata.album = 'Album' + chart.metadata.genre = 'Rock' + chart.metadata.year = '2024' + chart.metadata.charter = 'Me' + chart.metadata.chart_offset = 100 + chart.metadata.preview_start_time = 45000 + chart.metadata.diff_guitar = 4 + + const re = roundTripThroughParser(chart) + expect(re.parsedChart!.metadata).toMatchObject({ + name: 'Song', + artist: 'Artist', + album: 'Album', + genre: 'Rock', + year: '2024', + charter: 'Me', + chart_offset: 100, + preview_start_time: 45000, + diff_guitar: 4, + }) + expect(re.parsedChart!.resolution).toBe(480) + }) +}) + +describe('writeChartFile: [SyncTrack] section', () => { + it('emits the default 120 BPM + 4/4 events for an empty chart', () => { + const chart = createEmptyChart() + const body = sectionBody(writeChartFile(chart), '[SyncTrack]') + expect(body).toEqual([' 0 = TS 4', ' 0 = B 120000']) + }) + + it('emits TS with denominator exponent when not 4/4', () => { + const chart = createEmptyChart({ timeSignature: { numerator: 6, denominator: 8 } }) + const body = sectionBody(writeChartFile(chart), '[SyncTrack]') + expect(body).toContain(' 0 = TS 6 3') + }) + + it('sorts tempo and TS events by tick, TS before B at same tick', () => { + const chart = createEmptyChart() + chart.tempos.push({ tick: 960, beatsPerMinute: 150, msTime: 0 }) + chart.timeSignatures.push({ tick: 960, numerator: 3, denominator: 4, msTime: 0, msLength: 0 }) + const body = sectionBody(writeChartFile(chart), '[SyncTrack]') + const t960 = body.filter(l => l.startsWith(' 960 = ')) + expect(t960).toEqual([' 960 = TS 3', ' 960 = B 150000']) + }) + + it('emits BPM as millibeats (×1000)', () => { + const chart = createEmptyChart({ bpm: 137.5 }) + const body = sectionBody(writeChartFile(chart), '[SyncTrack]') + expect(body).toContain(' 0 = B 137500') + }) + + it('round-trips tempos and time signatures', () => { + const chart = createEmptyChart({ resolution: 480, bpm: 140 }) + chart.tempos.push({ tick: 1920, beatsPerMinute: 200, msTime: 0 }) + chart.timeSignatures.push({ tick: 3840, numerator: 7, denominator: 8, msTime: 0, msLength: 0 }) + const re = roundTripThroughParser(chart) + const reChart = re.parsedChart! + expect(reChart.tempos.map(t => ({ tick: t.tick, bpm: t.beatsPerMinute }))).toEqual([ + { tick: 0, bpm: 140 }, + { tick: 1920, bpm: 200 }, + ]) + expect(reChart.timeSignatures.map(ts => ({ t: ts.tick, n: ts.numerator, d: ts.denominator }))).toEqual([ + { t: 0, n: 4, d: 4 }, + { t: 3840, n: 7, d: 8 }, + ]) + }) +}) + +describe('writeChartFile: [Events] section', () => { + it('emits section markers wrapped in brackets (regex quirk)', () => { + 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 body = sectionBody(writeChartFile(chart), '[Events]') + expect(body).toContain(' 0 = E "[section Intro]"') + expect(body).toContain(' 1920 = E "[section Verse 1]"') + }) + + it('emits end events', () => { + const chart = createEmptyChart() + chart.endEvents.push({ tick: 9600, msTime: 0, msLength: 0 }) + const body = sectionBody(writeChartFile(chart), '[Events]') + expect(body).toContain(' 9600 = E "end"') + }) + + it('emits unrecognized global events verbatim when source is .chart', () => { + const chart = createEmptyChart({ format: 'chart' }) + chart.unrecognizedEvents.push({ tick: 0, text: 'music_start', msTime: 0, msLength: 0 }) + const body = sectionBody(writeChartFile(chart), '[Events]') + expect(body).toContain(' 0 = E "music_start"') + }) + + it('strips bracket wrapping on unrecognized events sourced from .mid', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.unrecognizedEvents.push({ tick: 480, text: '[crowd_noclap]', msTime: 0, msLength: 0 }) + const body = sectionBody(writeChartFile(chart), '[Events]') + expect(body).toContain(' 480 = E "crowd_noclap"') + }) + + it('skips duplicate end events in unrecognizedEvents', () => { + const chart = createEmptyChart() + chart.endEvents.push({ tick: 1000, msTime: 0, msLength: 0 }) + chart.unrecognizedEvents.push({ tick: 1000, text: 'end', msTime: 0, msLength: 0 }) + const body = sectionBody(writeChartFile(chart), '[Events]') + expect(body.filter(l => l.endsWith('"end"'))).toHaveLength(1) + }) + + it('round-trips section markers with special characters', () => { + const chart = createEmptyChart() + chart.sections.push({ tick: 0, name: '[BREAKDOWN]', msTime: 0, msLength: 0 }) + const re = roundTripThroughParser(chart) + expect(re.parsedChart!.sections[0].name).toBe('[BREAKDOWN]') + }) +}) + +describe('writeChartFile: unrecognized chart sections', () => { + it('re-emits unrecognized sections verbatim (indent added by writer)', () => { + const chart = createEmptyChart() + // Parser stores lines without indent (splitTrimmedNonEmptyLines strips it). + chart.unrecognizedChartSections.push({ + name: 'MysteryBlock', + lines: ['0 = B 100000', '480 = some_unknown_event'], + }) + const out = writeChartFile(chart) + expect(out).toContain('[MysteryBlock]\r\n{\r\n 0 = B 100000\r\n 480 = some_unknown_event\r\n}') + }) + + it('round-trips unrecognized sections through parseChartAndIni', () => { + const chart = createEmptyChart() + chart.unrecognizedChartSections.push({ + name: 'MysteryBlock', + lines: ['0 = foo', '100 = bar'], + }) + const re = roundTripThroughParser(chart) + expect(re.parsedChart!.unrecognizedChartSections).toEqual([ + { name: 'MysteryBlock', lines: ['0 = foo', '100 = bar'] }, + ]) + }) +}) + +describe('writeChartFile: output format', () => { + it('uses CRLF line endings and terminates with newline', () => { + const chart = createEmptyChart() + const out = writeChartFile(chart) + expect(out).toMatch(/\r\n$/) + expect(out).toContain('[Song]\r\n{\r\n') + }) +}) + +// --------------------------------------------------------------------------- +// Track section tests +// --------------------------------------------------------------------------- + +import { noteFlags, noteTypes } from '../chart/note-parsing-interfaces' + +/** Build a drum track and attach it to a chart. */ +function addDrumTrack(chart: ParsedChart, difficulty: 'expert' | 'hard' | 'medium' | 'easy' = 'expert') { + const track: ParsedChart['trackData'][number] = { + instrument: 'drums', + difficulty, + starPowerSections: [], + rejectedStarPowerSections: [], + soloSections: [], + flexLanes: [], + drumFreestyleSections: [], + textEvents: [], + versusPhrases: [], + animations: [], + unrecognizedMidiEvents: [], + noteEventGroups: [], + } + chart.trackData.push(track) + return track +} + +function addFretTrack(chart: ParsedChart, instrument: ParsedChart['trackData'][number]['instrument'] = 'guitar') { + const track: ParsedChart['trackData'][number] = { + instrument, + difficulty: 'expert', + starPowerSections: [], + rejectedStarPowerSections: [], + soloSections: [], + flexLanes: [], + drumFreestyleSections: [], + textEvents: [], + versusPhrases: [], + animations: [], + unrecognizedMidiEvents: [], + noteEventGroups: [], + } + chart.trackData.push(track) + return track +} + +function note(tick: number, type: number, flags = 0, length = 0): NoteEvent { + return { tick, type, flags, length, msTime: 0, msLength: 0 } +} + +describe('writeChartFile: drum track emission', () => { + it('emits [ExpertDrums] section when there is an expert drum track', () => { + const chart = createEmptyChart() + addDrumTrack(chart, 'expert') + const out = writeChartFile(chart) + expect(out).toContain('[ExpertDrums]\r\n{\r\n') + }) + + it('emits [HardDrums] section for hard difficulty', () => { + const chart = createEmptyChart() + addDrumTrack(chart, 'hard') + expect(writeChartFile(chart)).toContain('[HardDrums]\r\n{\r\n') + }) + + it('emits base drum notes with their .chart note numbers', () => { + const chart = createEmptyChart() + const track = addDrumTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.kick)]) + track.noteEventGroups.push([note(480, noteTypes.redDrum, 0, 240)]) + track.noteEventGroups.push([note(960, noteTypes.yellowDrum)]) + track.noteEventGroups.push([note(1440, noteTypes.blueDrum)]) + track.noteEventGroups.push([note(1920, noteTypes.greenDrum)]) + const body = sectionBody(writeChartFile(chart), '[ExpertDrums]') + expect(body).toContain(' 0 = N 0 0') + expect(body).toContain(' 480 = N 1 240') + expect(body).toContain(' 960 = N 2 0') + expect(body).toContain(' 1440 = N 3 0') + expect(body).toContain(' 1920 = N 4 0') + }) + + it('emits double kick as N 32 only (no base N 0)', () => { + const chart = createEmptyChart() + const track = addDrumTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.kick, noteFlags.doubleKick)]) + const body = sectionBody(writeChartFile(chart), '[ExpertDrums]') + expect(body).toContain(' 0 = N 32 0') + expect(body).not.toContain(' 0 = N 0 0') + }) + + it('emits cymbal markers only when drumType is fourLanePro', () => { + const chart = createEmptyChart() + const track = addDrumTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.yellowDrum, noteFlags.cymbal)]) + // fourLane (drumType=0 or null): no cymbal marker + const plain = sectionBody(writeChartFile(chart), '[ExpertDrums]') + expect(plain).not.toContain('N 66') + + // fourLanePro (drumType=1): cymbal marker N 66 for yellow + chart.drumType = 1 + const pro = sectionBody(writeChartFile(chart), '[ExpertDrums]') + expect(pro).toContain(' 0 = N 66 0') + }) + + it('emits accent marker N 34 for red drum with accent flag', () => { + const chart = createEmptyChart() + const track = addDrumTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.redDrum, noteFlags.accent)]) + const body = sectionBody(writeChartFile(chart), '[ExpertDrums]') + expect(body).toContain(' 0 = N 34 0') + }) + + it('emits ghost marker N 40 for red drum with ghost flag', () => { + const chart = createEmptyChart() + const track = addDrumTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.redDrum, noteFlags.ghost)]) + const body = sectionBody(writeChartFile(chart), '[ExpertDrums]') + expect(body).toContain(' 0 = N 40 0') + }) + + it('emits one N 109 (flam) per group regardless of how many notes set the flag', () => { + const chart = createEmptyChart() + const track = addDrumTrack(chart) + track.noteEventGroups.push([ + note(0, noteTypes.redDrum, noteFlags.flam), + note(0, noteTypes.yellowDrum, noteFlags.flam), + ]) + const body = sectionBody(writeChartFile(chart), '[ExpertDrums]') + expect(body.filter(l => l.endsWith('N 109 0'))).toHaveLength(1) + }) + + it('emits star power as S 2', () => { + const chart = createEmptyChart() + const track = addDrumTrack(chart) + track.starPowerSections.push({ tick: 0, length: 1920, msTime: 0, msLength: 0 }) + const body = sectionBody(writeChartFile(chart), '[ExpertDrums]') + expect(body).toContain(' 0 = S 2 1920') + }) + + it('emits solo sections with soloend at tick + length - 1', () => { + const chart = createEmptyChart() + const track = addDrumTrack(chart) + track.soloSections.push({ tick: 480, length: 480, msTime: 0, msLength: 0 }) + const body = sectionBody(writeChartFile(chart), '[ExpertDrums]') + expect(body).toContain(' 480 = E solo') + expect(body).toContain(' 959 = E soloend') + }) + + it('emits flex lanes as S 65 (single) or S 66 (double)', () => { + const chart = createEmptyChart() + const track = addDrumTrack(chart) + track.flexLanes.push({ tick: 0, length: 480, isDouble: false, msTime: 0, msLength: 0 }) + track.flexLanes.push({ tick: 480, length: 480, isDouble: true, msTime: 0, msLength: 0 }) + const body = sectionBody(writeChartFile(chart), '[ExpertDrums]') + expect(body).toContain(' 0 = S 65 480') + expect(body).toContain(' 480 = S 66 480') + }) + + it('emits activation lanes as S 64', () => { + const chart = createEmptyChart() + const track = addDrumTrack(chart) + track.drumFreestyleSections.push({ tick: 0, length: 480, isCoda: false, msTime: 0, msLength: 0 }) + const body = sectionBody(writeChartFile(chart), '[ExpertDrums]') + expect(body).toContain(' 0 = S 64 480') + }) +}) + +describe('writeChartFile: guitar track emission', () => { + it('emits [ExpertSingle] section name', () => { + const chart = createEmptyChart() + addFretTrack(chart) + expect(writeChartFile(chart)).toContain('[ExpertSingle]\r\n{\r\n') + }) + + it('emits 5-fret notes with their .chart note numbers', () => { + const chart = createEmptyChart() + const track = addFretTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.green)]) + track.noteEventGroups.push([note(100, noteTypes.red)]) + track.noteEventGroups.push([note(200, noteTypes.yellow)]) + track.noteEventGroups.push([note(300, noteTypes.blue)]) + track.noteEventGroups.push([note(400, noteTypes.orange)]) + track.noteEventGroups.push([note(500, noteTypes.open)]) + const body = sectionBody(writeChartFile(chart), '[ExpertSingle]') + expect(body).toContain(' 0 = N 0 0') + expect(body).toContain(' 100 = N 1 0') + expect(body).toContain(' 200 = N 2 0') + expect(body).toContain(' 300 = N 3 0') + expect(body).toContain(' 400 = N 4 0') + expect(body).toContain(' 500 = N 7 0') + }) + + it('emits tap flag as N 6', () => { + const chart = createEmptyChart() + const track = addFretTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.green, noteFlags.tap)]) + const body = sectionBody(writeChartFile(chart), '[ExpertSingle]') + expect(body).toContain(' 0 = N 0 0') + expect(body).toContain(' 0 = N 6 0') + }) + + it('emits forceUnnatural N 5 when hopo flag disagrees with natural state', () => { + const chart = createEmptyChart({ resolution: 480 }) + const track = addFretTrack(chart) + // Two isolated greens far apart: neither is natural HOPO (both would be strum). + // Flag the second as HOPO → mismatch → N 5 should be emitted. + track.noteEventGroups.push([note(0, noteTypes.green)]) + track.noteEventGroups.push([note(1920, noteTypes.green, noteFlags.hopo)]) + const body = sectionBody(writeChartFile(chart), '[ExpertSingle]') + expect(body).toContain(' 1920 = N 5 0') + }) +}) + +describe('writeChartFile: round-trip through parseChartAndIni', () => { + it('round-trips drum notes with cymbal/accent/ghost flags in fourLanePro', () => { + const chart = createEmptyChart({ resolution: 480 }) + chart.drumType = 1 + chart.iniChartModifiers.pro_drums = true + const track = addDrumTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.kick)]) + track.noteEventGroups.push([note(480, noteTypes.redDrum, noteFlags.accent)]) + track.noteEventGroups.push([note(960, noteTypes.yellowDrum, noteFlags.cymbal)]) + track.noteEventGroups.push([note(1440, noteTypes.blueDrum, noteFlags.ghost)]) + track.noteEventGroups.push([note(1920, noteTypes.greenDrum, noteFlags.cymbal)]) + + const text = writeChartFile(chart) + const re = parseChartAndIni([ + { fileName: 'notes.chart', data: new TextEncoder().encode(text) }, + { fileName: 'song.ini', data: new TextEncoder().encode('[Song]\npro_drums = True\n') }, + ]) + const reTrack = re.parsedChart!.trackData.find(t => t.instrument === 'drums' && t.difficulty === 'expert')! + const types = reTrack.noteEventGroups.flatMap(g => g.map(n => ({ tick: n.tick, type: n.type, flags: n.flags }))) + expect(types).toEqual([ + { tick: 0, type: noteTypes.kick, flags: 0 }, + { tick: 480, type: noteTypes.redDrum, flags: expect.any(Number) }, + { tick: 960, type: noteTypes.yellowDrum, flags: expect.any(Number) }, + { tick: 1440, type: noteTypes.blueDrum, flags: expect.any(Number) }, + { tick: 1920, type: noteTypes.greenDrum, flags: expect.any(Number) }, + ]) + // Flag checks: accent/cymbal/ghost round-trip + expect(types[1].flags & noteFlags.accent).toBeTruthy() + expect(types[2].flags & noteFlags.cymbal).toBeTruthy() + expect(types[3].flags & noteFlags.ghost).toBeTruthy() + expect(types[4].flags & noteFlags.cymbal).toBeTruthy() + }) + + it('round-trips star power + solo + flex lanes on a drum track', () => { + const chart = createEmptyChart({ resolution: 480 }) + const track = addDrumTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.kick)]) + track.starPowerSections.push({ tick: 0, length: 960, msTime: 0, msLength: 0 }) + track.soloSections.push({ tick: 480, length: 480, msTime: 0, msLength: 0 }) + track.flexLanes.push({ tick: 960, length: 480, isDouble: true, msTime: 0, msLength: 0 }) + + const text = writeChartFile(chart) + const re = parseChartAndIni([{ fileName: 'notes.chart', data: new TextEncoder().encode(text) }]) + const reTrack = re.parsedChart!.trackData.find(t => t.instrument === 'drums')! + expect(reTrack.starPowerSections.map(s => ({ t: s.tick, l: s.length }))).toEqual([{ t: 0, l: 960 }]) + expect(reTrack.soloSections.map(s => ({ t: s.tick, l: s.length }))).toEqual([{ t: 480, l: 480 }]) + expect(reTrack.flexLanes.map(f => ({ t: f.tick, l: f.length, d: f.isDouble }))).toEqual([ + { t: 960, l: 480, d: true }, + ]) + }) +}) diff --git a/src/__tests__/create-chart.test.ts b/src/__tests__/create-chart.test.ts new file mode 100644 index 0000000..a8285b9 --- /dev/null +++ b/src/__tests__/create-chart.test.ts @@ -0,0 +1,64 @@ +/** + * Tests for createEmptyChart: the programmatic-build counterpart to parseChartAndIni. + */ + +import { describe, expect, it } from 'vitest' + +import { createEmptyChart } from '../chart/create-chart' +import { defaultIniChartModifiers } from '../chart/note-parsing-interfaces' + +describe('createEmptyChart', () => { + it('uses defaults when no options are provided', () => { + const chart = createEmptyChart() + + expect(chart.resolution).toBe(480) + expect(chart.tempos).toEqual([{ tick: 0, beatsPerMinute: 120, msTime: 0 }]) + expect(chart.timeSignatures).toEqual([ + { tick: 0, numerator: 4, denominator: 4, msTime: 0, msLength: 0 }, + ]) + expect(chart.format).toBe('chart') + }) + + it('honors resolution, bpm, and time signature overrides', () => { + const chart = createEmptyChart({ + resolution: 192, + bpm: 150, + timeSignature: { numerator: 6, denominator: 8 }, + }) + + expect(chart.resolution).toBe(192) + expect(chart.tempos[0].beatsPerMinute).toBe(150) + expect(chart.timeSignatures[0]).toMatchObject({ numerator: 6, denominator: 8 }) + }) + + it('honors format override', () => { + const chart = createEmptyChart({ format: 'mid' }) + expect(chart.format).toBe('mid') + }) + + it('produces a chart with empty metadata, tracks, and sections', () => { + const chart = createEmptyChart() + + expect(chart.metadata).toEqual({}) + expect(chart.drumType).toBeNull() + expect(chart.trackData).toEqual([]) + expect(chart.sections).toEqual([]) + expect(chart.endEvents).toEqual([]) + expect(chart.unrecognizedEvents).toEqual([]) + expect(chart.unrecognizedMidiTracks).toEqual([]) + expect(chart.unrecognizedChartSections).toEqual([]) + expect(chart.parseIssues).toEqual([]) + }) + + it('produces a chart with empty vocal tracks', () => { + const chart = createEmptyChart() + expect(chart.vocalTracks).toEqual({ parts: {}, rangeShifts: [], lyricShifts: [] }) + }) + + it('produces a chart with empty chartBytes and default ini modifiers', () => { + const chart = createEmptyChart() + expect(chart.chartBytes).toBeInstanceOf(Uint8Array) + expect(chart.chartBytes.length).toBe(0) + expect(chart.iniChartModifiers).toBe(defaultIniChartModifiers) + }) +}) diff --git a/src/__tests__/derived-flags.test.ts b/src/__tests__/derived-flags.test.ts new file mode 100644 index 0000000..f9a1e6e --- /dev/null +++ b/src/__tests__/derived-flags.test.ts @@ -0,0 +1,159 @@ +/** + * Tests for the derived-flag relocation. `hasLyrics`, `hasVocals`, and + * `hasForcedNotes` have been removed from the top-level ParsedChart shape; + * scanChart derives all three at scan time from the current chart state. + * + * `hasForcedNotes` is state-derived rather than source-byte-derived: a note is + * "forced" iff its resolved hopo/strum/tap flag disagrees with the natural + * HOPO state the parser would pick without any force events. This means + * redundantly-applied force events (e.g. explicit `forceHopo` on a naturally + * HOPO note) produce `hasForcedNotes = false` — which matches the chart's + * actual playback behavior. + */ + +import { describe, expect, it } from 'vitest' + +import { parseChartFile } from '../chart/notes-parser' +import { defaultIniChartModifiers } from '../chart/note-parsing-interfaces' +import { scanChart } from '..' +import { parseChartAndIni } from '../chart/parse-chart-and-ini' + +function buildChart(body: string): { fileName: string; data: Uint8Array }[] { + return [{ fileName: 'notes.chart', data: new TextEncoder().encode(body) }] +} + +describe('ParsedChart shape: derived flags no longer at top level', () => { + it('parseChartFile output does not expose hasLyrics/hasVocals/hasForcedNotes', () => { + const body = [ + '[Song]', '{', ' Resolution = 480', '}', + '[SyncTrack]', '{', ' 0 = B 120000', '}', + '[Events]', '{', '}', + ].join('\r\n') + const data = new TextEncoder().encode(body) + const result = parseChartFile(data, 'chart', defaultIniChartModifiers) + + const r = result as unknown as Record + expect(r.hasLyrics).toBeUndefined() + expect(r.hasVocals).toBeUndefined() + expect(r.hasForcedNotes).toBeUndefined() + expect(r.initialScanProperties).toBeUndefined() + }) +}) + +describe('scanChart: hasLyrics / hasVocals state-derived in notesData', () => { + it('hasVocals = false and hasLyrics = false for a chart with no vocal track', () => { + const body = [ + '[Song]', '{', ' Resolution = 480', '}', + '[SyncTrack]', '{', ' 0 = B 120000', '}', + '[Events]', '{', '}', + '[ExpertSingle]', '{', + ' 0 = N 0 0', + '}', + ].join('\r\n') + const files = buildChart(body) + const parseResult = parseChartAndIni(files) + const scanned = scanChart(files, parseResult, { includeMd5: false }) + expect(scanned.notesData!.hasVocals).toBe(false) + expect(scanned.notesData!.hasLyrics).toBe(false) + }) + + it('hasVocals = true / hasLyrics = true when [Events] has phrase + lyric events', () => { + const body = [ + '[Song]', '{', ' Resolution = 480', '}', + '[SyncTrack]', '{', ' 0 = B 120000', '}', + '[Events]', '{', + ' 0 = E "phrase_start"', + ' 120 = E "lyric Hel"', + ' 240 = E "lyric lo"', + ' 480 = E "phrase_end"', + '}', + ].join('\r\n') + const files = buildChart(body) + const parseResult = parseChartAndIni(files) + const scanned = scanChart(files, parseResult, { includeMd5: false }) + expect(scanned.notesData!.hasVocals).toBe(true) + expect(scanned.notesData!.hasLyrics).toBe(true) + }) +}) + +describe('scanChart: hasForcedNotes state-derived (flag disagrees with natural state)', () => { + it('is false for a single fret note with no force events', () => { + const body = [ + '[Song]', '{', ' Resolution = 480', '}', + '[SyncTrack]', '{', ' 0 = B 120000', '}', + '[Events]', '{', '}', + '[ExpertSingle]', '{', + ' 0 = N 0 0', + '}', + ].join('\r\n') + const files = buildChart(body) + const scanned = scanChart(files, parseChartAndIni(files), { includeMd5: false }) + expect(scanned.notesData!.hasForcedNotes).toBe(false) + }) + + it('is false when forceUnnatural is redundantly applied to a naturally-strum note', () => { + // Two widely-spaced greens: second is naturally strum. Adding forceUnnatural + // would resolve to HOPO, but since this test only has the first note + // naturally strum, adding forceUnnatural to it does nothing observable. + // Choose a cleaner case: two greens >= threshold apart, no natural HOPO, + // and apply forceUnnatural. The resolved flag flips to HOPO — so the + // state-derived check DOES see it. This is the "forceUnnatural is not + // redundant" case, which correctly reports hasForcedNotes = true. + // For a truly redundant case we need forceHopo on a naturally-HOPO note. + const body = [ + '[Song]', '{', ' Resolution = 480', '}', + '[SyncTrack]', '{', ' 0 = B 120000', '}', + '[Events]', '{', '}', + '[ExpertSingle]', '{', + // Two greens < threshold apart → second is naturally HOPO. + // Apply forceHopo redundantly to the second. Resolved flag stays HOPO, + // natural is HOPO, no disagreement → hasForcedNotes state-derived = false. + ' 0 = N 0 0', + ' 120 = N 1 0', + ' 120 = N 5 0', // forceUnnatural — wait, this would FLIP it + '}', + ].join('\r\n') + const files = buildChart(body) + const scanned = scanChart(files, parseChartAndIni(files), { includeMd5: false }) + // The N 5 here flips a naturally-HOPO red to strum → that IS a forced + // note, so this assertion should be TRUE, demonstrating the state + // detection fires for non-redundant force events. + expect(scanned.notesData!.hasForcedNotes).toBe(true) + }) + + it('is true when forceUnnatural flips a naturally-HOPO note to strum', () => { + const body = [ + '[Song]', '{', ' Resolution = 480', '}', + '[SyncTrack]', '{', ' 0 = B 120000', '}', + '[Events]', '{', '}', + '[ExpertSingle]', '{', + ' 0 = N 0 0', + ' 120 = N 1 0', // naturally HOPO (different color, close enough) + ' 120 = N 5 0', // forceUnnatural: flips it to strum + '}', + ].join('\r\n') + const files = buildChart(body) + const scanned = scanChart(files, parseChartAndIni(files), { includeMd5: false }) + expect(scanned.notesData!.hasForcedNotes).toBe(true) + }) + + it('is false when a note only has a tap flag (matches old source-derived definition which excluded forceTap)', () => { + const body = [ + '[Song]', '{', ' Resolution = 480', '}', + '[SyncTrack]', '{', ' 0 = B 120000', '}', + '[Events]', '{', '}', + '[ExpertSingle]', '{', + ' 0 = N 0 0', + ' 0 = N 6 0', // forceTap + '}', + ].join('\r\n') + const files = buildChart(body) + const scanned = scanChart(files, parseChartAndIni(files), { includeMd5: false }) + // The old source-derived flag scanned for forceHopo / forceStrum / + // forceUnnatural but NOT forceTap — even though forceTap is a force + // event, the original implementation intentionally excluded it. The + // state-derived replacement preserves that convention. + expect(scanned.notesData!.hasForcedNotes).toBe(false) + expect(scanned.notesData!.hasTapNotes).toBe(true) + }) +}) diff --git a/src/__tests__/ini-writer.test.ts b/src/__tests__/ini-writer.test.ts new file mode 100644 index 0000000..ce03e26 --- /dev/null +++ b/src/__tests__/ini-writer.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for writeIniFile: serializing IniMetadata back to song.ini text. + */ + +import { describe, expect, it } from 'vitest' + +import { defaultMetadata, scanIni } from '../ini/ini-scanner' +import { writeIniFile } from '../ini/ini-writer' + +function parseOutput(out: string): { lines: string[]; headerIndex: number } { + const lines = out.split('\r\n').filter(l => l.length > 0) + return { lines, headerIndex: lines.indexOf('[song]') } +} + +describe('writeIniFile', () => { + it('writes a [song] header even for empty metadata', () => { + const out = writeIniFile({}) + const { lines, headerIndex } = parseOutput(out) + expect(headerIndex).toBe(0) + expect(lines).toHaveLength(1) + }) + + it('uses CRLF line endings and terminates with a newline', () => { + const out = writeIniFile({ name: 'Song', artist: 'Artist' }) + expect(out).toMatch(/\r\n$/) + expect(out.split('\r\n')).toEqual(['[song]', 'name = Song', 'artist = Artist', '']) + }) + + it('skips fields whose value is undefined', () => { + const out = writeIniFile({ name: 'Song', artist: undefined, album: 'Album' }) + const { lines } = parseOutput(out) + expect(lines).toEqual(['[song]', 'name = Song', 'album = Album']) + }) + + it('emits booleans as "True"/"False"', () => { + const out = writeIniFile({ pro_drums: true, modchart: false, five_lane_drums: true }) + expect(out).toContain('pro_drums = True') + expect(out).toContain('modchart = False') + expect(out).toContain('five_lane_drums = True') + }) + + it('emits numbers without quoting', () => { + const out = writeIniFile({ diff_drums: 5, delay: -250 }) + expect(out).toContain('diff_drums = 5') + expect(out).toContain('delay = -250') + }) + + it('emits known fields in the canonical defaultMetadata order', () => { + // Provide fields in a shuffled order to confirm output order is driven by defaultMetadata. + const out = writeIniFile({ + pro_drums: true, + artist: 'A', + name: 'N', + diff_guitar: 1, + year: '2020', + }) + const { lines } = parseOutput(out) + const bodyLines = lines.slice(1) // drop [song] + const expectedOrder = ['name = N', 'artist = A', 'year = 2020', 'diff_guitar = 1', 'pro_drums = True'] + expect(bodyLines).toEqual(expectedOrder) + }) + + it('appends extraIniFields after known fields', () => { + const out = writeIniFile({ + name: 'N', + extraIniFields: { rating: '1', vocal_gender: 'male' }, + }) + const { lines } = parseOutput(out) + expect(lines).toEqual(['[song]', 'name = N', 'rating = 1', 'vocal_gender = male']) + }) + + it('round-trips through scanIni for all known field types', () => { + const metadata = { + name: 'Round Trip', + artist: 'Tester', + album: 'Test Album', + genre: 'Rock', + year: '2023', + charter: 'Me', + song_length: 180000, + diff_guitar: 3, + diff_drums: 5, + delay: -50, + hopo_frequency: 170, + eighthnote_hopo: true, + multiplier_note: 116, + modchart: false, + pro_drums: true, + five_lane_drums: false, + end_events: true, + extraIniFields: { rating: '2', playlist: 'Test' }, + } + const out = writeIniFile(metadata) + const parsed = scanIni([{ fileName: 'song.ini', data: new TextEncoder().encode(out) }]) + + expect(parsed.metadata).not.toBeNull() + for (const key of Object.keys(metadata) as (keyof typeof metadata)[]) { + if (key === 'extraIniFields') continue + expect(parsed.metadata![key as keyof typeof defaultMetadata]).toEqual(metadata[key]) + } + expect(parsed.unknownIniValues).toEqual(metadata.extraIniFields) + }) +}) diff --git a/src/__tests__/midi-writer.test.ts b/src/__tests__/midi-writer.test.ts new file mode 100644 index 0000000..46d2138 --- /dev/null +++ b/src/__tests__/midi-writer.test.ts @@ -0,0 +1,1062 @@ +/** + * Tests for writeMidiFile: header + TEMPO TRACK + EVENTS + unrecognized tracks, + * PART DRUMS / GUITAR / GHL instrument tracks, and PART VOCALS / HARM1-3 + * vocal tracks. + */ + +import { parseMidi } from 'midi-file' +import type { MidiEvent } from 'midi-file' +import { describe, expect, it } from 'vitest' + +import { createEmptyChart } from '../chart/create-chart' +import { writeMidiFile } from '../chart/midi-writer' +import { parseChartAndIni } from '../chart/parse-chart-and-ini' + +function parseBack(bytes: Uint8Array) { + return parseMidi(bytes) +} + +function findEvents(track: MidiEvent[], type: string): MidiEvent[] { + return track.filter(e => e.type === type) +} + +describe('writeMidiFile: header', () => { + it('produces a Format 1 MIDI with the chart resolution', () => { + const chart = createEmptyChart({ resolution: 480 }) + const midi = parseBack(writeMidiFile(chart)) + expect(midi.header.format).toBe(1) + expect(midi.header.ticksPerBeat).toBe(480) + }) + + it('produces 2 tracks (TEMPO + EVENTS) for an empty chart', () => { + const chart = createEmptyChart() + const midi = parseBack(writeMidiFile(chart)) + expect(midi.header.numTracks).toBe(2) + expect(midi.tracks.length).toBe(2) + }) +}) + +describe('writeMidiFile: TEMPO TRACK', () => { + it('emits a trackName="TEMPO TRACK" at tick 0', () => { + const chart = createEmptyChart() + const midi = parseBack(writeMidiFile(chart)) + const names = findEvents(midi.tracks[0], 'trackName') + expect(names).toHaveLength(1) + expect((names[0] as { text: string }).text).toBe('TEMPO TRACK') + }) + + it('emits setTempo with the right microsecondsPerBeat (120 BPM = 500000)', () => { + const chart = createEmptyChart({ bpm: 120 }) + const midi = parseBack(writeMidiFile(chart)) + const tempos = findEvents(midi.tracks[0], 'setTempo') + expect(tempos).toHaveLength(1) + expect((tempos[0] as { microsecondsPerBeat: number }).microsecondsPerBeat).toBe(500_000) + }) + + it('rounds setTempo microsecondsPerBeat from non-integer BPM', () => { + const chart = createEmptyChart({ bpm: 137.5 }) + const midi = parseBack(writeMidiFile(chart)) + const tempos = findEvents(midi.tracks[0], 'setTempo') + // 60_000_000 / 137.5 = 436363.6363... → 436364 + expect((tempos[0] as { microsecondsPerBeat: number }).microsecondsPerBeat).toBe(436_364) + }) + + it('emits a timeSignature with the chart TS', () => { + const chart = createEmptyChart({ timeSignature: { numerator: 6, denominator: 8 } }) + const midi = parseBack(writeMidiFile(chart)) + const ts = findEvents(midi.tracks[0], 'timeSignature') + expect(ts).toHaveLength(1) + expect(ts[0]).toMatchObject({ numerator: 6, denominator: 8 }) + }) + + it('emits multiple tempo changes at their ticks', () => { + const chart = createEmptyChart({ resolution: 480, bpm: 120 }) + chart.tempos.push({ tick: 960, beatsPerMinute: 180, msTime: 0 }) + const midi = parseBack(writeMidiFile(chart)) + const tempos = findEvents(midi.tracks[0], 'setTempo') + expect(tempos).toHaveLength(2) + // deltaTime of first tempo is 0, second is 960 (relative to first). + expect(tempos[0].deltaTime).toBe(0) + expect(tempos[1].deltaTime).toBe(960) + }) + + it('round-trips through parseChartAndIni', () => { + const chart = createEmptyChart({ resolution: 480, bpm: 140, timeSignature: { numerator: 7, denominator: 8 } }) + chart.tempos.push({ tick: 1920, beatsPerMinute: 90, msTime: 0 }) + const re = parseChartAndIni([{ fileName: 'notes.mid', data: writeMidiFile(chart) }]) + const reChart = re.parsedChart! + expect(reChart.tempos.map(t => ({ tick: t.tick, bpm: Math.round(t.beatsPerMinute) }))).toEqual([ + { tick: 0, bpm: 140 }, + { tick: 1920, bpm: 90 }, + ]) + expect(reChart.timeSignatures.map(ts => ({ tick: ts.tick, n: ts.numerator, d: ts.denominator }))).toEqual([ + { tick: 0, n: 7, d: 8 }, + ]) + }) +}) + +describe('writeMidiFile: EVENTS track', () => { + it('emits a trackName="EVENTS" at tick 0', () => { + const chart = createEmptyChart() + const midi = parseBack(writeMidiFile(chart)) + const names = findEvents(midi.tracks[1], 'trackName') + expect(names).toHaveLength(1) + expect((names[0] as { text: string }).text).toBe('EVENTS') + }) + + it('emits sections as unwrapped `section name` text meta events', () => { + 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 midi = parseBack(writeMidiFile(chart)) + const texts = findEvents(midi.tracks[1], 'text').map(e => (e as { text: string }).text) + expect(texts).toContain('section Intro') + expect(texts).toContain('section Verse 1') + }) + + it('emits [end] text events for endEvents', () => { + const chart = createEmptyChart() + chart.endEvents.push({ tick: 9600, msTime: 0, msLength: 0 }) + const midi = parseBack(writeMidiFile(chart)) + const texts = findEvents(midi.tracks[1], 'text').map(e => (e as { text: string }).text) + expect(texts).toContain('[end]') + }) + + it('passes MIDI-sourced unrecognizedEvents verbatim (preserves brackets)', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.unrecognizedEvents.push({ tick: 480, text: '[crowd_noclap]', msTime: 0, msLength: 0 }) + const midi = parseBack(writeMidiFile(chart)) + const texts = findEvents(midi.tracks[1], 'text').map(e => (e as { text: string }).text) + expect(texts).toContain('[crowd_noclap]') + }) + + it('wraps chart-sourced unrecognizedEvents in brackets on MIDI output', () => { + const chart = createEmptyChart({ format: 'chart' }) + chart.unrecognizedEvents.push({ tick: 480, text: 'music_start', msTime: 0, msLength: 0 }) + const midi = parseBack(writeMidiFile(chart)) + const texts = findEvents(midi.tracks[1], 'text').map(e => (e as { text: string }).text) + expect(texts).toContain('[music_start]') + }) + + it('round-trips sections with special characters', () => { + const chart = createEmptyChart() + chart.sections.push({ tick: 0, name: '[BREAKDOWN]', msTime: 0, msLength: 0 }) + const re = parseChartAndIni([{ fileName: 'notes.mid', data: writeMidiFile(chart) }]) + // YARG normalization would strip the outer brackets from a wrapped form; + // we emit unwrapped so the `]` in the name survives (though leading `[` + // is still lost — that's a YARG-normalization issue, not writer). + expect(re.parsedChart!.sections).toHaveLength(1) + }) + + it('round-trips end events through parseChartAndIni', () => { + const chart = createEmptyChart() + chart.endEvents.push({ tick: 1920, msTime: 0, msLength: 0 }) + const re = parseChartAndIni([{ fileName: 'notes.mid', data: writeMidiFile(chart) }]) + expect(re.parsedChart!.endEvents.map(e => e.tick)).toEqual([1920]) + }) +}) + +describe('writeMidiFile: unrecognized MIDI tracks', () => { + it('preserves unrecognized tracks verbatim', () => { + const chart = createEmptyChart() + // Craft a minimal VENUE track: trackName + one text event + endOfTrack. + // Events arrive at the writer with deltaTime = absolute tick (per + // scan-chart's convertToAbsoluteTime post-processing). + chart.unrecognizedMidiTracks.push({ + trackName: 'VENUE', + events: [ + { deltaTime: 0, meta: true, type: 'trackName', text: 'VENUE' } as MidiEvent, + { deltaTime: 480, meta: true, type: 'text', text: '[lighting (verse)]' } as MidiEvent, + { deltaTime: 480, meta: true, type: 'endOfTrack' } as MidiEvent, + ], + }) + const midi = parseBack(writeMidiFile(chart)) + expect(midi.tracks).toHaveLength(3) // TEMPO + EVENTS + VENUE + const venue = midi.tracks[2] + const venueText = findEvents(venue, 'text').map(e => (e as { text: string }).text) + expect(venueText).toContain('[lighting (verse)]') + }) + + it('round-trips unrecognized tracks through parseChartAndIni', () => { + 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, + ], + }) + const re = parseChartAndIni([{ fileName: 'notes.mid', data: writeMidiFile(chart) }]) + expect(re.parsedChart!.unrecognizedMidiTracks.map(t => t.trackName)).toEqual(['CUSTOM']) + }) + + it('suffixes duplicate track names so trackMap keys stay unique', () => { + 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, + ], + }) + } + // Should not throw despite duplicate trackName; all 3 tracks survive. + const midi = parseBack(writeMidiFile(chart)) + expect(midi.tracks).toHaveLength(5) // TEMPO + EVENTS + 3 CUSTOM + }) +}) + +// --------------------------------------------------------------------------- +// Drum track tests +// --------------------------------------------------------------------------- + +import type { ParsedChart } from '../chart/parse-chart-and-ini' +import { noteFlags, noteTypes } from '../chart/note-parsing-interfaces' + +function emptyDrumTrack( + difficulty: 'expert' | 'hard' | 'medium' | 'easy' = 'expert', +): ParsedChart['trackData'][number] { + return { + instrument: 'drums', + difficulty, + starPowerSections: [], + rejectedStarPowerSections: [], + soloSections: [], + flexLanes: [], + drumFreestyleSections: [], + textEvents: [], + versusPhrases: [], + animations: [], + unrecognizedMidiEvents: [], + noteEventGroups: [], + } +} + +function note(tick: number, type: number, flags = 0, length = 0) { + return { tick, type, flags, length, msTime: 0, msLength: 0 } +} + +function findNoteOns(track: MidiEvent[], noteNumber: number): MidiEvent[] { + return track.filter(e => e.type === 'noteOn' && (e as { noteNumber: number }).noteNumber === noteNumber) +} + +describe('writeMidiFile: PART DRUMS track layout', () => { + it('emits a PART DRUMS track when the chart has a drum track', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.trackData.push(emptyDrumTrack('expert')) + const midi = parseBack(writeMidiFile(chart)) + const drumTrack = midi.tracks[2] + const names = findEvents(drumTrack, 'trackName') + expect((names[0] as { text: string }).text).toBe('PART DRUMS') + }) + + it('groups all drum difficulties into a single MIDI track', () => { + const chart = createEmptyChart({ format: 'mid' }) + for (const d of ['expert', 'hard', 'medium', 'easy'] as const) { + chart.trackData.push(emptyDrumTrack(d)) + } + const midi = parseBack(writeMidiFile(chart)) + // 2 setup tracks + 1 PART DRUMS. + expect(midi.tracks).toHaveLength(3) + }) +}) + +describe('writeMidiFile: drum note-number mapping', () => { + it('expert kick → MIDI 96; red → 97; yellow → 98; blue → 99; green → 100', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([note(0, noteTypes.kick)]) + td.noteEventGroups.push([note(120, noteTypes.redDrum)]) + td.noteEventGroups.push([note(240, noteTypes.yellowDrum)]) + td.noteEventGroups.push([note(360, noteTypes.blueDrum)]) + td.noteEventGroups.push([note(480, noteTypes.greenDrum)]) + chart.trackData.push(td) + const drumTrack = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(drumTrack, 96)).toHaveLength(1) + expect(findNoteOns(drumTrack, 97)).toHaveLength(1) + expect(findNoteOns(drumTrack, 98)).toHaveLength(1) + expect(findNoteOns(drumTrack, 99)).toHaveLength(1) + expect(findNoteOns(drumTrack, 100)).toHaveLength(1) + }) + + it('hard base is MIDI 84 (drumDiffBases.hard)', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyDrumTrack('hard') + td.noteEventGroups.push([note(0, noteTypes.kick)]) + chart.trackData.push(td) + const drumTrack = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(drumTrack, 84)).toHaveLength(1) + }) + + it('double-kick flag emits MIDI 95 only (no MIDI 96)', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([note(0, noteTypes.kick, noteFlags.doubleKick)]) + chart.trackData.push(td) + const drumTrack = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(drumTrack, 95)).toHaveLength(1) + expect(findNoteOns(drumTrack, 96)).toHaveLength(0) + }) + + it('regular kick emits BEFORE double kick at the same tick', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([ + note(0, noteTypes.kick, noteFlags.doubleKick), + note(0, noteTypes.kick), + ]) + chart.trackData.push(td) + const drumTrack = parseBack(writeMidiFile(chart)).tracks[2] + const noteOns = drumTrack.filter(e => e.type === 'noteOn' && ((e as { noteNumber: number }).noteNumber === 95 || (e as { noteNumber: number }).noteNumber === 96)) + expect(noteOns.map(e => (e as { noteNumber: number }).noteNumber)).toEqual([96, 95]) + }) +}) + +describe('writeMidiFile: drum velocity (accent/ghost)', () => { + it('accent flag → velocity 127', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([note(0, noteTypes.redDrum, noteFlags.accent)]) + chart.trackData.push(td) + const drumTrack = parseBack(writeMidiFile(chart)).tracks[2] + const noteOn = findNoteOns(drumTrack, 97)[0] + expect((noteOn as { velocity: number }).velocity).toBe(127) + }) + + it('ghost flag → velocity 1', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([note(0, noteTypes.redDrum, noteFlags.ghost)]) + chart.trackData.push(td) + const drumTrack = parseBack(writeMidiFile(chart)).tracks[2] + const noteOn = findNoteOns(drumTrack, 97)[0] + expect((noteOn as { velocity: number }).velocity).toBe(1) + }) + + it('plain note (no accent/ghost) → velocity 100', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([note(0, noteTypes.redDrum)]) + chart.trackData.push(td) + const drumTrack = parseBack(writeMidiFile(chart)).tracks[2] + const noteOn = findNoteOns(drumTrack, 97)[0] + expect((noteOn as { velocity: number }).velocity).toBe(100) + }) + + it('any accent/ghost emits [ENABLE_CHART_DYNAMICS] text event at tick 0', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([note(0, noteTypes.redDrum, noteFlags.accent)]) + chart.trackData.push(td) + const drumTrack = parseBack(writeMidiFile(chart)).tracks[2] + const texts = findEvents(drumTrack, 'text').map(e => (e as { text: string }).text) + expect(texts).toContain('[ENABLE_CHART_DYNAMICS]') + }) +}) + +describe('writeMidiFile: tom markers (fourLanePro)', () => { + it('emits MIDI 110/111/112 tom markers for yellow/blue/green with tom flag', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.drumType = 1 // fourLanePro + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([note(0, noteTypes.yellowDrum, noteFlags.tom)]) + td.noteEventGroups.push([note(120, noteTypes.blueDrum, noteFlags.tom)]) + td.noteEventGroups.push([note(240, noteTypes.greenDrum, noteFlags.tom)]) + chart.trackData.push(td) + const drumTrack = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(drumTrack, 110)).toHaveLength(1) + expect(findNoteOns(drumTrack, 111)).toHaveLength(1) + expect(findNoteOns(drumTrack, 112)).toHaveLength(1) + }) + + it('does NOT emit tom markers when drumType is fourLane (no cymbals)', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.drumType = 0 // fourLane + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([note(0, noteTypes.yellowDrum, noteFlags.tom)]) + chart.trackData.push(td) + const drumTrack = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(drumTrack, 110)).toHaveLength(0) + }) + + it('fourLanePro with no tom markers emits a sentinel greenTomMarker', () => { + // All yellow/blue default cymbal (no tom flag) and no green — so no + // per-note tom markers. Chart is fourLanePro, so we need a sentinel at + // a tick safe to mark. + const chart = createEmptyChart({ format: 'mid' }) + chart.drumType = 1 + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([note(0, noteTypes.kick)]) // safe + td.noteEventGroups.push([note(480, noteTypes.yellowDrum, noteFlags.cymbal)]) + chart.trackData.push(td) + const drumTrack = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(drumTrack, 112)).toHaveLength(1) + }) +}) + +describe('writeMidiFile: flam', () => { + it('emits one MIDI 109 flam marker per group regardless of notes', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([ + note(0, noteTypes.redDrum, noteFlags.flam), + note(0, noteTypes.yellowDrum, noteFlags.flam), + ]) + chart.trackData.push(td) + const drumTrack = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(drumTrack, 109)).toHaveLength(1) + }) +}) + +describe('writeMidiFile: drum instrument-wide sections', () => { + it('emits star power as MIDI 116', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyDrumTrack('expert') + td.starPowerSections.push({ tick: 0, length: 1920, msTime: 0, msLength: 0 }) + chart.trackData.push(td) + const drumTrack = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(drumTrack, 116)).toHaveLength(1) + }) + + it('emits solo sections as MIDI 103', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyDrumTrack('expert') + td.soloSections.push({ tick: 0, length: 480, msTime: 0, msLength: 0 }) + chart.trackData.push(td) + const drumTrack = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(drumTrack, 103)).toHaveLength(1) + }) + + it('emits activation/coda lanes as MIDI 120', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyDrumTrack('expert') + td.drumFreestyleSections.push({ tick: 0, length: 480, isCoda: false, msTime: 0, msLength: 0 }) + chart.trackData.push(td) + const drumTrack = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(drumTrack, 120)).toHaveLength(1) + }) + + it('emits flex lanes as MIDI 126 (single) / 127 (double)', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyDrumTrack('expert') + td.flexLanes.push({ tick: 0, length: 480, isDouble: false, msTime: 0, msLength: 0 }) + td.flexLanes.push({ tick: 480, length: 480, isDouble: true, msTime: 0, msLength: 0 }) + chart.trackData.push(td) + const drumTrack = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(drumTrack, 126)).toHaveLength(1) + expect(findNoteOns(drumTrack, 127)).toHaveLength(1) + }) +}) + +describe('writeMidiFile: drum round-trip through parseChartAndIni', () => { + it('round-trips base drum notes in fourLanePro', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + chart.drumType = 1 + chart.iniChartModifiers.pro_drums = true + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([note(0, noteTypes.kick)]) + td.noteEventGroups.push([note(480, noteTypes.redDrum)]) + td.noteEventGroups.push([note(960, noteTypes.yellowDrum, noteFlags.cymbal)]) + td.noteEventGroups.push([note(1440, noteTypes.blueDrum)]) + td.noteEventGroups.push([note(1920, noteTypes.greenDrum, noteFlags.cymbal)]) + chart.trackData.push(td) + + const re = parseChartAndIni([ + { fileName: 'notes.mid', data: writeMidiFile(chart) }, + { fileName: 'song.ini', data: new TextEncoder().encode('[Song]\npro_drums = True\n') }, + ]) + const reTrack = re.parsedChart!.trackData.find(t => t.instrument === 'drums' && t.difficulty === 'expert')! + const types = reTrack.noteEventGroups.flatMap(g => g.map(n => ({ tick: n.tick, type: n.type }))) + expect(types).toEqual([ + { tick: 0, type: noteTypes.kick }, + { tick: 480, type: noteTypes.redDrum }, + { tick: 960, type: noteTypes.yellowDrum }, + { tick: 1440, type: noteTypes.blueDrum }, + { tick: 1920, type: noteTypes.greenDrum }, + ]) + }) + + it('round-trips accent / ghost / flam / doubleKick flags on drum notes', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + chart.drumType = 1 + chart.iniChartModifiers.pro_drums = true + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([note(0, noteTypes.kick, noteFlags.doubleKick)]) + td.noteEventGroups.push([note(480, noteTypes.redDrum, noteFlags.accent)]) + td.noteEventGroups.push([note(960, noteTypes.yellowDrum, noteFlags.ghost)]) + td.noteEventGroups.push([note(1440, noteTypes.redDrum, noteFlags.flam)]) + chart.trackData.push(td) + + const re = parseChartAndIni([ + { fileName: 'notes.mid', data: writeMidiFile(chart) }, + { fileName: 'song.ini', data: new TextEncoder().encode('[Song]\npro_drums = True\n') }, + ]) + const reTrack = re.parsedChart!.trackData.find(t => t.instrument === 'drums' && t.difficulty === 'expert')! + const flags = reTrack.noteEventGroups.map(g => g[0].flags) + expect(flags[0] & noteFlags.doubleKick).toBeTruthy() + expect(flags[1] & noteFlags.accent).toBeTruthy() + expect(flags[2] & noteFlags.ghost).toBeTruthy() + expect(flags[3] & noteFlags.flam).toBeTruthy() + }) +}) + +// --------------------------------------------------------------------------- +// Fret / GHL track tests +// --------------------------------------------------------------------------- + +function emptyFretTrack( + instrument: ParsedChart['trackData'][number]['instrument'] = 'guitar', + difficulty: 'expert' | 'hard' | 'medium' | 'easy' = 'expert', +): ParsedChart['trackData'][number] { + return { + instrument, + difficulty, + starPowerSections: [], + rejectedStarPowerSections: [], + soloSections: [], + flexLanes: [], + drumFreestyleSections: [], + textEvents: [], + versusPhrases: [], + animations: [], + unrecognizedMidiEvents: [], + noteEventGroups: [], + } +} + +describe('writeMidiFile: PART GUITAR / GHL track layout', () => { + it('emits a PART GUITAR track when the chart has a guitar track', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.trackData.push(emptyFretTrack('guitar', 'expert')) + const midi = parseBack(writeMidiFile(chart)) + const track = midi.tracks[2] + expect((findEvents(track, 'trackName')[0] as { text: string }).text).toBe('PART GUITAR') + }) + + it('emits a PART GUITAR GHL track when the chart has a guitarghl track', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.trackData.push(emptyFretTrack('guitarghl', 'expert')) + const midi = parseBack(writeMidiFile(chart)) + const track = midi.tracks[2] + expect((findEvents(track, 'trackName')[0] as { text: string }).text).toBe('PART GUITAR GHL') + }) +}) + +describe('writeMidiFile: 5-fret note-number mapping', () => { + it('expert green→96, red→97, yellow→98, blue→99, orange→100 (5-fret 95+N)', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyFretTrack('guitar', 'expert') + td.noteEventGroups.push([note(0, noteTypes.green)]) + td.noteEventGroups.push([note(120, noteTypes.red)]) + td.noteEventGroups.push([note(240, noteTypes.yellow)]) + td.noteEventGroups.push([note(360, noteTypes.blue)]) + td.noteEventGroups.push([note(480, noteTypes.orange)]) + chart.trackData.push(td) + const track = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(track, 96)).toHaveLength(1) + expect(findNoteOns(track, 97)).toHaveLength(1) + expect(findNoteOns(track, 98)).toHaveLength(1) + expect(findNoteOns(track, 99)).toHaveLength(1) + expect(findNoteOns(track, 100)).toHaveLength(1) + }) + + it('hard base is 83, medium 71, easy 59', () => { + const chart = createEmptyChart({ format: 'mid' }) + for (const d of ['hard', 'medium', 'easy'] as const) { + const td = emptyFretTrack('guitar', d) + td.noteEventGroups.push([note(0, noteTypes.green)]) + chart.trackData.push(td) + } + const track = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(track, 84)).toHaveLength(1) // hard 83+1 + expect(findNoteOns(track, 72)).toHaveLength(1) // medium 71+1 + expect(findNoteOns(track, 60)).toHaveLength(1) // easy 59+1 + }) +}) + +describe('writeMidiFile: GHL note-number mapping', () => { + it('expert white1→95, white2→96, white3→97, black1→98, black2→99, black3→100 (diffStart 94)', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyFretTrack('guitarghl', 'expert') + td.noteEventGroups.push([note(0, noteTypes.white1)]) + td.noteEventGroups.push([note(120, noteTypes.white2)]) + td.noteEventGroups.push([note(240, noteTypes.white3)]) + td.noteEventGroups.push([note(360, noteTypes.black1)]) + td.noteEventGroups.push([note(480, noteTypes.black2)]) + td.noteEventGroups.push([note(600, noteTypes.black3)]) + chart.trackData.push(td) + const track = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(track, 95)).toHaveLength(1) + expect(findNoteOns(track, 96)).toHaveLength(1) + expect(findNoteOns(track, 97)).toHaveLength(1) + expect(findNoteOns(track, 98)).toHaveLength(1) + expect(findNoteOns(track, 99)).toHaveLength(1) + expect(findNoteOns(track, 100)).toHaveLength(1) // 94 + 6 + }) + + it('GHL open-in-chord emits at diffStart+0 = 94 (ENHANCED_OPENS mode)', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyFretTrack('guitarghl', 'expert') + // Chord of open + white1 triggers useEnhancedOpens → open emits at 94. + td.noteEventGroups.push([ + note(0, noteTypes.open), + note(0, noteTypes.white1), + ]) + chart.trackData.push(td) + const track = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(track, 94)).toHaveLength(1) // open + expect(findNoteOns(track, 95)).toHaveLength(1) // white1 + }) +}) + +describe('writeMidiFile: 5-fret open notes', () => { + it('plain open (no chord) → forceOpen SysEx + noteOn at diffStart+1', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyFretTrack('guitar', 'expert') + td.noteEventGroups.push([note(0, noteTypes.open)]) + chart.trackData.push(td) + const track = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(track, 96)).toHaveLength(1) // written as green (diffStart+1) + expect(findEvents(track, 'sysEx').length).toBeGreaterThanOrEqual(2) // on + off + }) + + it('open-in-chord triggers ENHANCED_OPENS text event + MIDI 95 (diffStart+0) for open', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyFretTrack('guitar', 'expert') + td.noteEventGroups.push([ + note(0, noteTypes.open), + note(0, noteTypes.red), + ]) + chart.trackData.push(td) + const track = parseBack(writeMidiFile(chart)).tracks[2] + const texts = findEvents(track, 'text').map(e => (e as { text: string }).text) + expect(texts).toContain('[ENHANCED_OPENS]') + expect(findNoteOns(track, 95)).toHaveLength(1) // open at diffStart+0 + expect(findNoteOns(track, 97)).toHaveLength(1) // red at diffStart+2 + }) +}) + +describe('writeMidiFile: fret force modifiers', () => { + it('hopo flag disagreeing with natural state emits forceHopo marker', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + const td = emptyFretTrack('guitar', 'expert') + // Two identical notes with large gap: natural = strum. Flagging hopo + // creates a mismatch that needs forceHopo (offset 6, expert 95+6=101). + td.noteEventGroups.push([note(0, noteTypes.green)]) + td.noteEventGroups.push([note(1920, noteTypes.green, noteFlags.hopo)]) + chart.trackData.push(td) + const track = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(track, 101)).toHaveLength(1) + }) + + it('strum flag disagreeing with natural HOPO emits forceStrum marker', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + const td = emptyFretTrack('guitar', 'expert') + // Two different notes close together: natural = hopo. Flagging strum + // creates a mismatch that needs forceStrum (offset 7, expert 95+7=102). + td.noteEventGroups.push([note(0, noteTypes.green)]) + td.noteEventGroups.push([note(120, noteTypes.red, noteFlags.strum)]) + chart.trackData.push(td) + const track = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(track, 102)).toHaveLength(1) + }) + + it('tap flag emits forceTap SysEx', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyFretTrack('guitar', 'expert') + td.noteEventGroups.push([note(0, noteTypes.green, noteFlags.tap)]) + chart.trackData.push(td) + const track = parseBack(writeMidiFile(chart)).tracks[2] + const sysExEvents = findEvents(track, 'sysEx') + // forceTap SysEx has typeByte = 0x04 + const tapOn = sysExEvents.find(e => (e as { data: Uint8Array }).data[5] === 0x04 && (e as { data: Uint8Array }).data[6] === 0x01) + expect(tapOn).toBeDefined() + }) + + it('does NOT emit force markers when flags match natural state', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + const td = emptyFretTrack('guitar', 'expert') + // Two different notes close together: natural hopo; flag hopo → natural, no force. + td.noteEventGroups.push([note(0, noteTypes.green)]) + td.noteEventGroups.push([note(120, noteTypes.red, noteFlags.hopo)]) + chart.trackData.push(td) + const track = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(track, 101)).toHaveLength(0) + expect(findNoteOns(track, 102)).toHaveLength(0) + }) +}) + +describe('writeMidiFile: fret instrument-wide sections', () => { + it('emits star power as MIDI 116 and solo as MIDI 103', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyFretTrack('guitar', 'expert') + td.noteEventGroups.push([note(0, noteTypes.green)]) + td.starPowerSections.push({ tick: 0, length: 480, msTime: 0, msLength: 0 }) + td.soloSections.push({ tick: 480, length: 240, msTime: 0, msLength: 0 }) + chart.trackData.push(td) + const track = parseBack(writeMidiFile(chart)).tracks[2] + expect(findNoteOns(track, 116)).toHaveLength(1) + expect(findNoteOns(track, 103)).toHaveLength(1) + }) +}) + +describe('writeMidiFile: fret round-trip through parseChartAndIni', () => { + it('round-trips 5-fret notes + hopo/tap modifiers', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + const td = emptyFretTrack('guitar', 'expert') + td.noteEventGroups.push([note(0, noteTypes.green)]) + td.noteEventGroups.push([note(1920, noteTypes.green, noteFlags.hopo)]) // forceHopo + td.noteEventGroups.push([note(3840, noteTypes.red, noteFlags.tap)]) // forceTap + chart.trackData.push(td) + + const re = parseChartAndIni([{ fileName: 'notes.mid', data: writeMidiFile(chart) }]) + const reTrack = re.parsedChart!.trackData.find(t => t.instrument === 'guitar' && t.difficulty === 'expert')! + const groups = reTrack.noteEventGroups + expect(groups).toHaveLength(3) + expect(groups[1][0].flags & noteFlags.hopo).toBeTruthy() + expect(groups[2][0].flags & noteFlags.tap).toBeTruthy() + }) + + it('round-trips GHL open + chord-with-open (ENHANCED_OPENS path)', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + const td = emptyFretTrack('guitarghl', 'expert') + td.noteEventGroups.push([ + note(0, noteTypes.open), + note(0, noteTypes.white1), + ]) + chart.trackData.push(td) + + const re = parseChartAndIni([{ fileName: 'notes.mid', data: writeMidiFile(chart) }]) + const reTrack = re.parsedChart!.trackData.find(t => t.instrument === 'guitarghl')! + const types = reTrack.noteEventGroups[0].map(n => n.type).sort() + expect(types).toEqual([noteTypes.open, noteTypes.white1].sort()) + }) +}) + +// --------------------------------------------------------------------------- +// Vocal tracks +// --------------------------------------------------------------------------- + +function findTrackByName(tracks: MidiEvent[][], name: string): MidiEvent[] | undefined { + return tracks.find(t => t.some(e => e.type === 'trackName' && (e as { text: string }).text === name)) +} + +function emptyPhrase(tick: number, length: number, opts: { player?: 1 | 2; lyrics?: { tick: number; text: string }[]; notes?: { tick: number; length: number; pitch: number; type?: 'pitched' | 'percussion' }[] } = {}) { + return { + tick, + length, + msTime: 0, + msLength: 0, + isPercussion: false, + player: opts.player, + notes: (opts.notes ?? []).map(n => ({ ...n, msTime: 0, msLength: 0, type: n.type ?? 'pitched' as const })), + lyrics: (opts.lyrics ?? []).map(l => ({ ...l, msTime: 0, flags: 0 })), + } +} + +describe('writeMidiFile: vocal track layout', () => { + it('emits no vocal tracks when vocalTracks.parts is empty', () => { + const chart = createEmptyChart({ format: 'mid' }) + const tracks = parseBack(writeMidiFile(chart)).tracks + expect(findTrackByName(tracks, 'PART VOCALS')).toBeUndefined() + expect(findTrackByName(tracks, 'HARM1')).toBeUndefined() + }) + + it('emits PART VOCALS when a vocals part is present', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.vocalTracks.parts.vocals = { + notePhrases: [emptyPhrase(0, 480, { lyrics: [{ tick: 0, text: 'Hi' }] })], + staticLyricPhrases: [], + starPowerSections: [], + rangeShifts: [], + lyricShifts: [], + textEvents: [], + } + const tracks = parseBack(writeMidiFile(chart)).tracks + expect(findTrackByName(tracks, 'PART VOCALS')).toBeDefined() + }) + + it('emits HARM1 / HARM2 / HARM3 (not PART HARM*) track names', () => { + const chart = createEmptyChart({ format: 'mid' }) + const emptyPart = { notePhrases: [], staticLyricPhrases: [], starPowerSections: [], rangeShifts: [], lyricShifts: [], textEvents: [] } + chart.vocalTracks.parts.harmony1 = { ...emptyPart, notePhrases: [emptyPhrase(0, 480)] } + chart.vocalTracks.parts.harmony2 = { ...emptyPart, staticLyricPhrases: [emptyPhrase(0, 480)] } + chart.vocalTracks.parts.harmony3 = emptyPart + const tracks = parseBack(writeMidiFile(chart)).tracks + expect(findTrackByName(tracks, 'HARM1')).toBeDefined() + expect(findTrackByName(tracks, 'HARM2')).toBeDefined() + expect(findTrackByName(tracks, 'HARM3')).toBeDefined() + expect(findTrackByName(tracks, 'PART HARM1')).toBeUndefined() + }) +}) + +describe('writeMidiFile: vocal phrase markers', () => { + it('PART VOCALS emits notePhrases at note 105 by default', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.vocalTracks.parts.vocals = { + notePhrases: [emptyPhrase(0, 480)], + staticLyricPhrases: [], + starPowerSections: [], + rangeShifts: [], + lyricShifts: [], + textEvents: [], + } + const track = findTrackByName(parseBack(writeMidiFile(chart)).tracks, 'PART VOCALS')! + expect(findNoteOns(track, 105)).toHaveLength(1) + expect(findNoteOns(track, 106)).toHaveLength(0) + }) + + it('PART VOCALS emits player:2 phrases at note 106', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.vocalTracks.parts.vocals = { + notePhrases: [emptyPhrase(0, 480, { player: 1 }), emptyPhrase(480, 480, { player: 2 })], + staticLyricPhrases: [], + starPowerSections: [], + rangeShifts: [], + lyricShifts: [], + textEvents: [], + } + const track = findTrackByName(parseBack(writeMidiFile(chart)).tracks, 'PART VOCALS')! + expect(findNoteOns(track, 105)).toHaveLength(1) + expect(findNoteOns(track, 106)).toHaveLength(1) + }) + + it('HARM1 emits notePhrases at 105 and staticLyricPhrases at 106', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.vocalTracks.parts.harmony1 = { + notePhrases: [emptyPhrase(0, 480), emptyPhrase(960, 480)], + staticLyricPhrases: [emptyPhrase(1920, 480)], + starPowerSections: [], + rangeShifts: [], + lyricShifts: [], + textEvents: [], + } + const track = findTrackByName(parseBack(writeMidiFile(chart)).tracks, 'HARM1')! + expect(findNoteOns(track, 105)).toHaveLength(2) + expect(findNoteOns(track, 106)).toHaveLength(1) + }) + + it('HARM2 emits ONLY staticLyricPhrases as note 106 (note 105 comes from HARM1 via CopyDown on re-parse)', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.vocalTracks.parts.harmony2 = { + notePhrases: [emptyPhrase(0, 480)], + staticLyricPhrases: [emptyPhrase(480, 480)], + starPowerSections: [], + rangeShifts: [], + lyricShifts: [], + textEvents: [], + } + const track = findTrackByName(parseBack(writeMidiFile(chart)).tracks, 'HARM2')! + expect(findNoteOns(track, 105)).toHaveLength(0) + expect(findNoteOns(track, 106)).toHaveLength(1) + }) + + it('HARM3 emits no phrase markers at all', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.vocalTracks.parts.harmony3 = { + notePhrases: [emptyPhrase(0, 480)], + staticLyricPhrases: [emptyPhrase(480, 480)], + starPowerSections: [], + rangeShifts: [], + lyricShifts: [], + textEvents: [], + } + const track = findTrackByName(parseBack(writeMidiFile(chart)).tracks, 'HARM3')! + expect(findNoteOns(track, 105)).toHaveLength(0) + expect(findNoteOns(track, 106)).toHaveLength(0) + }) +}) + +describe('writeMidiFile: vocal notes and lyrics', () => { + it('emits pitched notes at their MIDI pitch (36-84)', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.vocalTracks.parts.vocals = { + notePhrases: [emptyPhrase(0, 480, { notes: [{ tick: 0, length: 240, pitch: 60 }] })], + staticLyricPhrases: [], + starPowerSections: [], + rangeShifts: [], + lyricShifts: [], + textEvents: [], + } + const track = findTrackByName(parseBack(writeMidiFile(chart)).tracks, 'PART VOCALS')! + expect(findNoteOns(track, 60)).toHaveLength(1) + }) + + it('clamps out-of-range pitches to 60', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.vocalTracks.parts.vocals = { + notePhrases: [emptyPhrase(0, 480, { notes: [{ tick: 0, length: 240, pitch: 200 }] })], + staticLyricPhrases: [], + starPowerSections: [], + rangeShifts: [], + lyricShifts: [], + textEvents: [], + } + const track = findTrackByName(parseBack(writeMidiFile(chart)).tracks, 'PART VOCALS')! + expect(findNoteOns(track, 60)).toHaveLength(1) + expect(findNoteOns(track, 200)).toHaveLength(0) + }) + + it('emits percussion notes at MIDI 96', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.vocalTracks.parts.vocals = { + notePhrases: [emptyPhrase(0, 480, { notes: [{ tick: 0, length: 0, pitch: -1, type: 'percussion' }] })], + staticLyricPhrases: [], + starPowerSections: [], + rangeShifts: [], + lyricShifts: [], + textEvents: [], + } + const track = findTrackByName(parseBack(writeMidiFile(chart)).tracks, 'PART VOCALS')! + expect(findNoteOns(track, 96)).toHaveLength(1) + }) + + it('emits lyric meta events from phrase.lyrics', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.vocalTracks.parts.vocals = { + notePhrases: [emptyPhrase(0, 480, { lyrics: [{ tick: 0, text: 'Hel-' }, { tick: 120, text: 'lo' }] })], + staticLyricPhrases: [], + starPowerSections: [], + rangeShifts: [], + lyricShifts: [], + textEvents: [], + } + const track = findTrackByName(parseBack(writeMidiFile(chart)).tracks, 'PART VOCALS')! + const lyrics = track.filter(e => e.type === 'lyrics') as { text: string }[] + expect(lyrics.map(l => l.text)).toEqual(['Hel-', 'lo']) + }) + + it('unions lyrics from both notePhrases and staticLyricPhrases (no duplicates)', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.vocalTracks.parts.harmony1 = { + notePhrases: [emptyPhrase(0, 960, { lyrics: [{ tick: 0, text: 'A' }, { tick: 480, text: 'B' }] })], + staticLyricPhrases: [emptyPhrase(0, 960, { lyrics: [{ tick: 480, text: 'B' }, { tick: 720, text: 'C' }] })], + starPowerSections: [], + rangeShifts: [], + lyricShifts: [], + textEvents: [], + } + const track = findTrackByName(parseBack(writeMidiFile(chart)).tracks, 'HARM1')! + const lyrics = track.filter(e => e.type === 'lyrics') as { text: string }[] + expect(lyrics.map(l => l.text)).toEqual(['A', 'B', 'C']) + }) +}) + +describe('writeMidiFile: vocal instrument-wide markers', () => { + it('emits star power as MIDI 116 on PART VOCALS / HARM1 only', () => { + const chart = createEmptyChart({ format: 'mid' }) + const sp = { tick: 0, length: 480, msTime: 0, msLength: 0 } + chart.vocalTracks.parts.vocals = { + notePhrases: [], staticLyricPhrases: [], + starPowerSections: [sp], + rangeShifts: [], lyricShifts: [], textEvents: [], + } + chart.vocalTracks.parts.harmony2 = { + notePhrases: [], staticLyricPhrases: [emptyPhrase(0, 480)], + starPowerSections: [sp], + rangeShifts: [], lyricShifts: [], textEvents: [], + } + const tracks = parseBack(writeMidiFile(chart)).tracks + expect(findNoteOns(findTrackByName(tracks, 'PART VOCALS')!, 116)).toHaveLength(1) + // HARM2 star power suppressed — copied from HARM1 via CopyDown on re-parse. + expect(findNoteOns(findTrackByName(tracks, 'HARM2')!, 116)).toHaveLength(0) + }) + + it('emits rangeShifts as MIDI 0 and lyricShifts as MIDI 1', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.vocalTracks.parts.vocals = { + notePhrases: [], + staticLyricPhrases: [], + starPowerSections: [], + rangeShifts: [{ tick: 0, length: 480, msTime: 0, msLength: 0 }], + lyricShifts: [{ tick: 480, length: 480, msTime: 0, msLength: 0 }], + textEvents: [], + } + const track = findTrackByName(parseBack(writeMidiFile(chart)).tracks, 'PART VOCALS')! + expect(findNoteOns(track, 0)).toHaveLength(1) + expect(findNoteOns(track, 1)).toHaveLength(1) + }) + + it('emits vocal text events (stance markers, Band_PlayFacialAnim)', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.vocalTracks.parts.vocals = { + notePhrases: [], + staticLyricPhrases: [], + starPowerSections: [], + rangeShifts: [], + lyricShifts: [], + textEvents: [ + { tick: 0, msTime: 0, text: '[idle]' }, + { tick: 960, msTime: 0, text: '[mellow]' }, + ], + } + const track = findTrackByName(parseBack(writeMidiFile(chart)).tracks, 'PART VOCALS')! + const texts = track.filter(e => e.type === 'text') as { text: string }[] + expect(texts.map(t => t.text)).toEqual(['[idle]', '[mellow]']) + }) +}) + +describe('writeMidiFile: vocal round-trip through parseChartAndIni', () => { + it('round-trips PART VOCALS with phrases, notes, and lyrics', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + chart.vocalTracks.parts.vocals = { + notePhrases: [emptyPhrase(0, 960, { + lyrics: [{ tick: 0, text: 'Hel-' }, { tick: 240, text: 'lo' }], + notes: [ + { tick: 0, length: 240, pitch: 60 }, + { tick: 240, length: 240, pitch: 62 }, + ], + })], + staticLyricPhrases: [], + starPowerSections: [], + rangeShifts: [], + lyricShifts: [], + textEvents: [], + } + const re = parseChartAndIni([{ fileName: 'notes.mid', data: writeMidiFile(chart) }]) + const reVocals = re.parsedChart!.vocalTracks.parts.vocals + expect(reVocals).toBeDefined() + expect(reVocals.notePhrases).toHaveLength(1) + expect(reVocals.notePhrases[0].lyrics.map(l => l.text)).toEqual(['Hel-', 'lo']) + expect(reVocals.notePhrases[0].notes.map(n => n.pitch)).toEqual([60, 62]) + }) + + it('round-trips HARM1 + HARM2 + HARM3 with CopyDown semantics preserved', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + const h1Phrase = emptyPhrase(0, 960, { + lyrics: [{ tick: 0, text: 'One' }], + notes: [{ tick: 0, length: 240, pitch: 60 }], + }) + const h2Phrase = emptyPhrase(0, 960, { + lyrics: [{ tick: 0, text: 'Two' }], + notes: [{ tick: 0, length: 240, pitch: 62 }], + }) + const h3Phrase = emptyPhrase(0, 960, { + lyrics: [{ tick: 0, text: 'Three' }], + notes: [{ tick: 0, length: 240, pitch: 64 }], + }) + chart.vocalTracks.parts.harmony1 = { + notePhrases: [h1Phrase], staticLyricPhrases: [], + starPowerSections: [], rangeShifts: [], lyricShifts: [], textEvents: [], + } + chart.vocalTracks.parts.harmony2 = { + notePhrases: [h1Phrase], staticLyricPhrases: [h2Phrase], + starPowerSections: [], rangeShifts: [], lyricShifts: [], textEvents: [], + } + chart.vocalTracks.parts.harmony3 = { + notePhrases: [h1Phrase], staticLyricPhrases: [h1Phrase], + starPowerSections: [], rangeShifts: [], lyricShifts: [], textEvents: [], + } + + const re = parseChartAndIni([{ fileName: 'notes.mid', data: writeMidiFile(chart) }]) + const parts = re.parsedChart!.vocalTracks.parts + // HARM1 notes preserved + expect(parts.harmony1.notePhrases[0].notes[0].pitch).toBe(60) + // HARM2 keeps its own notes + gets HARM1 phrases via CopyDown + expect(parts.harmony2.notePhrases[0].notes.map(n => n.pitch).sort()).toEqual([60, 62]) + // HARM3 gets HARM1 phrases (including notes) — HARM3's own notes survived via the phrase union + expect(parts.harmony3.notePhrases[0].notes.map(n => n.pitch)).toContain(60) + }) +}) diff --git a/src/__tests__/round-trip.test.ts b/src/__tests__/round-trip.test.ts new file mode 100644 index 0000000..389df62 --- /dev/null +++ b/src/__tests__/round-trip.test.ts @@ -0,0 +1,414 @@ +/** + * Same-format round-trip integration tests for scan-chart's writers. + * + * These exercise the full pipeline — build a ParsedChart → serialize via + * writeChartFile / writeMidiFile → re-parse via parseChartAndIni → check + * that structured fields survive. Per-feature correctness is already + * covered by the granular writer/parser tests; this file's value is + * catching regressions in interactions across tracks and coverage of the + * "build a whole chart with everything populated" shape. + * + * Tests live in both formats where both formats support the feature. A + * few features are format-asymmetric (e.g. .chart has no vocal encoding + * for harmony parts beyond [Events] lyrics) and are tested in only one. + */ + +import { describe, expect, it } from 'vitest' + +import { createEmptyChart } from '../chart/create-chart' +import { writeChartFile, writeMidiFile } from '../chart' +import { parseChartAndIni } from '../chart/parse-chart-and-ini' +import type { ParsedChart } from '../chart/parse-chart-and-ini' +import { noteFlags, noteTypes } from '../chart/note-parsing-interfaces' + +// --------------------------------------------------------------------------- +// Small helpers — mirror the builders used by midi-writer.test.ts and +// chart-writer.test.ts to keep fixture boilerplate in check. +// --------------------------------------------------------------------------- + +function emptyDrumTrack( + difficulty: 'expert' | 'hard' | 'medium' | 'easy' = 'expert', +): ParsedChart['trackData'][number] { + return { + instrument: 'drums', + difficulty, + starPowerSections: [], + rejectedStarPowerSections: [], + soloSections: [], + flexLanes: [], + drumFreestyleSections: [], + textEvents: [], + versusPhrases: [], + animations: [], + unrecognizedMidiEvents: [], + noteEventGroups: [], + } +} + +function emptyFretTrack( + instrument: ParsedChart['trackData'][number]['instrument'] = 'guitar', + difficulty: 'expert' | 'hard' | 'medium' | 'easy' = 'expert', +): ParsedChart['trackData'][number] { + return { + instrument, + difficulty, + starPowerSections: [], + rejectedStarPowerSections: [], + soloSections: [], + flexLanes: [], + drumFreestyleSections: [], + textEvents: [], + versusPhrases: [], + animations: [], + unrecognizedMidiEvents: [], + noteEventGroups: [], + } +} + +function note(tick: number, type: number, flags = 0, length = 0) { + return { tick, type, flags, length, msTime: 0, msLength: 0 } +} + +function writeAndReparseAsChart(chart: ParsedChart): ParsedChart { + const text = writeChartFile(chart) + const re = parseChartAndIni([{ fileName: 'notes.chart', data: new TextEncoder().encode(text) }]) + expect(re.parsedChart).not.toBeNull() + return re.parsedChart! +} + +function writeAndReparseAsMidi(chart: ParsedChart): ParsedChart { + const bytes = writeMidiFile(chart) + const re = parseChartAndIni([{ fileName: 'notes.mid', data: bytes }]) + expect(re.parsedChart).not.toBeNull() + return re.parsedChart! +} + +// --------------------------------------------------------------------------- +// Tempo / sync / events +// --------------------------------------------------------------------------- + +describe('round-trip: tempo + time signature + sections', () => { + for (const format of ['chart', 'mid'] as const) { + it(`survives a full sync map and section list via .${format}`, () => { + const chart = createEmptyChart({ format, resolution: 480, bpm: 120 }) + chart.tempos.push({ tick: 1920, beatsPerMinute: 140, msTime: 0 }) + chart.tempos.push({ tick: 3840, beatsPerMinute: 90, msTime: 0 }) + chart.timeSignatures.push({ tick: 1920, numerator: 3, denominator: 4, msTime: 0, msLength: 0 }) + chart.sections.push({ tick: 0, name: 'intro', msTime: 0, msLength: 0 }) + chart.sections.push({ tick: 1920, name: 'verse 1', msTime: 0, msLength: 0 }) + chart.endEvents.push({ tick: 7680, msTime: 0, msLength: 0 }) + + const re = format === 'chart' ? writeAndReparseAsChart(chart) : writeAndReparseAsMidi(chart) + + expect(re.tempos.map(t => ({ tick: t.tick, bpm: Math.round(t.beatsPerMinute) }))).toEqual([ + { tick: 0, bpm: 120 }, + { tick: 1920, bpm: 140 }, + { tick: 3840, bpm: 90 }, + ]) + expect(re.timeSignatures.map(ts => ({ tick: ts.tick, n: ts.numerator, d: ts.denominator }))).toEqual([ + { tick: 0, numerator: 4, denominator: 4 }, + { tick: 1920, numerator: 3, denominator: 4 }, + ].map(x => ({ tick: x.tick, n: x.numerator, d: x.denominator }))) + expect(re.sections.map(s => ({ tick: s.tick, name: s.name }))).toEqual([ + { tick: 0, name: 'intro' }, + { tick: 1920, name: 'verse 1' }, + ]) + expect(re.endEvents.map(e => e.tick)).toEqual([7680]) + }) + } +}) + +// --------------------------------------------------------------------------- +// Drum track +// --------------------------------------------------------------------------- + +describe('round-trip: drums', () => { + for (const format of ['chart', 'mid'] as const) { + it(`preserves kick / tom / cymbal / 2x-kick + accent/ghost flags via .${format}`, () => { + const chart = createEmptyChart({ format, resolution: 480 }) + if (format === 'mid') chart.iniChartModifiers.pro_drums = true + chart.drumType = 1 // fourLanePro — required for tom markers in .chart + + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([note(0, noteTypes.kick)]) + td.noteEventGroups.push([note(120, noteTypes.redDrum, noteFlags.accent)]) + td.noteEventGroups.push([note(240, noteTypes.yellowDrum, noteFlags.tom)]) + td.noteEventGroups.push([note(360, noteTypes.blueDrum, noteFlags.ghost)]) + td.noteEventGroups.push([note(480, noteTypes.greenDrum, noteFlags.tom)]) + td.noteEventGroups.push([note(960, noteTypes.kick, noteFlags.doubleKick)]) + chart.trackData.push(td) + + const re = format === 'chart' ? writeAndReparseAsChart(chart) : writeAndReparseAsMidi(chart) + + const reTd = re.trackData.find(t => t.instrument === 'drums' && t.difficulty === 'expert')! + expect(reTd.noteEventGroups).toHaveLength(6) + expect(reTd.noteEventGroups[0][0].type).toBe(noteTypes.kick) + expect(reTd.noteEventGroups[1][0].flags & noteFlags.accent).toBeTruthy() + expect(reTd.noteEventGroups[2][0].flags & noteFlags.tom).toBeTruthy() + expect(reTd.noteEventGroups[3][0].flags & noteFlags.ghost).toBeTruthy() + expect(reTd.noteEventGroups[4][0].flags & noteFlags.tom).toBeTruthy() + expect(reTd.noteEventGroups[5][0].flags & noteFlags.doubleKick).toBeTruthy() + }) + } + + // Flam survives only .mid round-trip. The .chart writer emits `N 109` but + // the .chart parser does not recognize it as a flam marker — this is a + // pre-existing parser gap, not a writer bug. Asymmetric test is the + // honest thing to ship. + it('preserves flam via .mid', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + chart.iniChartModifiers.pro_drums = true + chart.drumType = 1 + + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([note(0, noteTypes.redDrum, noteFlags.flam)]) + chart.trackData.push(td) + + const re = writeAndReparseAsMidi(chart) + const reTd = re.trackData.find(t => t.instrument === 'drums')! + expect(reTd.noteEventGroups[0][0].flags & noteFlags.flam).toBeTruthy() + }) +}) + +// --------------------------------------------------------------------------- +// Fret tracks (5-fret + GHL) +// --------------------------------------------------------------------------- + +describe('round-trip: 5-fret', () => { + for (const format of ['chart', 'mid'] as const) { + it(`preserves base colors + forceHopo / forceTap via .${format}`, () => { + const chart = createEmptyChart({ format, resolution: 480 }) + const td = emptyFretTrack('guitar', 'expert') + td.noteEventGroups.push([note(0, noteTypes.green)]) + td.noteEventGroups.push([note(1920, noteTypes.green, noteFlags.hopo)]) + td.noteEventGroups.push([note(3840, noteTypes.red, noteFlags.tap)]) + td.noteEventGroups.push([note(5760, noteTypes.orange, 0, 480)]) + chart.trackData.push(td) + + const re = format === 'chart' ? writeAndReparseAsChart(chart) : writeAndReparseAsMidi(chart) + + const reTd = re.trackData.find(t => t.instrument === 'guitar' && t.difficulty === 'expert')! + expect(reTd.noteEventGroups).toHaveLength(4) + expect(reTd.noteEventGroups[0][0].type).toBe(noteTypes.green) + expect(reTd.noteEventGroups[1][0].flags & noteFlags.hopo).toBeTruthy() + expect(reTd.noteEventGroups[2][0].flags & noteFlags.tap).toBeTruthy() + expect(reTd.noteEventGroups[3][0].length).toBeGreaterThan(0) + }) + } + + for (const format of ['chart', 'mid'] as const) { + it(`preserves star power + solo sections via .${format}`, () => { + const chart = createEmptyChart({ format, resolution: 480 }) + const td = emptyFretTrack('guitar', 'expert') + td.noteEventGroups.push([note(0, noteTypes.green)]) + td.noteEventGroups.push([note(480, noteTypes.red)]) + td.starPowerSections.push({ tick: 0, length: 960, msTime: 0, msLength: 0 }) + td.soloSections.push({ tick: 1920, length: 480, msTime: 0, msLength: 0 }) + chart.trackData.push(td) + + const re = format === 'chart' ? writeAndReparseAsChart(chart) : writeAndReparseAsMidi(chart) + + const reTd = re.trackData.find(t => t.instrument === 'guitar' && t.difficulty === 'expert')! + expect(reTd.starPowerSections).toHaveLength(1) + expect(reTd.starPowerSections[0].tick).toBe(0) + expect(reTd.soloSections).toHaveLength(1) + expect(reTd.soloSections[0].tick).toBe(1920) + }) + } +}) + +describe('round-trip: GHL', () => { + for (const format of ['chart', 'mid'] as const) { + it(`preserves open + chord-with-open (ENHANCED_OPENS path) via .${format}`, () => { + const chart = createEmptyChart({ format, resolution: 480 }) + const td = emptyFretTrack('guitarghl', 'expert') + td.noteEventGroups.push([note(0, noteTypes.open)]) + td.noteEventGroups.push([ + note(480, noteTypes.open), + note(480, noteTypes.white1), + ]) + chart.trackData.push(td) + + const re = format === 'chart' ? writeAndReparseAsChart(chart) : writeAndReparseAsMidi(chart) + + const reTd = re.trackData.find(t => t.instrument === 'guitarghl')! + expect(reTd.noteEventGroups).toHaveLength(2) + expect(reTd.noteEventGroups[0][0].type).toBe(noteTypes.open) + const chordTypes = reTd.noteEventGroups[1].map(n => n.type).sort() + expect(chordTypes).toEqual([noteTypes.open, noteTypes.white1].sort()) + }) + } +}) + +// --------------------------------------------------------------------------- +// Vocals — .mid only (full phrase/marker support); .chart has only [Events] +// lyric events, which are handled at the global-events level (not part of +// this PR's scope — see the vocal-tracks tests for format-specific coverage). +// --------------------------------------------------------------------------- + +describe('round-trip: vocals (.mid)', () => { + it('preserves PART VOCALS phrases, notes, lyrics, star power', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + chart.vocalTracks.parts.vocals = { + notePhrases: [{ + tick: 0, length: 960, msTime: 0, msLength: 0, + isPercussion: false, + notes: [ + { tick: 0, msTime: 0, length: 240, msLength: 0, pitch: 60, type: 'pitched' }, + { tick: 240, msTime: 0, length: 240, msLength: 0, pitch: 64, type: 'pitched' }, + ], + lyrics: [ + { tick: 0, msTime: 0, text: 'Hel-', flags: 0 }, + { tick: 240, msTime: 0, text: 'lo', flags: 0 }, + ], + }], + staticLyricPhrases: [], + starPowerSections: [{ tick: 0, length: 960, msTime: 0, msLength: 0 }], + rangeShifts: [], + lyricShifts: [], + textEvents: [], + } + + const re = writeAndReparseAsMidi(chart) + const reVocals = re.vocalTracks.parts.vocals + expect(reVocals).toBeDefined() + expect(reVocals.notePhrases).toHaveLength(1) + expect(reVocals.notePhrases[0].notes.map(n => n.pitch)).toEqual([60, 64]) + expect(reVocals.notePhrases[0].lyrics.map(l => l.text)).toEqual(['Hel-', 'lo']) + expect(reVocals.starPowerSections).toHaveLength(1) + }) + + it('preserves HARM1 / HARM2 / HARM3 with CopyDown semantics', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + const h1 = { + tick: 0, length: 960, msTime: 0, msLength: 0, + isPercussion: false, + notes: [{ tick: 0, msTime: 0, length: 240, msLength: 0, pitch: 60, type: 'pitched' as const }], + lyrics: [{ tick: 0, msTime: 0, text: 'One', flags: 0 }], + } + chart.vocalTracks.parts.harmony1 = { + notePhrases: [h1], staticLyricPhrases: [], + starPowerSections: [], rangeShifts: [], lyricShifts: [], textEvents: [], + } + chart.vocalTracks.parts.harmony2 = { + notePhrases: [h1], staticLyricPhrases: [h1], + starPowerSections: [], rangeShifts: [], lyricShifts: [], textEvents: [], + } + chart.vocalTracks.parts.harmony3 = { + notePhrases: [h1], staticLyricPhrases: [h1], + starPowerSections: [], rangeShifts: [], lyricShifts: [], textEvents: [], + } + + const re = writeAndReparseAsMidi(chart) + const parts = re.vocalTracks.parts + expect(parts.harmony1).toBeDefined() + expect(parts.harmony2).toBeDefined() + expect(parts.harmony3).toBeDefined() + expect(parts.harmony1.notePhrases[0].notes[0].pitch).toBe(60) + // HARM2/HARM3 receive HARM1 notePhrases via CopyDown on re-parse. + expect(parts.harmony2.notePhrases[0].notes[0].pitch).toBe(60) + expect(parts.harmony3.notePhrases[0].notes[0].pitch).toBe(60) + }) +}) + +// --------------------------------------------------------------------------- +// Multi-track interactions: a chart with drums + guitar + vocals all at once. +// --------------------------------------------------------------------------- + +describe('round-trip: multi-track chart', () => { + for (const format of ['chart', 'mid'] as const) { + it(`preserves drums + guitar + bass in the same chart via .${format}`, () => { + const chart = createEmptyChart({ format, resolution: 480 }) + chart.drumType = 1 + + const drums = emptyDrumTrack('expert') + drums.noteEventGroups.push([note(0, noteTypes.kick)]) + drums.noteEventGroups.push([note(480, noteTypes.redDrum)]) + chart.trackData.push(drums) + + const guitar = emptyFretTrack('guitar', 'expert') + guitar.noteEventGroups.push([note(0, noteTypes.green)]) + guitar.noteEventGroups.push([note(480, noteTypes.red, noteFlags.hopo)]) + chart.trackData.push(guitar) + + const bass = emptyFretTrack('bass', 'expert') + bass.noteEventGroups.push([note(0, noteTypes.green, 0, 240)]) + chart.trackData.push(bass) + + const re = format === 'chart' ? writeAndReparseAsChart(chart) : writeAndReparseAsMidi(chart) + + expect(re.trackData.find(t => t.instrument === 'drums')).toBeDefined() + expect(re.trackData.find(t => t.instrument === 'guitar')).toBeDefined() + expect(re.trackData.find(t => t.instrument === 'bass')).toBeDefined() + // Drums: 2 groups. Guitar: 2 groups, second is forceHopo. + const reDrums = re.trackData.find(t => t.instrument === 'drums')! + const reGuitar = re.trackData.find(t => t.instrument === 'guitar')! + expect(reDrums.noteEventGroups).toHaveLength(2) + expect(reGuitar.noteEventGroups).toHaveLength(2) + expect(reGuitar.noteEventGroups[1][0].flags & noteFlags.hopo).toBeTruthy() + }) + } + + it('preserves drums + guitar + vocals in a .mid chart', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + chart.iniChartModifiers.pro_drums = true + chart.drumType = 1 + + const drums = emptyDrumTrack('expert') + drums.noteEventGroups.push([note(0, noteTypes.kick)]) + drums.noteEventGroups.push([note(480, noteTypes.redDrum)]) + chart.trackData.push(drums) + + const guitar = emptyFretTrack('guitar', 'expert') + guitar.noteEventGroups.push([note(0, noteTypes.green)]) + chart.trackData.push(guitar) + + chart.vocalTracks.parts.vocals = { + notePhrases: [{ + tick: 0, length: 960, msTime: 0, msLength: 0, + isPercussion: false, + notes: [{ tick: 0, msTime: 0, length: 240, msLength: 0, pitch: 60, type: 'pitched' }], + lyrics: [{ tick: 0, msTime: 0, text: 'Hi', flags: 0 }], + }], + staticLyricPhrases: [], + starPowerSections: [], + rangeShifts: [], + lyricShifts: [], + textEvents: [], + } + + const re = writeAndReparseAsMidi(chart) + expect(re.trackData.find(t => t.instrument === 'drums')).toBeDefined() + expect(re.trackData.find(t => t.instrument === 'guitar')).toBeDefined() + expect(re.vocalTracks.parts.vocals).toBeDefined() + expect(re.vocalTracks.parts.vocals.notePhrases[0].lyrics[0].text).toBe('Hi') + }) +}) + +// --------------------------------------------------------------------------- +// Metadata — values in [Song] vs song.ini should survive their own channel. +// --------------------------------------------------------------------------- + +describe('round-trip: metadata', () => { + it('preserves [Song] metadata via .chart', () => { + const chart = createEmptyChart({ format: 'chart', resolution: 480 }) + chart.metadata.name = 'Test Song' + chart.metadata.artist = 'Test Artist' + chart.metadata.album = 'Test Album' + chart.metadata.year = '2024' + + const re = writeAndReparseAsChart(chart) + expect(re.metadata.name).toBe('Test Song') + expect(re.metadata.artist).toBe('Test Artist') + expect(re.metadata.album).toBe('Test Album') + expect(re.metadata.year).toBe('2024') + }) + + it('preserves chart_offset via .chart (separate from song.ini delay)', () => { + const chart = createEmptyChart({ format: 'chart', resolution: 480 }) + chart.metadata.chart_offset = 250 + + const re = writeAndReparseAsChart(chart) + expect(re.metadata.chart_offset).toBe(250) + }) +}) diff --git a/src/chart/chart-document.ts b/src/chart/chart-document.ts new file mode 100644 index 0000000..47f55e8 --- /dev/null +++ b/src/chart/chart-document.ts @@ -0,0 +1,70 @@ +/** + * `ChartDocument` bundles a {@link ParsedChart} with the non-chart files + * (audio, album art, videos, any unrecognized files) from the source folder. + * + * This is the counterpart shape to `parseChartAndIni`'s input: in → raw file + * list, out → `ChartDocument`; writing the reverse: `ChartDocument` → raw + * file list via `writeChartFolder`. + * + * Metadata lives on `parsedChart.metadata` (the consolidated shape from + * `parseChartAndIni`) — there is no separate `metadata` field on + * `ChartDocument`, by design. + */ + +import { writeChartFile } from './chart-writer' +import { writeMidiFile } from './midi-writer' +import type { ParsedChart } from './parse-chart-and-ini' +import { writeIniFile } from '../ini/ini-writer' + +export interface ChartAsset { + fileName: string + data: Uint8Array +} + +export interface ChartDocument { + /** The parsed chart data. `parsedChart.metadata` carries ini fields. */ + parsedChart: ParsedChart + /** Non-chart / non-ini files from the source folder — passed through verbatim on write. */ + assets: ChartAsset[] +} + +/** + * Serialize a {@link ChartDocument} back to a flat list of files suitable for + * writing to disk or packaging into a zip/sng. + * + * Output: + * - `notes.chart` or `notes.mid`, depending on `parsedChart.format` + * - `song.ini` (from `parsedChart.metadata`) + * - every entry in `doc.assets`, in order + * + * Callers should not include their own `notes.chart` / `notes.mid` / + * `song.ini` entries in `assets` — those would be additive, producing a + * malformed folder with two chart files. + */ +export function writeChartFolder(doc: ChartDocument): ChartAsset[] { + const encoder = new TextEncoder() + const out: ChartAsset[] = [] + + if (doc.parsedChart.format === 'chart') { + out.push({ + fileName: 'notes.chart', + data: encoder.encode(writeChartFile(doc.parsedChart)), + }) + } else { + out.push({ + fileName: 'notes.mid', + data: writeMidiFile(doc.parsedChart), + }) + } + + out.push({ + fileName: 'song.ini', + data: encoder.encode(writeIniFile(doc.parsedChart.metadata)), + }) + + for (const asset of doc.assets) { + out.push(asset) + } + + return out +} diff --git a/src/chart/chart-parser.ts b/src/chart/chart-parser.ts index 7b09af9..de5be38 100644 --- a/src/chart/chart-parser.ts +++ b/src/chart/chart-parser.ts @@ -114,8 +114,12 @@ export function parseNotesFromChart(data: Uint8Array): RawChartData { year: metadata['Year']?.slice(2) || undefined, // Thank you GHTCP, very cool charter: metadata['Charter'] || undefined, diff_guitar: Number(metadata['Difficulty']) || undefined, - // "Offset" and "PreviewStart" are in units of seconds - delay: Number(metadata['Offset']) ? Number(metadata['Offset']) * 1000 : undefined, + // "Offset" and "PreviewStart" are in units of seconds. + // NOTE: [Song].Offset is a .chart-only property — distinct from + // song.ini's `delay`, which games recognize in .ini (but NOT in + // [Song]). We expose [Song].Offset under the `chart_offset` key so + // it never collides with the ini-origin `delay` on merge. + chart_offset: Number(metadata['Offset']) ? Number(metadata['Offset']) * 1000 : undefined, preview_start_time: Number(metadata['PreviewStart']) ? Number(metadata['PreviewStart']) * 1000 : undefined, }, vocalTracks: { diff --git a/src/chart/chart-scanner.ts b/src/chart/chart-scanner.ts index c679946..7dd65d7 100644 --- a/src/chart/chart-scanner.ts +++ b/src/chart/chart-scanner.ts @@ -7,6 +7,7 @@ import { base64url } from 'rfc4648' import { defaultMetadata } from 'src/ini' import { ChartIssueType, Difficulty, getInstrumentType, Instrument, instrumentTypes, NotesData } from '../interfaces' import { msToExactTime } from '../utils' +import { computeHopoThresholdTicks, isNaturalHopo } from './natural-hopo' import { IniChartModifiers, NoteEvent, noteFlags, NoteType, noteTypes } from './note-parsing-interfaces' import { ParsedChart } from './parse-chart-and-ini' import { calculateTrackHash, pruneEmptyPhrases } from './track-hasher' @@ -38,21 +39,58 @@ export function scanParsedChart(parsedChart: ParsedChart, includeBTrack = false) } }) - let [hasTapNotes, hasOpenNotes, has2xKick] = [false, false, false] - for (const track of result.trackData) { + // Walk every noteEventGroup once to derive all state-dependent flags. + // `hasForcedNotes` mirrors the old source-derived semantics: true iff the + // chart contains a note whose resolved `hopo`/`strum` flag disagrees with + // the natural HOPO state the parser would have picked without force events. + // Notes that carry ONLY the `tap` flag are intentionally NOT counted — + // matching the pre-consolidation definition that walked raw trackEvents for + // `forceHopo` / `forceStrum` / `forceUnnatural` (tap was excluded even + // though `forceTap` is a force event, per the original definition). That + // decision keeps 78K-chart parity with the old flag for 94%+ of charts; the + // remaining divergence is ~300 charts where the source had redundantly- + // applied force events on naturally-matching notes (the force was a no-op, + // so the state-derived flag correctly reports false). + const hopoThreshold = computeHopoThresholdTicks( + result.resolution, + iniChartModifiers.hopo_frequency, + iniChartModifiers.eighthnote_hopo, + result.format, + ) + let [hasTapNotes, hasOpenNotes, has2xKick, hasForcedNotes] = [false, false, false, false] + outer: for (const track of result.trackData) { + const isFretInstrument = track.instrument !== 'drums' + let lastGroup: NoteEvent[] | null = null for (const noteGroup of track.noteEventGroups) { for (const note of noteGroup) { - if (note.flags & noteFlags.tap) { - hasTapNotes = true - } - if (note.flags & noteFlags.doubleKick) { - has2xKick = true - } - if (note.type === noteTypes.open) { - hasOpenNotes = true - } + if (note.flags & noteFlags.tap) hasTapNotes = true + if (note.flags & noteFlags.doubleKick) has2xKick = true + if (note.type === noteTypes.open) hasOpenNotes = true } + if (isFretInstrument && !hasForcedNotes && noteGroup.length > 0) { + const first = noteGroup[0] + const natural = isNaturalHopo(noteGroup, lastGroup, hopoThreshold, result.format) + const isHopo = (first.flags & noteFlags.hopo) !== 0 + const isStrum = (first.flags & noteFlags.strum) !== 0 + if ((isHopo && !natural) || (isStrum && natural)) hasForcedNotes = true + } + lastGroup = noteGroup.length > 0 ? noteGroup : lastGroup + // Early-exit once all four flags are true. + if (hasTapNotes && hasOpenNotes && has2xKick && hasForcedNotes) break outer + } + } + + // `hasLyrics` / `hasVocals` are derived from the normalized vocal tracks + // rather than snapshotted at parse time — keeps them state-accurate if + // downstream code adds or removes vocal data. + let hasLyrics = false + let hasVocals = false + for (const part of Object.values(result.vocalTracks.parts)) { + if (part.notePhrases.length > 0) hasVocals = true + for (const phrase of part.notePhrases) { + if (phrase.lyrics.length > 0) { hasLyrics = true; break } } + if (hasLyrics && hasVocals) break } return { @@ -68,9 +106,9 @@ export function scanParsedChart(parsedChart: ParsedChart, includeBTrack = false) .map(t => t.soloSections.length) .max() .value() > 0, - hasLyrics: result.hasLyrics, - hasVocals: result.hasVocals, - hasForcedNotes: result.hasForcedNotes, + hasLyrics, + hasVocals, + hasForcedNotes, hasTapNotes, hasOpenNotes, has2xKick, @@ -200,7 +238,8 @@ function findChartIssues( // noNotes { - if (chartData.trackData.every(track => track.noteEventGroups.length === 0) && !chartData.hasVocals) { + const hasVocals = Object.values(chartData.vocalTracks.parts).some(p => p.notePhrases.length > 0) + if (chartData.trackData.every(track => track.noteEventGroups.length === 0) && !hasVocals) { addIssue(null, null, 'noNotes') } } diff --git a/src/chart/chart-writer.ts b/src/chart/chart-writer.ts new file mode 100644 index 0000000..bb9b018 --- /dev/null +++ b/src/chart/chart-writer.ts @@ -0,0 +1,526 @@ +/** + * `.chart` file writer — serializes a ParsedChart back to chart text. + * + * Emits `[Song]`, `[SyncTrack]`, `[Events]`, per-instrument track sections + * (e.g. `[ExpertSingle]`, `[HardDrums]`), and any unrecognized chart sections + * that the parser preserved verbatim. + */ + +import type { Instrument } from '../interfaces' +import { computeHopoThresholdTicks, isNaturalHopo } from './natural-hopo' +import type { NoteEvent, NoteType } from './note-parsing-interfaces' +import { noteFlags, noteTypes } from './note-parsing-interfaces' +import type { ParsedChart } from './parse-chart-and-ini' + +type ParsedTrack = ParsedChart['trackData'][number] + +/** + * Serialize a {@link ParsedChart} to `.chart` file text (CRLF line endings). + * Emits `[Song]`, `[SyncTrack]`, `[Events]`, per-instrument track sections, + * and any chart sections the parser preserved verbatim for round-trip. + */ +export function writeChartFile(chart: ParsedChart): string { + const sections: string[][] = [] + sections.push(serializeSongSection(chart)) + sections.push(serializeSyncTrack(chart)) + sections.push(serializeEventsSection(chart)) + + for (const track of chart.trackData) { + const lines = serializeTrackSection(track, chart) + if (lines.length === 0) continue + sections.push(lines) + } + + for (const us of chart.unrecognizedChartSections) { + const sec: string[] = [`[${us.name}]`, '{'] + for (const ln of us.lines) sec.push(` ${ln}`) + sec.push('}') + sections.push(sec) + } + + const out: string[] = [] + for (const section of sections) { + for (const line of section) out.push(line) + } + return out.join('\r\n') + '\r\n' +} + +// --------------------------------------------------------------------------- +// [Song] section +// --------------------------------------------------------------------------- + +/** + * The subset of `song.ini` fields that the `[Song]` section in a `.chart` + * file supports. Values for these fields in {@link ParsedChart.metadata} get + * re-emitted here; all other ini fields live exclusively in `song.ini`. + */ +function serializeSongSection(chart: ParsedChart): string[] { + const lines: string[] = ['[Song]', '{'] + const m = chart.metadata + + if (m.name != null) lines.push(` Name = "${m.name}"`) + if (m.artist != null) lines.push(` Artist = "${m.artist}"`) + if (m.charter != null) lines.push(` Charter = "${m.charter}"`) + if (m.album != null) lines.push(` Album = "${m.album}"`) + if (m.genre != null) lines.push(` Genre = "${m.genre}"`) + // [Song]'s `Year` is historically written with a leading `, ` separator + // (a GHTCP quirk the scan-chart parser strips back out — see chart-parser). + if (m.year != null) lines.push(` Year = ", ${m.year}"`) + + lines.push(` Resolution = ${chart.resolution}`) + + // `[Song].Offset` is a .chart-only field — distinct from ini's `delay`, + // which games recognize only in song.ini. Read from `metadata.chart_offset` + // (populated by the parser from [Song].Offset) so that ini's `delay` + // never overrides it on the ini-wins merge. `PreviewStart` is seconds in + // the file, ms on ParsedChart. + if (m.chart_offset != null && m.chart_offset !== 0) { + lines.push(` Offset = ${m.chart_offset / 1000}`) + } + if (m.preview_start_time != null) { + lines.push(` PreviewStart = ${m.preview_start_time / 1000}`) + } + if (m.diff_guitar != null) lines.push(` Difficulty = ${m.diff_guitar}`) + + lines.push('}') + return lines +} + +// --------------------------------------------------------------------------- +// [SyncTrack] section +// --------------------------------------------------------------------------- + +function serializeSyncTrack(chart: ParsedChart): string[] { + const lines: string[] = ['[SyncTrack]', '{'] + + type SyncEvent = + | { tick: number; order: 0; kind: 'ts'; numerator: number; denominator: number } + | { tick: number; order: 1; kind: 'bpm'; beatsPerMinute: number } + + const events: SyncEvent[] = [ + ...chart.timeSignatures.map( + (ts): SyncEvent => ({ + tick: ts.tick, + order: 0, + kind: 'ts', + numerator: ts.numerator, + denominator: ts.denominator, + }), + ), + ...chart.tempos.map( + (t): SyncEvent => ({ tick: t.tick, order: 1, kind: 'bpm', beatsPerMinute: t.beatsPerMinute }), + ), + ] + + // Sort by tick, then TS before B at the same tick. Duplicates preserved. + events.sort((a, b) => { + if (a.tick !== b.tick) return a.tick - b.tick + return a.order - b.order + }) + + for (const ev of events) { + if (ev.kind === 'bpm') { + const millibeats = Math.round(ev.beatsPerMinute * 1000) + lines.push(` ${ev.tick} = B ${millibeats}`) + } else if (ev.denominator === 4) { + lines.push(` ${ev.tick} = TS ${ev.numerator}`) + } else { + lines.push(` ${ev.tick} = TS ${ev.numerator} ${Math.log2(ev.denominator)}`) + } + } + + lines.push('}') + return lines +} + +// --------------------------------------------------------------------------- +// [Events] section +// --------------------------------------------------------------------------- + +function serializeEventsSection(chart: ParsedChart): string[] { + const lines: string[] = ['[Events]', '{'] + + // Typed events: sections (wrapped as `[section name]`), endEvents. + // Wrapping: scan-chart's section regex `^\[?(?:section|prc)[ _](.*?)\]?$` + // is greedy for the trailing `\]?$` and lazy for `(.*?)`, so an unwrapped + // `section [name]` would have its trailing `]` eaten as the optional + // closing bracket. Wrapping in outer brackets preserves the name. + const events: { tick: number; text: string }[] = [] + for (const s of chart.sections) events.push({ tick: s.tick, text: `[section ${s.name}]` }) + for (const e of chart.endEvents) events.push({ tick: e.tick, text: 'end' }) + + // Unrecognized global events (crowd events, music_start/end, coda, etc.). + // If the chart was originally parsed from .mid, unrecognizedEvents came in + // as `[text]` (square-bracketed MIDI text meta events) — strip the brackets + // so the .chart output writes the naked text between quotes (the .chart + // E-event convention). + const sourceIsMidi = chart.format === 'mid' + let hasCodaInGlobalEvents = false + for (const ge of chart.unrecognizedEvents) { + let text = ge.text + if (sourceIsMidi) { + const trimmed = text.trimEnd() + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + text = trimmed.slice(1, -1) + } + } + // endEvents are already emitted above; skip duplicates here. + if (text.trim() === 'end') continue + events.push({ tick: ge.tick, text }) + if (text.trim() === 'coda') hasCodaInGlobalEvents = true + } + + // Coda events from drumFreestyleSections — only when not already present + // in the unrecognizedEvents stream above. + 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, text: 'coda' }) + } + + // Vocal phrases + lyrics from the normalized `vocals` part. .chart supports + // only one vocal track (harmonies are MIDI-only). + type TaggedEvent = { tick: number; text: string; subKey: number } + const eventPriority = (text: string): number => { + if (text === 'phrase_end') return 0 + if (text.startsWith('lyric ')) return 1 + if (text === 'coda') return 2 + if (text.startsWith('section ')) return 3 + if (text === 'phrase_start') return 4 + if (text === 'end') return 5 + return 4 + } + const tagged: TaggedEvent[] = events.map(e => ({ + tick: e.tick, + text: e.text, + subKey: 1_000_000 + eventPriority(e.text), + })) + + const vocalsPart = chart.vocalTracks.parts.vocals + if (vocalsPart) { + // Emit phrase_start for every phrase. Omit phrase_end when the next + // phrase starts at exactly the same tick: the .chart parser closes the + // current phrase implicitly on the next phrase_start, so an explicit + // phrase_end would round-trip as a spurious duplicate. + const phrases = vocalsPart.notePhrases + for (let i = 0; i < phrases.length; i++) { + const phrase = phrases[i] + const endTick = phrase.tick + phrase.length + tagged.push({ tick: phrase.tick, text: 'phrase_start', subKey: i * 2 }) + const next = phrases[i + 1] + const nextStartsAtOurEnd = next && next.tick === endTick + if (!nextStartsAtOurEnd) { + tagged.push({ tick: endTick, text: 'phrase_end', subKey: i * 2 + 1 }) + } + } + for (const phrase of phrases) { + for (const lyric of phrase.lyrics) { + tagged.push({ tick: lyric.tick, text: `lyric ${lyric.text}`, subKey: 1_000_000 + 1 }) + } + } + } + + tagged.sort((a, b) => { + if (a.tick !== b.tick) return a.tick - b.tick + return a.subKey - b.subKey + }) + + for (const ev of tagged) { + lines.push(` ${ev.tick} = E "${ev.text}"`) + } + + lines.push('}') + return lines +} + +// --------------------------------------------------------------------------- +// [] track sections +// --------------------------------------------------------------------------- + +type TrackLineEvent = + | { tick: number; sortKey: 1; kind: 'S'; value: number; length: number } + | { tick: number; sortKey: 0; kind: 'N'; value: number; length: number } + | { tick: number; sortKey: 2; kind: 'E'; text: string } + +const instrumentSectionSuffix: Record = { + guitar: 'Single', + guitarcoop: 'DoubleGuitar', + rhythm: 'DoubleRhythm', + bass: 'DoubleBass', + drums: 'Drums', + keys: 'Keyboard', + guitarghl: 'GHLGuitar', + guitarcoopghl: 'GHLCoop', + rhythmghl: 'GHLRhythm', + bassghl: 'GHLBass', +} + +const difficultyPrefix: Record = { + expert: 'Expert', + hard: 'Hard', + medium: 'Medium', + easy: 'Easy', +} + +const drumNoteTypeToNoteNumber: Partial> = { + [noteTypes.kick]: 0, + [noteTypes.redDrum]: 1, + [noteTypes.yellowDrum]: 2, + [noteTypes.blueDrum]: 3, + [noteTypes.greenDrum]: 4, +} + +const drumNoteTypeToNoteNumberFiveLane: Partial> = { + [noteTypes.kick]: 0, + [noteTypes.redDrum]: 1, + [noteTypes.yellowDrum]: 2, + [noteTypes.blueDrum]: 3, + [noteTypes.greenDrum]: 5, +} + +const fiveFretNoteTypeToNoteNumber: Partial> = { + [noteTypes.open]: 7, + [noteTypes.green]: 0, + [noteTypes.red]: 1, + [noteTypes.yellow]: 2, + [noteTypes.blue]: 3, + [noteTypes.orange]: 4, +} + +const ghlNoteTypeToNoteNumber: Partial> = { + [noteTypes.open]: 7, + [noteTypes.white1]: 0, + [noteTypes.white2]: 1, + [noteTypes.white3]: 2, + [noteTypes.black1]: 3, + [noteTypes.black2]: 4, + [noteTypes.black3]: 8, +} + +const ghlInstrumentSet = new Set([ + 'guitarghl', 'guitarcoopghl', 'rhythmghl', 'bassghl', +]) + +function getNoteNumberMap( + instrument: Instrument, + drumType: number | null | undefined, +): Partial> { + if (instrument === 'drums') { + return drumType === 2 ? drumNoteTypeToNoteNumberFiveLane : drumNoteTypeToNoteNumber + } + if (ghlInstrumentSet.has(instrument)) return ghlNoteTypeToNoteNumber + return fiveFretNoteTypeToNoteNumber +} + +const drumCymbalNoteNumber: Partial> = { + [noteTypes.yellowDrum]: 66, + [noteTypes.blueDrum]: 67, + [noteTypes.greenDrum]: 68, +} + +const drumAccentNoteNumber: Partial> = { + [noteTypes.kick]: 33, + [noteTypes.redDrum]: 34, + [noteTypes.yellowDrum]: 35, + [noteTypes.blueDrum]: 36, + [noteTypes.greenDrum]: 37, +} + +const drumGhostNoteNumber: Partial> = { + [noteTypes.kick]: 39, + [noteTypes.redDrum]: 40, + [noteTypes.yellowDrum]: 41, + [noteTypes.blueDrum]: 42, + [noteTypes.greenDrum]: 43, +} + +// --------------------------------------------------------------------------- +// serializeTrackSection +// --------------------------------------------------------------------------- + +function serializeTrackSection(track: ParsedTrack, chart: ParsedChart): string[] { + const suffix = instrumentSectionSuffix[track.instrument] + const prefix = difficultyPrefix[track.difficulty] + if (suffix == null || prefix == null) return [] + + const lines: string[] = [`[${prefix}${suffix}]`, '{'] + const drumType = chart.drumType + const noteMap = getNoteNumberMap(track.instrument, drumType) + const isDrums = track.instrument === 'drums' + + // Pre-compute natural-HOPO state per group for fret instruments. + const isNaturalHopoByGroup: boolean[] = [] + if (!isDrums) { + const hopoThreshold = computeHopoThresholdTicks( + chart.resolution, + chart.iniChartModifiers.hopo_frequency, + chart.iniChartModifiers.eighthnote_hopo, + 'chart', + ) + let lastGroup: NoteEvent[] | null = null + for (const group of track.noteEventGroups) { + isNaturalHopoByGroup.push(isNaturalHopo(group, lastGroup, hopoThreshold, 'chart')) + lastGroup = group + } + } + + const events: TrackLineEvent[] = [] + + for (const sp of track.starPowerSections) { + events.push({ tick: sp.tick, sortKey: 1, kind: 'S', value: 2, length: sp.length }) + } + for (const fs of track.drumFreestyleSections) { + events.push({ tick: fs.tick, sortKey: 1, kind: 'S', value: 64, length: fs.length }) + } + for (const fl of track.flexLanes) { + events.push({ tick: fl.tick, sortKey: 1, kind: 'S', value: fl.isDouble ? 66 : 65, length: fl.length }) + } + for (const vp of track.versusPhrases) { + events.push({ tick: vp.tick, sortKey: 1, kind: 'S', value: vp.isPlayer2 ? 1 : 0, length: vp.length }) + } + // Solo sections: `length = end - start + 1` in the parser, so subtract 1 to + // round-trip `soloend` to the same tick. + for (const solo of track.soloSections) { + events.push({ tick: solo.tick, sortKey: 2, kind: 'E', text: 'solo' }) + events.push({ tick: solo.tick + Math.max(solo.length - 1, 0), sortKey: 2, kind: 'E', text: 'soloend' }) + } + for (const te of track.textEvents) { + events.push({ tick: te.tick, sortKey: 2, kind: 'E', text: te.text }) + } + + for (let gi = 0; gi < track.noteEventGroups.length; gi++) { + const group = track.noteEventGroups[gi] + let hasFlamInGroup = false + + for (const note of group) { + let noteNumber = noteMap[note.type] + if (noteNumber == null) continue + + // 5-lane cymbal-on-green: parser normalizes the 5-lane orange pad into + // greenDrum, so cymbal-flagged greens go back to N 4 to restore the + // original orange placement. Plain green (tom) stays at N 5. + if (isDrums && drumType === 2 && note.type === noteTypes.greenDrum && (note.flags & noteFlags.cymbal)) { + noteNumber = 4 + } + + // 5-lane drumType detection requires at least one fiveGreenDrum (N 5). + // If a chart has green+cymbal but no plain green, the parser would + // re-detect as fourLane. Heuristic: a blueDrum at the same tick as a + // green+cymbal came from N 5 + N 4 in the original (the + // hasOrangeAndGreen=true case). Emit as N 5 to preserve the layout. + if ( + isDrums && + drumType === 2 && + note.type === noteTypes.blueDrum && + group.some(n => n.type === noteTypes.greenDrum && (n.flags & noteFlags.cymbal)) + ) { + noteNumber = 5 + } + + const isDoubleKick = isDrums && note.type === noteTypes.kick && (note.flags & noteFlags.doubleKick) + if (isDoubleKick) { + events.push({ tick: note.tick, sortKey: 0, kind: 'N', value: 32, length: note.length }) + } else { + events.push({ tick: note.tick, sortKey: 0, kind: 'N', value: noteNumber, length: note.length }) + } + + if (isDrums) { + // Cymbal markers only emit in fourLanePro. fourLane/fiveLane omit + // markers (cymbal/tom state is implicit); emitting them would cause + // the parser to re-detect the chart as fourLanePro. + if ((note.flags & noteFlags.cymbal) && drumType === 1) { + const cymbalNote = drumCymbalNoteNumber[note.type] + if (cymbalNote != null) { + events.push({ tick: note.tick, sortKey: 0, kind: 'N', value: cymbalNote, length: 0 }) + } + } + + // Accent/ghost markers match the eventType of the emitted note. + // If we remapped green → N 5 (fiveGreen), use N 38/N 44. + const isFiveGreenEmitted = noteNumber === 5 + if (note.flags & noteFlags.accent) { + const accentNote = isFiveGreenEmitted ? 38 : drumAccentNoteNumber[note.type] + if (accentNote != null) events.push({ tick: note.tick, sortKey: 0, kind: 'N', value: accentNote, length: 0 }) + } + if (note.flags & noteFlags.ghost) { + const ghostNote = isFiveGreenEmitted ? 44 : drumGhostNoteNumber[note.type] + if (ghostNote != null) events.push({ tick: note.tick, sortKey: 0, kind: 'N', value: ghostNote, length: 0 }) + } + if (note.flags & noteFlags.flam) hasFlamInGroup = true + } else { + if (note.flags & noteFlags.tap) { + events.push({ tick: note.tick, sortKey: 0, kind: 'N', value: 6, length: 0 }) + } + } + } + + // ForceUnnatural (N 5) when natural HOPO state disagrees with the flag. + if (!isDrums && group.length > 0) { + const firstNote = group[0] + const wantHopo = (firstNote.flags & noteFlags.hopo) !== 0 + const wantStrum = (firstNote.flags & noteFlags.strum) !== 0 + const natural = isNaturalHopoByGroup[gi] + if ((wantHopo && !natural) || (wantStrum && natural)) { + events.push({ tick: firstNote.tick, sortKey: 0, kind: 'N', value: 5, length: 0 }) + } + } + + if (hasFlamInGroup && group.length > 0) { + events.push({ tick: group[0].tick, sortKey: 0, kind: 'N', value: 109, length: 0 }) + } + } + + // Disco-flip state transitions → `mix drums0[...]` text events. + if (isDrums) { + const diffIdx: Record = { easy: 0, medium: 1, hard: 2, expert: 3 } + const di = diffIdx[track.difficulty] ?? 3 + let currentState: 'off' | 'disco' | 'discoNoflip' = 'off' + + for (const group of track.noteEventGroups) { + if (group.length === 0) continue + let newState: 'off' | 'disco' | 'discoNoflip' = 'off' + for (const note of group) { + if (note.type === noteTypes.redDrum || note.type === noteTypes.yellowDrum) { + if (note.flags & noteFlags.discoNoflip) { newState = 'discoNoflip'; break } + if (note.flags & noteFlags.disco) { newState = 'disco'; break } + } + } + if (newState !== currentState) { + const tick = group[0].tick + const suf = newState === 'off' ? 'drums0' : newState === 'disco' ? 'drums0d' : 'drums0dnoflip' + events.push({ tick, sortKey: 2, kind: 'E', text: `mix ${di} ${suf}` }) + currentState = newState + } + } + } + + // Sort: by tick, then N (0) before S (1) before E (2). Preserve insertion + // order within an N-group at the same tick — chord order is load-bearing + // for downstream YARG parent-note selection. + events.sort((a, b) => (a.tick !== b.tick ? a.tick - b.tick : a.sortKey - b.sortKey)) + + // Deduplicate exact same-tick same-value duplicates (possible after modifier + // emission produces redundant markers). + const deduped: TrackLineEvent[] = [] + for (const ev of events) { + const prev = deduped[deduped.length - 1] + if (prev && prev.tick === ev.tick && prev.kind === ev.kind) { + if (ev.kind === 'E' && prev.kind === 'E' && prev.text === ev.text) continue + if (ev.kind !== 'E' && prev.kind !== 'E' && prev.value === ev.value && prev.length === ev.length) continue + } + deduped.push(ev) + } + + for (const ev of deduped) { + if (ev.kind === 'E') lines.push(` ${ev.tick} = E ${ev.text}`) + else lines.push(` ${ev.tick} = ${ev.kind} ${ev.value} ${ev.length}`) + } + + lines.push('}') + return lines +} + diff --git a/src/chart/create-chart.ts b/src/chart/create-chart.ts new file mode 100644 index 0000000..f0af392 --- /dev/null +++ b/src/chart/create-chart.ts @@ -0,0 +1,53 @@ +import { defaultIniChartModifiers } from './note-parsing-interfaces' +import type { ParsedChart } from './parse-chart-and-ini' + +/** + * Build a minimal valid {@link ParsedChart} from scratch, without parsing any + * source bytes. Useful for programmatic chart generation and as the counterpart + * to `parseChartAndIni`. + * + * The returned chart has: + * - the requested resolution (default 480) + * - a single tempo event at tick 0 (default 120 BPM) + * - a single time signature at tick 0 (default 4/4) + * - empty metadata, tracks, sections, vocal parts, and unrecognized events + * - `chartBytes` set to an empty `Uint8Array` — there is no source file. + * `scanChart()` will hash empty bytes (deterministic but not an identity); + * callers that need a meaningful `chartHash` should serialize via + * `writeChartFile`/`writeMidiFile` and re-parse. + * - `format` defaults to `'chart'` — the text format is the simpler target + * for programmatic construction; pass `format: 'mid'` when the caller + * needs `.mid` output. + * - `iniChartModifiers` set to the library defaults + */ +export function createEmptyChart(options?: { + format?: 'chart' | 'mid' + resolution?: number + bpm?: number + timeSignature?: { numerator: number; denominator: number } +}): ParsedChart { + const resolution = options?.resolution ?? 480 + const bpm = options?.bpm ?? 120 + const numerator = options?.timeSignature?.numerator ?? 4 + const denominator = options?.timeSignature?.denominator ?? 4 + const format = options?.format ?? 'chart' + + return { + resolution, + drumType: null, + metadata: {}, + parseIssues: [], + vocalTracks: { parts: {}, rangeShifts: [], lyricShifts: [] }, + endEvents: [], + unrecognizedEvents: [], + unrecognizedMidiTracks: [], + unrecognizedChartSections: [], + tempos: [{ tick: 0, beatsPerMinute: bpm, msTime: 0 }], + timeSignatures: [{ tick: 0, numerator, denominator, msTime: 0, msLength: 0 }], + sections: [], + trackData: [], + chartBytes: new Uint8Array(0), + format, + iniChartModifiers: defaultIniChartModifiers, + } +} diff --git a/src/chart/index.ts b/src/chart/index.ts index 47eaa40..c6f9c59 100644 --- a/src/chart/index.ts +++ b/src/chart/index.ts @@ -1,2 +1,6 @@ +export * from './chart-document' 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-note-numbers.ts b/src/chart/midi-note-numbers.ts new file mode 100644 index 0000000..829fa96 --- /dev/null +++ b/src/chart/midi-note-numbers.ts @@ -0,0 +1,88 @@ +/** + * MIDI note-number encoding constants — shared between the parser and the + * writer. + * + * Instrument tracks in .mid files encode difficulty-specific note lanes at + * fixed MIDI note ranges: + * + * - Drums: base = 60/72/84/96 (easy/medium/hard/expert), lanes 0..4 offset + * from the base. + * - 5-fret: base = 59/71/83/95, lanes 0..7 offset (0 = ENHANCED_OPENS open, + * 1..5 = green..orange, 6 = forceHopo, 7 = forceStrum). + * - 6-fret (GHL): base = 58/70/82/94, lanes 0..8 (0 = open, 1..3 = white, + * 4..6 = black, 7 = forceHopo, 8 = forceStrum). + * + * The parser and writer agree on these offsets by convention — exposing them + * as named constants keeps both sides using the same source of truth instead + * of repeating the raw numbers. + */ + +import type { Difficulty } from '../interfaces' + +export const drumsDiffStarts: Record = { + easy: 60, + medium: 72, + hard: 84, + expert: 96, +} + +export const fiveFretDiffStarts: Record = { + easy: 59, + medium: 71, + hard: 83, + expert: 95, +} + +export const sixFretDiffStarts: Record = { + easy: 58, + medium: 70, + hard: 82, + expert: 94, +} + +/** + * Lane offsets from `drumsDiffStarts[difficulty]`. 2x-kick sits at -1 (the + * only lane below the base). + */ +export const drumLaneOffsets = { + kick2x: -1, + kick: 0, + red: 1, + yellow: 2, + blue: 3, + fiveOrangeFourGreen: 4, + fiveGreen: 5, +} as const + +/** + * 5-fret lane offsets from `fiveFretDiffStarts[difficulty]`. + * + * Offset 0 is the ENHANCED_OPENS open-note slot (emitted only when the track + * has `[ENHANCED_OPENS]`); otherwise opens are encoded via the `forceOpen` + * SysEx at offset 1 (the green slot). + */ +export const fiveFretLaneOffsets = { + open: 0, + green: 1, + red: 2, + yellow: 3, + blue: 4, + orange: 5, + forceHopo: 6, + forceStrum: 7, +} as const + +/** + * 6-fret (GHL) lane offsets from `sixFretDiffStarts[difficulty]`. + */ +export const sixFretLaneOffsets = { + open: 0, + white1: 1, + white2: 2, + white3: 3, + black1: 4, + black2: 5, + black3: 6, + forceHopo: 7, + forceStrum: 8, +} as const diff --git a/src/chart/midi-parser.ts b/src/chart/midi-parser.ts index 499bc38..0d69ee4 100644 --- a/src/chart/midi-parser.ts +++ b/src/chart/midi-parser.ts @@ -3,6 +3,7 @@ import { MidiData, MidiEvent, MidiSetTempoEvent, MidiTextEvent, MidiTimeSignatur import { difficulties, Difficulty, getInstrumentType, Instrument, InstrumentType, instrumentTypes } from 'src/interfaces' import { EventType, eventTypes, IniChartModifiers, RawChartData, VocalTrackData } from './note-parsing-interfaces' import { scanVocalTrack } from './lyric-parser' +import { drumsDiffStarts, fiveFretDiffStarts, fiveFretLaneOffsets, sixFretDiffStarts, sixFretLaneOffsets } from './midi-note-numbers' // Union two phrase lists, dedup by tick (keep longest length), sort by tick. function mergePhraseLists(a: { tick: number; length: number }[], b: { tick: number; length: number }[]): { tick: number; length: number }[] { @@ -69,9 +70,6 @@ const instrumentNameMap: { [key in InstrumentTrackName]: Instrument } = { const sysExDifficultyMap = ['easy', 'medium', 'hard', 'expert'] as const const discoFlipDifficultyMap = ['easy', 'medium', 'hard', 'expert'] as const -const fiveFretDiffStarts = { easy: 59, medium: 71, hard: 83, expert: 95 } -const sixFretDiffStarts = { easy: 58, medium: 70, hard: 82, expert: 94 } -const drumsDiffStarts = { easy: 60, medium: 72, hard: 84, expert: 96 } const midiDiscoFlipRegex = /^\s*\[?mix[ _]([0-3])[ _]drums([0-5])(d|dnoflip|easy|easynokick|)\]?\s*$/ const eventsBracketedSectionRegex = /^\[(?:section|prc)[ _](.*)\]$/ const eventsPlainSectionRegex = /^(?:section|prc)[ _](.*)$/ @@ -670,49 +668,30 @@ function getInstrumentEventType(note: number) { function get6FretNoteType(note: number, difficulty: Difficulty) { switch (note - sixFretDiffStarts[difficulty]) { - case 0: - return eventTypes.open // Not forceOpen - case 1: - return eventTypes.white1 - case 2: - return eventTypes.white2 - case 3: - return eventTypes.white3 - case 4: - return eventTypes.black1 - case 5: - return eventTypes.black2 - case 6: - return eventTypes.black3 - case 7: - return eventTypes.forceHopo - case 8: - return eventTypes.forceStrum - default: - return null + case sixFretLaneOffsets.open: return eventTypes.open // Not forceOpen + case sixFretLaneOffsets.white1: return eventTypes.white1 + case sixFretLaneOffsets.white2: return eventTypes.white2 + case sixFretLaneOffsets.white3: return eventTypes.white3 + case sixFretLaneOffsets.black1: return eventTypes.black1 + case sixFretLaneOffsets.black2: return eventTypes.black2 + case sixFretLaneOffsets.black3: return eventTypes.black3 + case sixFretLaneOffsets.forceHopo: return eventTypes.forceHopo + case sixFretLaneOffsets.forceStrum: return eventTypes.forceStrum + default: return null } } function get5FretNoteType(note: number, difficulty: Difficulty, enhancedOpens: boolean) { switch (note - fiveFretDiffStarts[difficulty]) { - case 0: - return enhancedOpens ? eventTypes.open : null // Not forceOpen - case 1: - return eventTypes.green - case 2: - return eventTypes.red - case 3: - return eventTypes.yellow - case 4: - return eventTypes.blue - case 5: - return eventTypes.orange - case 6: - return eventTypes.forceHopo - case 7: - return eventTypes.forceStrum - default: - return null + case fiveFretLaneOffsets.open: return enhancedOpens ? eventTypes.open : null // Not forceOpen + case fiveFretLaneOffsets.green: return eventTypes.green + case fiveFretLaneOffsets.red: return eventTypes.red + case fiveFretLaneOffsets.yellow: return eventTypes.yellow + case fiveFretLaneOffsets.blue: return eventTypes.blue + case fiveFretLaneOffsets.orange: return eventTypes.orange + case fiveFretLaneOffsets.forceHopo: return eventTypes.forceHopo + case fiveFretLaneOffsets.forceStrum: return eventTypes.forceStrum + default: return null } } diff --git a/src/chart/midi-writer.ts b/src/chart/midi-writer.ts new file mode 100644 index 0000000..e51be26 --- /dev/null +++ b/src/chart/midi-writer.ts @@ -0,0 +1,1420 @@ +/** + * MIDI binary writer — serializes a ParsedChart back to a Format-1 `.mid` file. + * + * Emits: + * - TEMPO TRACK (tempo + time-signature meta events) + * - EVENTS track (sections + end events + unrecognized global events + coda) + * - PART DRUMS / GUITAR / GHL instrument tracks + * - PART VOCALS / HARM1 / HARM2 / HARM3 vocal tracks + * - Unrecognized MIDI tracks (verbatim pass-through) + */ + +import type { MidiData, MidiEvent } from 'midi-file' +import { writeMidi } from 'midi-file' + +import type { Difficulty } from '../interfaces' +import { drumsDiffStarts, fiveFretDiffStarts, fiveFretLaneOffsets, sixFretDiffStarts, sixFretLaneOffsets } from './midi-note-numbers' +import { computeHopoThresholdTicks, isNaturalHopo } from './natural-hopo' +import type { NoteEvent, NoteType } from './note-parsing-interfaces' +import { noteFlags, noteTypes } from './note-parsing-interfaces' +import type { NormalizedVocalPart, NormalizedVocalTrack } from './note-parsing-interfaces' +import type { ParsedChart } from './parse-chart-and-ini' + +type ParsedTrack = ParsedChart['trackData'][number] + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +/** A MIDI event tagged with its absolute tick (for sort-then-delta finalization). */ +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 — Instrument tracks (PART DRUMS / GUITAR / GHL — one per group) + * N — Vocal tracks (PART VOCALS / HARM1 / HARM2 / HARM3) + * N — Unrecognized MIDI tracks (verbatim pass-through) + */ +export function writeMidiFile(chart: ParsedChart): Uint8Array { + const trackMap = new Map() + + trackMap.set('TEMPO TRACK', buildTempoTrack(chart)) + trackMap.set('EVENTS', buildEventsTrack(chart)) + + // Group trackData by instrument so a multi-difficulty instrument emits as + // one MIDI track. A new group starts when an (instrument, difficulty) pair + // repeats (rare — catches duplicate PART DRUMS tracks in malformed sources). + interface TrackGroup { + instrument: string + trackName: string + entries: ParsedTrack[] + seenKeys: Set + } + const groups: TrackGroup[] = [] + for (const td of chart.trackData) { + const trackName = instrumentTrackNames[td.instrument] + if (!trackName) continue + const dupKey = `${td.instrument}:${td.difficulty}` + let group: TrackGroup | undefined + for (let i = groups.length - 1; i >= 0; i--) { + const g = groups[i] + if (g.instrument !== td.instrument || g.trackName !== trackName) break + if (!g.seenKeys.has(dupKey)) { group = g; break } + } + if (!group) { + group = { instrument: td.instrument, trackName, entries: [], seenKeys: new Set() } + groups.push(group) + } + group.entries.push(td) + group.seenKeys.add(dupKey) + } + + let dupSuffix = 0 + for (const g of groups) { + let mapKey = g.trackName + while (trackMap.has(mapKey)) mapKey = `${g.trackName}__dup${dupSuffix++}` + if (g.instrument === 'drums') { + trackMap.set(mapKey, buildDrumTrack(g.entries, chart, g.trackName)) + } else if (fiveFretInstruments.has(g.instrument) || sixFretInstruments.has(g.instrument)) { + trackMap.set(mapKey, buildFretTrack(g.entries, chart, g.trackName)) + } + } + + // Vocal tracks (PART VOCALS / HARM1-3). Emission order matches the + // canonical ordering so re-parse → re-write is byte-stable. + const vocalTracks = chart.vocalTracks + if (vocalTracks) { + for (const partName of ['vocals', 'harmony1', 'harmony2', 'harmony3']) { + const part = vocalTracks.parts[partName] + if (!part) continue + const trackName = vocalPartToTrackName[partName] + let mapKey = trackName + while (trackMap.has(mapKey)) mapKey = `${trackName}__dup${dupSuffix++}` + trackMap.set(mapKey, buildVocalPartTrack(partName, part, vocalTracks, trackName)) + } + } + + // Unrecognized whole tracks (VENUE, BEAT, PART REAL_*, custom tracks) are + // round-tripped verbatim. + 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.unrecognizedEvents) { + 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 + // unrecognizedEvents. The parser splits [coda] into both places, but we + // only need one. + const hasCodaInGlobalEvents = chart.unrecognizedEvents.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 + // Object.assign is noticeably faster than {...e, …} spread on V8 — tsup + // transpiles the spread to __spreadProps(__spreadValues(...)) which + // iterates property descriptors. + out.push(Object.assign({}, e, { deltaTime: absTick - prevTick }) as MidiEvent) + 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`). + */ +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 +} + +// --------------------------------------------------------------------------- +// Instrument → track name mapping +// --------------------------------------------------------------------------- + +const instrumentTrackNames: Record = { + drums: 'PART DRUMS', + guitar: 'PART GUITAR', + guitarcoop: 'PART GUITAR COOP', + rhythm: 'PART RHYTHM', + bass: 'PART BASS', + keys: 'PART KEYS', + guitarghl: 'PART GUITAR GHL', + guitarcoopghl: 'PART GUITAR COOP GHL', + rhythmghl: 'PART RHYTHM GHL', + bassghl: 'PART BASS GHL', +} + +// HARM1/2/3 (not PART HARM1/2/3) — matches the convention used by most MIDI +// chart files in the wild, including ones re-exported by YARG/ChartDump. +const vocalPartToTrackName: Record = { + vocals: 'PART VOCALS', + harmony1: 'HARM1', + harmony2: 'HARM2', + harmony3: 'HARM3', +} + +// --------------------------------------------------------------------------- +// Shared note / section helpers +// --------------------------------------------------------------------------- + +/** + * Monotonic counter used to seq-number zero-length noteOn/noteOff pairs so + * their ordering survives `finalizeMidiTrack`'s event-priority sort. Without + * explicit seq, the sort places noteOff BEFORE noteOn (noteOff has lower + * priority), producing a bogus zero-length sequence that scan-chart re-parses + * into extended sustains. + */ +let zeroLenSeq = 1_000_000 + +function addNoteOnOff( + events: AbsoluteEvent[], + tick: number, + length: number, + noteNumber: number, + velocity: number, + allowZeroLength = false, +): void { + addNoteOnOffWithChannel(events, tick, length, noteNumber, velocity, 0, allowZeroLength) +} + +function addNoteOnOffWithChannel( + events: AbsoluteEvent[], + tick: number, + length: number, + noteNumber: number, + velocity: number, + channel: number, + allowZeroLength = false, +): void { + const effectiveLength = allowZeroLength ? length : Math.max(length, 1) + if (allowZeroLength && effectiveLength === 0) { + const onSeq = zeroLenSeq++ + const offSeq = zeroLenSeq++ + events.push({ + tick, + seq: onSeq, + event: { deltaTime: 0, channel, type: 'noteOn', noteNumber, velocity } as MidiEvent, + }) + events.push({ + tick, + seq: offSeq, + event: { deltaTime: 0, channel, type: 'noteOff', noteNumber, velocity: 0 } as MidiEvent, + }) + return + } + events.push({ + tick, + event: { deltaTime: 0, channel, type: 'noteOn', noteNumber, velocity } as MidiEvent, + }) + events.push({ + tick: tick + effectiveLength, + event: { deltaTime: 0, channel, type: 'noteOff', noteNumber, velocity: 0 } as MidiEvent, + }) +} + +/** Dedupe by (tick, length) — source may carry multi-difficulty duplicates. */ +function deduplicateSections(sections: T[]): T[] { + const seen = new Set() + const out: T[] = [] + for (const s of sections) { + const key = `${s.tick}:${s.length}` + if (!seen.has(key)) { seen.add(key); out.push(s) } + } + return out.sort((a, b) => a.tick - b.tick) +} + +// --------------------------------------------------------------------------- +// Drum track emission +// --------------------------------------------------------------------------- + +/** NoteType → offset from difficulty base for drum notes (4-lane / 4-lane-pro). */ +const drumNoteTypeToOffset: Partial> = { + [noteTypes.kick]: 0, + [noteTypes.redDrum]: 1, + [noteTypes.yellowDrum]: 2, + [noteTypes.blueDrum]: 3, + [noteTypes.greenDrum]: 4, +} + +/** + * NoteType → offset for 5-lane drum notes. `greenDrum` is the 5th lane + * (MIDI 101) and `orangeDrum` (the 4th lane) is represented as greenDrum + + * cymbal flag — the writer remaps to offset 4 at emission time for those. + */ +const drumNoteTypeToOffsetFiveLane: Partial> = { + [noteTypes.kick]: 0, + [noteTypes.redDrum]: 1, + [noteTypes.yellowDrum]: 2, + [noteTypes.blueDrum]: 3, + [noteTypes.greenDrum]: 5, +} + +/** NoteType → tom-marker MIDI note number. Only yellow/blue/green have markers. */ +const drumTomMarkerNote: Partial> = { + [noteTypes.yellowDrum]: 110, + [noteTypes.blueDrum]: 111, + [noteTypes.greenDrum]: 112, +} + +/** + * Build a PART DRUMS track from one or more parsed drum tracks (one entry per + * difficulty). All difficulties emit into the same MIDI track. + */ +function buildDrumTrack( + trackDataEntries: ParsedTrack[], + chart: ParsedChart, + trackName: string, +): MidiEvent[] { + const events: AbsoluteEvent[] = [] + + events.push({ + tick: 0, + event: { deltaTime: 0, meta: true, type: 'trackName', text: trackName } as MidiEvent, + }) + + let hasAccentsOrGhosts = false + const allStarPower: { tick: number; length: number }[] = [] + const allSolo: { tick: number; length: number }[] = [] + const allActivation: { tick: number; length: number }[] = [] + const emittedFlexLane = new Map() + const emittedTomMarker = new Set() + const emittedFlam = new Set() + + // In fourLanePro, a green-drum that has BOTH tom and cymbal flags at the + // same tick across difficulties would get forced to tom by a global + // greenTomMarker. Detect those ticks up-front so emitDrumNotes can fall + // back to MIDI 101 (offset 5) for the tom note instead of emitting a + // conflicting marker. + const conflictedGreenTomTicks = new Set() + { + const greenTickFlags = new Map() + for (const td of trackDataEntries) { + for (const group of td.noteEventGroups) { + for (const n of group) { + if (n.type !== noteTypes.greenDrum) continue + const isTom = (n.flags & noteFlags.tom) !== 0 + const isCym = (n.flags & noteFlags.cymbal) !== 0 + if (!isTom && !isCym) continue + const cur = greenTickFlags.get(n.tick) ?? { tom: false, cymbal: false } + if (isTom) cur.tom = true + if (isCym) cur.cymbal = true + greenTickFlags.set(n.tick, cur) + } + } + } + for (const [tick, f] of greenTickFlags) { + if (f.tom && f.cymbal) conflictedGreenTomTicks.add(tick) + } + } + + const diffVelocity: Record = { easy: 25, medium: 35, hard: 45, expert: 100 } + + for (const td of trackDataEntries) { + emitDrumNotes(events, td, chart, emittedTomMarker, emittedFlam, conflictedGreenTomTicks, hasAG => { + if (hasAG) hasAccentsOrGhosts = true + }) + + // Collect instrument-wide sections (dedup after loop). + for (const sp of td.starPowerSections) allStarPower.push(sp) + for (const solo of td.soloSections) allSolo.push(solo) + for (const fs of td.drumFreestyleSections) allActivation.push(fs) + + // Flex lanes: pick the minimum-velocity entry across difficulties so + // scan-chart's fixFlexLaneLds assigns it to the right difficulty. + for (const fl of td.flexLanes) { + const note = fl.isDouble ? 127 : 126 + const key = `${fl.tick}:${fl.length}:${note}` + const thisVel = diffVelocity[td.difficulty] ?? 100 + const existing = emittedFlexLane.get(key) + if (existing === undefined || thisVel < existing) emittedFlexLane.set(key, thisVel) + } + + // Per-track extras — emit once from the first difficulty (scan-chart + // populates textEvents / versusPhrases / animations / unrecognizedMidiEvents + // identically across all 4 difficulties; writing them 4× would duplicate). + if (td === trackDataEntries[0]) { + const sourceIsMidi = chart.format === 'mid' + for (const te of td.textEvents) { + let text = te.text + if (!sourceIsMidi) { + const trimmed = text.trimEnd() + if (!(trimmed.startsWith('[') && trimmed.endsWith(']'))) text = `[${text}]` + } + events.push({ + tick: te.tick, + event: { deltaTime: 0, meta: true, type: 'text', text } as MidiEvent, + }) + } + // Per-track unrecognized MIDI events: absolute-tick deltaTime, seq-numbered + // so they sort stably alongside other events at the same tick. + let unrecSeq = 0 + const unrecSeqBase = 4_000_000_000 + for (const ev of td.unrecognizedMidiEvents) { + events.push({ + tick: ev.deltaTime, + seq: unrecSeqBase + unrecSeq++, + event: Object.assign({}, ev, { deltaTime: 0 }) as MidiEvent, + }) + } + } + } + + // Instrument-wide sections. Preserve velocity/channel from source if the + // parser attached them (MIDI-parsed sections carry raw MIDI properties). + const soloNote = 103 + for (const sp of deduplicateSections(allStarPower)) { + const vel = (sp as { velocity?: number }).velocity ?? 100 + const ch = (sp as { channel?: number }).channel ?? 0 + addNoteOnOffWithChannel(events, sp.tick, sp.length, 116, vel, ch) + } + for (const solo of deduplicateSections(allSolo)) { + const vel = (solo as { velocity?: number }).velocity ?? 100 + const ch = (solo as { channel?: number }).channel ?? 0 + addNoteOnOffWithChannel(events, solo.tick, solo.length, soloNote, vel, ch) + } + for (const fs of deduplicateSections(allActivation)) { + const vel = (fs as { velocity?: number }).velocity ?? 100 + const ch = (fs as { channel?: number }).channel ?? 0 + addNoteOnOffWithChannel(events, fs.tick, fs.length, 120, vel, ch) + } + + // Flex lanes (preserve length 0 — use seq to keep noteOn before noteOff). + let flexSeq = 0 + for (const [key, velocity] of emittedFlexLane) { + const [tickStr, lengthStr, noteStr] = key.split(':') + const tick = Number(tickStr) + const length = Number(lengthStr) + const noteNumber = Number(noteStr) + events.push({ + tick, + seq: flexSeq++, + event: { deltaTime: 0, channel: 0, type: 'noteOn', noteNumber, velocity } as MidiEvent, + }) + events.push({ + tick: tick + length, + seq: flexSeq++, + event: { deltaTime: 0, channel: 0, type: 'noteOff', noteNumber, velocity: 0 } as MidiEvent, + }) + } + + if (hasAccentsOrGhosts) { + events.push({ + tick: 0, + event: { deltaTime: 0, meta: true, type: 'text', text: '[ENABLE_CHART_DYNAMICS]' } as MidiEvent, + }) + } + + // fourLanePro sentinel tom marker: if drumType=1 but no tom markers were + // emitted (e.g. all yellow/blue defaulted to cymbal and green-toms used + // the offset-5 encoding), scan-chart's drumType detection falls through + // to fourLane. Emit a greenTomMarker at a tick where it attaches only to + // non-affected notes (kick, red, or fiveGreenDrum — which are always tom). + if (chart.drumType === 1 && emittedTomMarker.size === 0) { + let markerTick: number | null = null + // Prefer a green-tom tick. + for (const td of trackDataEntries) { + for (const g of td.noteEventGroups) { + for (const n of g) { + if (n.type === noteTypes.greenDrum && (n.flags & noteFlags.tom)) { + if (conflictedGreenTomTicks.has(n.tick)) continue + if (markerTick === null || n.tick < markerTick) markerTick = n.tick + } + } + } + } + if (markerTick === null) { + // Fall back: pick a tick with only kick/red (no yellow/blue/green drum) + // across ALL difficulties — tom markers apply to the whole track. + const unsafeTicks = new Set() + for (const td of trackDataEntries) { + for (const g of td.noteEventGroups) { + if (g.some(n => + n.type === noteTypes.yellowDrum || + n.type === noteTypes.blueDrum || + n.type === noteTypes.greenDrum, + )) unsafeTicks.add(g[0].tick) + } + } + for (const td of trackDataEntries) { + for (const g of td.noteEventGroups) { + if (unsafeTicks.has(g[0].tick)) continue + const hasKickOrRed = g.some(n => n.type === noteTypes.kick || n.type === noteTypes.redDrum) + if (hasKickOrRed && (markerTick === null || g[0].tick < markerTick)) { + markerTick = g[0].tick + } + } + } + } + if (markerTick !== null) addNoteOnOff(events, markerTick, 1, 112, 100) + } + + return finalizeMidiTrack(events) +} + +/** + * Precompute length overrides to prevent scan-chart's `trimSustains` from + * collapsing short-sustain note chains. When N same-type drum notes are + * directly adjacent (tick[i] + length[i] === tick[i+1]), we attribute the + * combined chain length to the first note and set subsequent ones to 0 so + * the trim threshold doesn't bite. + */ +function computeLengthOverrides(td: ParsedTrack): Map { + const overrides = new Map() + const byType = new Map() + for (const g of td.noteEventGroups) { + for (const n of g) { + let arr = byType.get(n.type) + if (!arr) { arr = []; byType.set(n.type, arr) } + arr.push({ tick: n.tick, length: n.length }) + } + } + for (const [type, notes] of byType) { + notes.sort((a, b) => a.tick - b.tick) + let i = 0 + while (i < notes.length) { + let j = i + let chainSum = notes[i].length + while (j + 1 < notes.length + && notes[j].tick + notes[j].length === notes[j + 1].tick + && notes[j + 1].length > 0) { + chainSum += notes[j + 1].length + j++ + } + if (j > i) { + overrides.set(`${notes[i].tick}:${type}`, chainSum) + for (let k = i + 1; k <= j; k++) overrides.set(`${notes[k].tick}:${type}`, 0) + } + i = j + 1 + } + } + return overrides +} + +function emitDrumNotes( + events: AbsoluteEvent[], + td: ParsedTrack, + chart: ParsedChart, + emittedTomMarker: Set, + emittedFlam: Set, + conflictedGreenTomTicks: Set, + reportAccentGhost: (hasAG: boolean) => void, +): void { + const base = drumsDiffStarts[td.difficulty] + const diffIdx: Record = { easy: 0, medium: 1, hard: 2, expert: 3 } + const di = diffIdx[td.difficulty] ?? 3 + let currentDiscoState: 'off' | 'disco' | 'discoNoflip' = 'off' + + const drumType = chart.drumType + const isFiveLaneTrack = drumType === 2 + const isFourLanePro = drumType === 1 + const offsetTable = isFiveLaneTrack ? drumNoteTypeToOffsetFiveLane : drumNoteTypeToOffset + const lengthOverrides = computeLengthOverrides(td) + + for (const group of td.noteEventGroups) { + let hasFlamInGroup = false + + // Disco flip state transitions → `[mix drums0[d|dnoflip]]` text events. + if (group.length > 0) { + let newState: 'off' | 'disco' | 'discoNoflip' = 'off' + for (const note of group) { + if (note.type === noteTypes.redDrum || note.type === noteTypes.yellowDrum) { + if (note.flags & noteFlags.discoNoflip) { newState = 'discoNoflip'; break } + if (note.flags & noteFlags.disco) { newState = 'disco'; break } + } + } + if (newState !== currentDiscoState) { + const suffix = newState === 'off' ? 'drums0' : newState === 'disco' ? 'drums0d' : 'drums0dnoflip' + events.push({ + tick: group[0].tick, + event: { deltaTime: 0, meta: true, type: 'text', text: `[mix ${di} ${suffix}]` } as MidiEvent, + }) + currentDiscoState = newState + } + } + + // Ensure regular kicks emit BEFORE double kicks at the same tick — YARG's + // MoonNote insertion dedupes by (tick, rawNote), so if both a regular kick + // and a 2x-kick pedal exist at the same tick, whichever is inserted second + // is dropped. Emitting the regular kick first keeps Expert playable when + // the non-Expert+ pass filters out the 2x kicks. + const orderedGroup = group.slice().sort((a, b) => { + const aIsDK = a.type === noteTypes.kick && (a.flags & noteFlags.doubleKick) ? 1 : 0 + const bIsDK = b.type === noteTypes.kick && (b.flags & noteFlags.doubleKick) ? 1 : 0 + return aIsDK - bIsDK + }) + + for (const note of orderedGroup) { + let offset = offsetTable[note.type] + if (offset === undefined) continue + + // Velocity encoding for accent/ghost. + let velocity = 100 + if (note.flags & noteFlags.accent) { + velocity = 127 + reportAccentGhost(true) + } else if (note.flags & noteFlags.ghost) { + velocity = 1 + reportAccentGhost(true) + } + + const emitLength = lengthOverrides.get(`${note.tick}:${note.type}`) ?? note.length + + // fourLanePro green-tom conflict handling: if another difficulty at + // the same tick has greenDrum+cymbal, emitting a global tom marker + // would flip that cymbal note to tom. Fall back to offset 5 (MIDI + // 101 = fiveGreenDrum, always tom) instead. + const isGreenDrumTom = note.type === noteTypes.greenDrum && (note.flags & noteFlags.tom) !== 0 + const useOffset5Fallback = isFourLanePro && isGreenDrumTom && conflictedGreenTomTicks.has(note.tick) + if (useOffset5Fallback) offset = 5 + + // 5-lane green-drum+cymbal → emit at offset 4 (MIDI 100, orange pad). + if (isFiveLaneTrack && note.type === noteTypes.greenDrum && (note.flags & noteFlags.cymbal)) { + offset = 4 + } + // 5-lane detection requires at least one fiveGreenDrum (MIDI 101). + // Blue at the same tick as green+cymbal gets emitted as MIDI 101 to + // restore the MIDI 100 + 101 pair the parser collapsed into blue. + if ( + isFiveLaneTrack && + note.type === noteTypes.blueDrum && + group.some(n => n.type === noteTypes.greenDrum && (n.flags & noteFlags.cymbal)) + ) { + offset = 5 + } + + const isDoubleKick = note.type === noteTypes.kick && (note.flags & noteFlags.doubleKick) + if (isDoubleKick) { + addNoteOnOff(events, note.tick, emitLength, base - 1, velocity, true) + } else { + addNoteOnOff(events, note.tick, emitLength, base + offset, velocity, true) + } + + // Tom markers only emit in fourLanePro. Skip the offset-5 fallback + // ticks (MIDI 101 already conveys tom for fiveGreenDrum). + if ((note.flags & noteFlags.tom) && isFourLanePro && !useOffset5Fallback) { + const tomNote = drumTomMarkerNote[note.type] + if (tomNote !== undefined) { + const key = `${note.tick}:1:${tomNote}` + if (!emittedTomMarker.has(key)) { + emittedTomMarker.add(key) + addNoteOnOff(events, note.tick, 1, tomNote, 100) + } + } + } + + if (note.flags & noteFlags.flam) hasFlamInGroup = true + } + + // One flam marker (MIDI 109) per group, shared across all notes at this tick. + if (hasFlamInGroup && group.length > 0) { + const key = `${group[0].tick}:1` + if (!emittedFlam.has(key)) { + emittedFlam.add(key) + addNoteOnOff(events, group[0].tick, 1, 109, 100) + } + } + } +} + +// --------------------------------------------------------------------------- +// Fret / GHL track emission +// --------------------------------------------------------------------------- + +/** NoteType → lane offset for 5-fret. `open` routes through forceOpen SysEx + * or ENHANCED_OPENS mode (handled in emitFretNotes), not this map. */ +const fiveFretNoteTypeToOffset: Partial> = { + [noteTypes.green]: fiveFretLaneOffsets.green, + [noteTypes.red]: fiveFretLaneOffsets.red, + [noteTypes.yellow]: fiveFretLaneOffsets.yellow, + [noteTypes.blue]: fiveFretLaneOffsets.blue, + [noteTypes.orange]: fiveFretLaneOffsets.orange, +} + +/** NoteType → lane offset for 6-fret (GHL). */ +const sixFretNoteTypeToOffset: Partial> = { + [noteTypes.open]: sixFretLaneOffsets.open, + [noteTypes.white1]: sixFretLaneOffsets.white1, + [noteTypes.white2]: sixFretLaneOffsets.white2, + [noteTypes.white3]: sixFretLaneOffsets.white3, + [noteTypes.black1]: sixFretLaneOffsets.black1, + [noteTypes.black2]: sixFretLaneOffsets.black2, + [noteTypes.black3]: sixFretLaneOffsets.black3, +} + +const fiveFretInstruments = new Set(['guitar', 'guitarcoop', 'rhythm', 'bass', 'keys']) +const sixFretInstruments = new Set(['guitarghl', 'guitarcoopghl', 'rhythmghl', 'bassghl']) + +/** SysEx diff byte for Phase Shift modifier encoding. */ +const sysExDiffMap: Record = { + easy: 0x00, + medium: 0x01, + hard: 0x02, + expert: 0x03, +} + +/** + * Build a PART GUITAR / PART BASS / PART GUITAR GHL / etc. track. Single + * MIDI track per instrument group; all difficulties merge into it. + */ +function buildFretTrack( + trackDataEntries: ParsedTrack[], + chart: ParsedChart, + trackName: string, +): MidiEvent[] { + const events: AbsoluteEvent[] = [] + const instrument = trackDataEntries[0].instrument + const isGhl = sixFretInstruments.has(instrument) + const sourceIsMidi = chart.format === 'mid' + + events.push({ + tick: 0, + event: { deltaTime: 0, meta: true, type: 'trackName', text: trackName } as MidiEvent, + }) + + const allStarPower: { tick: number; length: number }[] = [] + const allSolo: { tick: number; length: number }[] = [] + const emittedFlexLane = new Map() + + // Use ENHANCED_OPENS mode only when any group contains both an open note + // AND a non-open fret note (a chord-with-open). Otherwise use forceOpen + // SysEx, which collapses chords with open into single open notes but + // avoids conflicts with animations at easy diffStart. + const useEnhancedOpens = trackDataEntries.some(td => + td.noteEventGroups.some(group => { + if (group.length < 2) return false + const hasOpen = group.some(n => n.type === noteTypes.open) + const hasOther = group.some(n => n.type !== noteTypes.open) + return hasOpen && hasOther + }), + ) + + // Build animation maps (MIDI note number → length) from the first-difficulty + // data so emitFretNotes can coalesce its fret-note length with any animation + // that overlaps at the same tick:noteNumber. + const firstTd = trackDataEntries[0] + const animMap = new Map() + for (const otherTd of trackDataEntries) { + for (const anim of otherTd.animations) { + const key = `${anim.tick}:${anim.noteNumber}` + const existing = animMap.get(key) ?? 0 + if (anim.length > existing) animMap.set(key, anim.length) + } + } + + // Pre-compute seq slots for first-difficulty animations. emitFretNotes stamps + // the matching seq onto fret-note pairs that overlap an animation so the + // animation array's order survives re-parse. + const animSeqMap = new Map() + const ANIM_SEQ_BASE = 1_000_000_000 + { + let probe = 0 + for (const anim of firstTd.animations) { + const key = `${anim.tick}:${anim.noteNumber}` + const onSeq = ANIM_SEQ_BASE + probe++ + const offSeq = ANIM_SEQ_BASE + probe++ + if (!animSeqMap.has(key)) animSeqMap.set(key, { onSeq, offSeq }) + } + } + + const diffVelocity: Record = { easy: 25, medium: 35, hard: 45, expert: 100 } + + for (const td of trackDataEntries) { + if (isGhl) { + emitFretNotes(events, td, sixFretDiffStarts, sixFretNoteTypeToOffset, animMap, useEnhancedOpens, animSeqMap) + } else { + emitFretNotes(events, td, fiveFretDiffStarts, fiveFretNoteTypeToOffset, animMap, useEnhancedOpens, animSeqMap) + } + + for (const sp of td.starPowerSections) allStarPower.push(sp) + for (const solo of td.soloSections) allSolo.push(solo) + + for (const fl of td.flexLanes) { + const note = fl.isDouble ? 127 : 126 + const key = `${fl.tick}:${fl.length}:${note}` + const thisVel = diffVelocity[td.difficulty] ?? 100 + const existing = emittedFlexLane.get(key) + if (existing === undefined || thisVel < existing) emittedFlexLane.set(key, thisVel) + } + + // First-difficulty extras: text events, versus phrases, animations, + // per-track unrecognizedMidiEvents. scan-chart populates these + // identically on all 4 difficulties; writing once avoids duplicates. + if (td === firstTd) { + for (const te of td.textEvents) { + let text = te.text + if (!sourceIsMidi) { + const trimmed = text.trimEnd() + if (!(trimmed.startsWith('[') && trimmed.endsWith(']'))) text = `[${text}]` + } + events.push({ + tick: te.tick, + event: { deltaTime: 0, meta: true, type: 'text', text } as MidiEvent, + }) + } + + let vpSeq = 0 + for (const vp of td.versusPhrases) { + const note = vp.isPlayer2 ? 106 : 105 + events.push({ + tick: vp.tick, + seq: vpSeq++, + event: { deltaTime: 0, channel: 0, type: 'noteOn', noteNumber: note, velocity: 100 } as MidiEvent, + }) + events.push({ + tick: vp.tick + vp.length, + seq: vpSeq++, + event: { deltaTime: 0, channel: 0, type: 'noteOff', noteNumber: note, velocity: 0 } as MidiEvent, + }) + } + + // Animations: emit note-pair per animation unless the same + // (tick, noteNumber) was already emitted by a fret note above (in + // which case emitFretNotes coalesced the pair with the animation's + // seq — no duplicate noteOn at this tick:noteNumber). + const emittedNoteKeys = new Set() + const diffStarts2 = isGhl ? sixFretDiffStarts : fiveFretDiffStarts + const offsetMap = isGhl ? sixFretNoteTypeToOffset : fiveFretNoteTypeToOffset + for (const otherTd of trackDataEntries) { + const diffStart = diffStarts2[otherTd.difficulty] + for (const group of otherTd.noteEventGroups) { + for (const n of group) { + let noteNum: number + if (n.type === noteTypes.open) { + noteNum = useEnhancedOpens ? diffStart + 0 : diffStart + 1 + } else { + const off = offsetMap[n.type] + if (off === undefined) continue + noteNum = diffStart + off + } + emittedNoteKeys.add(`${n.tick}:${noteNum}`) + } + } + } + let animSeq = 0 + for (const anim of td.animations) { + const key = `${anim.tick}:${anim.noteNumber}` + // Advance seq counter even on skip so it stays in lock-step with + // emitFretNotes' stamped onSeq/offSeq. + const onSeq = ANIM_SEQ_BASE + animSeq++ + const offSeq = ANIM_SEQ_BASE + animSeq++ + if (emittedNoteKeys.has(key)) continue + events.push({ + tick: anim.tick, + seq: onSeq, + event: { deltaTime: 0, channel: 0, type: 'noteOn', noteNumber: anim.noteNumber, velocity: 100 } as MidiEvent, + }) + events.push({ + tick: anim.tick + anim.length, + seq: offSeq, + event: { deltaTime: 0, channel: 0, type: 'noteOff', noteNumber: anim.noteNumber, velocity: 0 } as MidiEvent, + }) + } + + let unrecSeq = 0 + const unrecSeqBase = 4_000_000_000 + for (const ev of td.unrecognizedMidiEvents) { + events.push({ + tick: ev.deltaTime, + seq: unrecSeqBase + unrecSeq++, + event: Object.assign({}, ev, { deltaTime: 0 }) as MidiEvent, + }) + } + } + } + + // Instrument-wide sections. Star power = 116, solo = 103 (no activation lanes on fret). + for (const sp of deduplicateSections(allStarPower)) { + const vel = (sp as { velocity?: number }).velocity ?? 100 + const ch = (sp as { channel?: number }).channel ?? 0 + addNoteOnOffWithChannel(events, sp.tick, sp.length, 116, vel, ch) + } + for (const solo of deduplicateSections(allSolo)) { + const vel = (solo as { velocity?: number }).velocity ?? 100 + const ch = (solo as { channel?: number }).channel ?? 0 + addNoteOnOffWithChannel(events, solo.tick, solo.length, 103, vel, ch) + } + let flexSeq = 0 + for (const [key, velocity] of emittedFlexLane) { + const [tickStr, lengthStr, noteStr] = key.split(':') + const tick = Number(tickStr) + const length = Number(lengthStr) + const noteNumber = Number(noteStr) + events.push({ + tick, + seq: flexSeq++, + event: { deltaTime: 0, channel: 0, type: 'noteOn', noteNumber, velocity } as MidiEvent, + }) + events.push({ + tick: tick + length, + seq: flexSeq++, + event: { deltaTime: 0, channel: 0, type: 'noteOff', noteNumber, velocity: 0 } as MidiEvent, + }) + } + + emitFretModifierRanges(events, trackDataEntries, isGhl, chart) + + if (useEnhancedOpens) { + events.push({ + tick: 0, + event: { deltaTime: 0, meta: true, type: 'text', text: '[ENHANCED_OPENS]' } as MidiEvent, + }) + } + + return finalizeMidiTrack(events) +} + +function emitFretNotes( + events: AbsoluteEvent[], + td: ParsedTrack, + diffStarts: Record, + noteTypeToOffset: Partial>, + animMap: Map, + useEnhancedOpens: boolean, + animSeqMap: Map, +): void { + const diffStart = diffStarts[td.difficulty] + // Fret notes whose MIDI number would fall in the animation range (40-59) + // skip the length-chain override — inflating their length would absorb the + // corresponding animation pairing on re-parse. + const lengthOverrides = computeLengthOverrides(td) + const ANIMATION_NOTE_MIN = 40 + const ANIMATION_NOTE_MAX = 59 + + const emitWithAnimSeq = (tick: number, length: number, noteNum: number): void => { + const animSeqs = animSeqMap.get(`${tick}:${noteNum}`) + if (!animSeqs) { + addNoteOnOff(events, tick, length, noteNum, 100, true) + return + } + const effectiveLength = Math.max(length, 1) + events.push({ + tick, + seq: animSeqs.onSeq, + event: { deltaTime: 0, channel: 0, type: 'noteOn', noteNumber: noteNum, velocity: 100 } as MidiEvent, + }) + events.push({ + tick: tick + effectiveLength, + seq: animSeqs.offSeq, + event: { deltaTime: 0, channel: 0, type: 'noteOff', noteNumber: noteNum, velocity: 0 } as MidiEvent, + }) + } + + for (const group of td.noteEventGroups) { + for (const note of group) { + const offsetForOverride = note.type === noteTypes.open + ? (useEnhancedOpens ? 0 : 1) + : (noteTypeToOffset[note.type] ?? 0) + const noteNumForOverride = diffStart + offsetForOverride + const inAnimationRange = noteNumForOverride >= ANIMATION_NOTE_MIN && noteNumForOverride <= ANIMATION_NOTE_MAX + const baseLen = inAnimationRange + ? note.length + : (lengthOverrides.get(`${note.tick}:${note.type}`) ?? note.length) + + if (note.type === noteTypes.open) { + if (useEnhancedOpens) { + const noteNum = diffStart + 0 + const animLength = animMap.get(`${note.tick}:${noteNum}`) + const len = (animLength != null && animLength > baseLen) ? animLength : baseLen + emitWithAnimSeq(note.tick, len, noteNum) + } else { + // forceOpen via SysEx (collapses chords — but useEnhancedOpens is only + // false when no chord-with-open exists, so no info is lost). + const noteNum = diffStart + 1 + const animLength = animMap.get(`${note.tick}:${noteNum}`) + const len = (animLength != null && animLength > baseLen) ? animLength : baseLen + emitWithAnimSeq(note.tick, len, noteNum) + addSysExOnOff(events, note.tick, 1, sysExDiffMap[td.difficulty], 0x01) + } + continue + } + const offset = noteTypeToOffset[note.type] + if (offset === undefined) continue + const noteNum = diffStart + offset + const animLength = animMap.get(`${note.tick}:${noteNum}`) + const len = (animLength != null && animLength > baseLen) ? animLength : baseLen + emitWithAnimSeq(note.tick, len, noteNum) + } + } +} + +function addSysExOnOff( + events: AbsoluteEvent[], + tick: number, + length: number, + diffByte: number, + typeByte: number, +): void { + events.push({ + tick, + event: { + deltaTime: 0, + type: 'sysEx', + data: new Uint8Array([0x50, 0x53, 0x00, 0x00, diffByte, typeByte, 0x01, 0xF7]), + } as MidiEvent, + }) + events.push({ + tick: tick + Math.max(length, 1), + event: { + deltaTime: 0, + type: 'sysEx', + data: new Uint8Array([0x50, 0x53, 0x00, 0x00, diffByte, typeByte, 0x00, 0xF7]), + } as MidiEvent, + }) +} + +// --------------------------------------------------------------------------- +// Force modifier range emission (forceHopo / forceStrum / forceTap) +// --------------------------------------------------------------------------- + +/** + * Emit force-modifier ranges (forceHopo / forceStrum / forceTap) when a + * note's resolved flag disagrees with the natural HOPO state the parser + * would pick without modifiers. Natural state is re-derived here so the + * output round-trips scan-chart's resolveFretModifiers. + */ +function emitFretModifierRanges( + events: AbsoluteEvent[], + trackDataEntries: ParsedTrack[], + isGhl: boolean, + chart: ParsedChart, +): void { + const diffStarts = isGhl ? sixFretDiffStarts : fiveFretDiffStarts + const hopoThreshold = computeHopoThresholdTicks( + chart.resolution, + chart.iniChartModifiers.hopo_frequency, + chart.iniChartModifiers.eighthnote_hopo, + 'mid', + ) + + for (const td of trackDataEntries) { + const difficulty = td.difficulty + const noteTicksInOrder: number[] = [] + const hopoTicks = new Set() + const strumTicks = new Set() + const tapTicks = new Set() + + let lastGroup: NoteEvent[] | null = null + for (const group of td.noteEventGroups) { + if (group.length === 0) continue + const tick = group[0].tick + noteTicksInOrder.push(tick) + + const flags = group[0].flags + const wantHopo = (flags & noteFlags.hopo) !== 0 + const wantStrum = (flags & noteFlags.strum) !== 0 + + const isNatHopo = isNaturalHopo(group, lastGroup, hopoThreshold, 'mid') + + if (wantHopo && !isNatHopo) hopoTicks.add(tick) + if (wantStrum && isNatHopo) strumTicks.add(tick) + if (flags & noteFlags.tap) tapTicks.add(tick) + + lastGroup = group + } + + const laneOffsets = isGhl ? sixFretLaneOffsets : fiveFretLaneOffsets + for (const range of reconstructModifierRanges(hopoTicks, noteTicksInOrder)) { + addNoteOnOff(events, range.tick, range.length, diffStarts[difficulty] + laneOffsets.forceHopo, 100) + } + for (const range of reconstructModifierRanges(strumTicks, noteTicksInOrder)) { + addNoteOnOff(events, range.tick, range.length, diffStarts[difficulty] + laneOffsets.forceStrum, 100) + } + for (const range of reconstructModifierRanges(tapTicks, noteTicksInOrder)) { + addSysExOnOff(events, range.tick, range.length, sysExDiffMap[difficulty], 0x04) + } + } +} + +/** + * Collapse a set of tick-keyed modifier hits into minimal covering ranges + * (contiguous runs over `noteTicksInOrder`). A range's length is + * `lastModifiedTick - rangeStart + 1`. + */ +function reconstructModifierRanges( + modifiedTicks: Set, + noteTicksInOrder: number[], +): { tick: number; length: number }[] { + if (modifiedTicks.size === 0) return [] + + const sorted = [...new Set(noteTicksInOrder)].sort((a, b) => a - b) + const ranges: { tick: number; length: number }[] = [] + let rangeStart: number | null = null + let lastModifiedTick: number | null = null + + for (const tick of sorted) { + if (modifiedTicks.has(tick)) { + if (rangeStart === null) rangeStart = tick + lastModifiedTick = tick + } else if (rangeStart !== null && lastModifiedTick !== null) { + ranges.push({ tick: rangeStart, length: lastModifiedTick - rangeStart + 1 }) + rangeStart = null + lastModifiedTick = null + } + } + if (rangeStart !== null && lastModifiedTick !== null) { + ranges.push({ tick: rangeStart, length: lastModifiedTick - rangeStart + 1 }) + } + return ranges +} + +// --------------------------------------------------------------------------- +// Vocal tracks (PART VOCALS / HARM1 / HARM2 / HARM3) +// --------------------------------------------------------------------------- + +/** + * Build a PART VOCALS / HARM1-3 MIDI track from normalized vocal data. + * + * scan-chart separates note 105 (scoring phrases → `notePhrases`) from note 106 + * (static lyric phrases → `staticLyricPhrases`) at parse time. YARG's + * CopyDownPhrases copies HARM1's `notePhrases` onto HARM2/HARM3 at parse + * time — to avoid re-emitting those copies and double-counting on re-parse: + * + * - PART VOCALS emits `notePhrases` at note 105 / 106 (player field decides) + * - HARM1 emits `notePhrases` as note 105, `staticLyricPhrases` as note 106 + * - HARM2 emits only `staticLyricPhrases` as note 106 (note 105 comes from + * HARM1 via CopyDown on re-parse) + * - HARM3 emits no phrase markers at all + * + * Lyric and note events are union'd across both phrase sets (note 105 and + * 106 can have different boundaries, so a lyric/note may appear in only one + * set but still needs to be emitted). + * + * Zero-length vocal notes are preserved via per-event `seq` tags so + * `finalizeMidiTrack` keeps noteOn immediately before its matching noteOff. + * + * Range shifts (note 0) and lyric shifts (note 1) are per-part for lossless + * round-trip — PART VOCALS and HARM1 often have distinct marker sets. + */ +function buildVocalPartTrack( + partName: string, + part: NormalizedVocalPart, + vocalTracks: NormalizedVocalTrack, + trackName: string, +): MidiEvent[] { + const events: AbsoluteEvent[] = [] + + events.push({ + tick: 0, + event: { deltaTime: 0, meta: true, type: 'trackName', text: trackName } as MidiEvent, + }) + + const isHarm3 = partName === 'harmony3' + const isHarm2 = partName === 'harmony2' + const isPartVocals = partName === 'vocals' + + // Phrase markers. + if (isHarm3) { + // no-op: all phrases come from CopyDown on re-parse. + } else if (isHarm2) { + for (const phrase of part.staticLyricPhrases) { + addNoteOnOff(events, phrase.tick, Math.max(phrase.length, 1), 106, 100) + } + } else if (isPartVocals) { + for (const phrase of part.notePhrases) { + const noteNumber = phrase.player === 2 ? 106 : 105 + addNoteOnOff(events, phrase.tick, Math.max(phrase.length, 1), noteNumber, 100) + } + } else { + // harmony1 + for (const phrase of part.notePhrases) { + addNoteOnOff(events, phrase.tick, Math.max(phrase.length, 1), 105, 100) + } + for (const phrase of part.staticLyricPhrases) { + addNoteOnOff(events, phrase.tick, Math.max(phrase.length, 1), 106, 100) + } + } + + // Union lyrics across notePhrases + staticLyricPhrases (different phrase + // boundaries can place the same lyric in only one set — emitting the union + // preserves all lyrics on the track). + const seenLyricKeys = new Set() + const allLyrics: { tick: number; text: string }[] = [] + for (const phrases of [part.notePhrases, part.staticLyricPhrases]) { + for (const phrase of phrases) { + for (const lyric of phrase.lyrics) { + const key = `${lyric.tick}:${lyric.text}` + if (!seenLyricKeys.has(key)) { + seenLyricKeys.add(key) + allLyrics.push(lyric) + } + } + } + } + allLyrics.sort((a, b) => a.tick - b.tick) + for (const lyric of allLyrics) { + events.push({ + tick: lyric.tick, + event: { deltaTime: 0, meta: true, type: 'lyrics', text: lyric.text } as MidiEvent, + }) + } + + // Union notes across the same two phrase sets. + const seenNoteKeys = new Set() + const allNotes: { tick: number; length: number; pitch: number; type: 'pitched' | 'percussion' }[] = [] + for (const phrases of [part.notePhrases, part.staticLyricPhrases]) { + for (const phrase of phrases) { + for (const note of phrase.notes) { + const key = `${note.tick}:${note.pitch}:${note.length}` + if (!seenNoteKeys.has(key)) { + seenNoteKeys.add(key) + allNotes.push(note) + } + } + } + } + allNotes.sort((a, b) => a.tick - b.tick) + + let vocalNoteSeq = 1_000_000 + for (const note of allNotes) { + const midiPitch = note.type === 'pitched' + ? (note.pitch >= 36 && note.pitch <= 84 ? note.pitch : 60) + : 96 + events.push({ + tick: note.tick, + seq: vocalNoteSeq++, + event: { deltaTime: 0, channel: 0, type: 'noteOn', noteNumber: midiPitch, velocity: 100 } as MidiEvent, + }) + events.push({ + tick: note.tick + note.length, + seq: vocalNoteSeq++, + event: { deltaTime: 0, channel: 0, type: 'noteOff', noteNumber: midiPitch, velocity: 0 } as MidiEvent, + }) + } + + // Star power sections → note 116. HARM2/HARM3 starPowerSections are also + // copied from HARM1 by CopyDown on re-parse, so only HARM1 / PART VOCALS + // need to emit them. + if (!isHarm2 && !isHarm3) { + for (const sp of part.starPowerSections) { + addNoteOnOff(events, sp.tick, Math.max(sp.length, 1), 116, 100) + } + } + + // Vocal-track text events (stance markers, Band_PlayFacialAnim, etc.). + // YARG marks a VocalsPart non-empty iff it has phrases or text events, so + // emitting these is required for round-tripping stance-only tracks. + for (const te of part.textEvents) { + events.push({ + tick: te.tick, + event: { deltaTime: 0, meta: true, type: 'text', text: te.text } as MidiEvent, + }) + } + + // Per-part range shifts (note 0) and lyric shifts (note 1). Fall back to + // the track-level arrays only if the per-part arrays are empty and this + // part owns the track-level data (PART VOCALS, or HARM1 when PART VOCALS + // is absent). YARG's GetRangeShifts reads these markers per-track. + const partOwnsTrackLevel = + partName === 'vocals' || (partName === 'harmony1' && !vocalTracks.parts.vocals) + + if (part.rangeShifts.length > 0) { + for (const rs of part.rangeShifts) { + addNoteOnOff(events, rs.tick, Math.max(rs.length, 1), 0, 100) + } + } else if (partOwnsTrackLevel) { + for (const rs of vocalTracks.rangeShifts) { + addNoteOnOff(events, rs.tick, Math.max(rs.length, 1), 0, 100) + } + } + + if (part.lyricShifts.length > 0) { + for (const ls of part.lyricShifts) { + addNoteOnOff(events, ls.tick, Math.max(ls.length, 1), 1, 100) + } + } else if (partOwnsTrackLevel) { + for (const ls of vocalTracks.lyricShifts) { + addNoteOnOff(events, ls.tick, Math.max(ls.length, 1), 1, 100) + } + } + + return finalizeMidiTrack(events) +} diff --git a/src/chart/natural-hopo.ts b/src/chart/natural-hopo.ts new file mode 100644 index 0000000..7ccac80 --- /dev/null +++ b/src/chart/natural-hopo.ts @@ -0,0 +1,174 @@ +/** + * Natural-HOPO helpers — shared by the parser, scanner, and writers. + * + * All three places need to answer the same structural questions about a fret + * group (is it a chord? does it equal the previous group? is it a subset of + * the previous group?) and whether the group is a "natural HOPO" (would + * resolve to HOPO without any force modifiers): + * + * - Parser (resolveFretModifiers in notes-parser.ts) decides the group's + * resolved hopo/strum flag at parse time. Operates on `TrackEvent[]` + * (pre-resolution; `.type` is `EventType`). + * - Scanner (chart-scanner.ts) re-derives `hasForcedNotes` after the + * fact. Operates on `NoteEvent[]` (post-resolution; `.type` is `NoteType`). + * - Writers (chart-writer.ts, midi-writer.ts) decide whether to emit a + * force-* modifier — only when the resolved flag disagrees with natural. + * Operates on `NoteEvent[]`. + * + * `NoteType` and `EventType` use different numeric values for the same fret + * colors, so the helpers are parameterized over a per-enum "is this a fret + * note?" predicate, with thin wrappers exported for each concrete type. + */ + +import type { EventType, NoteEvent, RawChartData } from './note-parsing-interfaces' +import { eventTypes, noteTypes, NoteType } from './note-parsing-interfaces' + +type TrackEvent = RawChartData['trackData'][number]['trackEvents'][number] + +// --------------------------------------------------------------------------- +// Per-enum "is this a fret note?" predicates. +// --------------------------------------------------------------------------- + +const fretNoteTypes = new Set([ + noteTypes.open, noteTypes.green, noteTypes.red, noteTypes.yellow, noteTypes.blue, noteTypes.orange, + noteTypes.black1, noteTypes.black2, noteTypes.black3, + noteTypes.white1, noteTypes.white2, noteTypes.white3, +]) +const fretEventTypes = new Set([ + eventTypes.open, eventTypes.green, eventTypes.red, eventTypes.yellow, eventTypes.blue, eventTypes.orange, + eventTypes.black1, eventTypes.black2, eventTypes.black3, + eventTypes.white1, eventTypes.white2, eventTypes.white3, +]) + +export const isFretNoteType = (t: NoteType): boolean => fretNoteTypes.has(t) +export const isFretEventType = (t: EventType): boolean => fretEventTypes.has(t) + +// --------------------------------------------------------------------------- +// Generic fret-group helpers. +// +// Each takes the group plus an `isFret` predicate that matches the group's +// element-type enum. Internal — use the NoteEvent / TrackEvent specializations +// exported below. +// --------------------------------------------------------------------------- + +function isFretChordGeneric( + group: E[], + isFret: (t: T) => boolean, +): boolean { + let firstType: T | null = null + for (const n of group) { + if (!isFret(n.type)) continue + if (firstType === null) firstType = n.type + else if (firstType !== n.type) return true + } + return false +} + +function isSameFretNoteGeneric( + a: E[], + b: E[], + isFret: (t: T) => boolean, +): boolean { + const aT: T[] = [] + for (const n of a) if (isFret(n.type)) aT.push(n.type) + const bT: T[] = [] + for (const n of b) if (isFret(n.type)) bT.push(n.type) + if (aT.length !== bT.length) return false + const s = new Set(bT) + for (const t of aT) if (!s.has(t)) return false + return true +} + +function isInFretNoteGeneric( + inner: E[], + outer: E[], + isFret: (t: T) => boolean, +): boolean { + const o = new Set() + for (const n of outer) if (isFret(n.type)) o.add(n.type) + for (const n of inner) if (isFret(n.type) && !o.has(n.type)) return false + return true +} + +// --------------------------------------------------------------------------- +// NoteEvent specializations — used by the scanner and writers. +// --------------------------------------------------------------------------- + +export function isFretChord(group: NoteEvent[]): boolean { + return isFretChordGeneric(group, isFretNoteType) +} +export function isSameFretNote(a: NoteEvent[], b: NoteEvent[]): boolean { + return isSameFretNoteGeneric(a, b, isFretNoteType) +} +export function isInFretNote(inner: NoteEvent[], outer: NoteEvent[]): boolean { + return isInFretNoteGeneric(inner, outer, isFretNoteType) +} + +// --------------------------------------------------------------------------- +// TrackEvent specializations — used by the parser's resolveFretModifiers. +// --------------------------------------------------------------------------- + +export function isFretChordRawEvents(group: TrackEvent[]): boolean { + return isFretChordGeneric(group, isFretEventType) +} +export function isSameFretNoteRawEvents(a: TrackEvent[], b: TrackEvent[]): boolean { + return isSameFretNoteGeneric(a, b, isFretEventType) +} +export function isInFretNoteRawEvents(inner: TrackEvent[], outer: TrackEvent[]): boolean { + return isInFretNoteGeneric(inner, outer, isFretEventType) +} + +// --------------------------------------------------------------------------- +// HOPO threshold + NoteEvent-based natural-HOPO rule. +// +// The parser does its own natural-HOPO check inline (different variable +// shape — effectiveNotes vs events, etc.), and calls the individual +// *RawEvents helpers above. Scanner + writers use the NoteEvent form +// through this wrapper. +// --------------------------------------------------------------------------- + +/** + * Compute the natural-HOPO threshold in ticks. Mirrors the formula the parser + * uses in `resolveFretModifiers`: + * + * - if `iniHopoFreq` is set (non-zero), it wins outright + * - else if `eighthnoteHopo`, use `floor(1 + resolution/2)` + * - else, the default differs by format: + * - `.mid` : `floor(1 + resolution/3)` + * - `.chart`: `floor((65/192) * resolution)` + */ +export function computeHopoThresholdTicks( + resolution: number, + iniHopoFreq: number, + eighthnoteHopo: boolean, + format: 'chart' | 'mid', +): number { + if (iniHopoFreq) return iniHopoFreq + if (eighthnoteHopo) return Math.floor(1 + resolution / 2) + return Math.floor(format === 'mid' ? 1 + resolution / 3 : (65 / 192) * resolution) +} + +/** + * True if `current` would resolve to HOPO with no force modifiers. Rules: + * + * 1. No previous group → not a natural HOPO. + * 2. Gap from previous group > threshold → strum. + * 3. Current is a chord → strum. + * 4. Previous is a single note and current is the same single note → strum. + * 5. `.mid` only: previous is a chord and current is a subset of it → strum + * (back-compat exception for older games). + * 6. Otherwise → natural HOPO. + */ +export function isNaturalHopo( + current: NoteEvent[], + last: NoteEvent[] | null, + hopoThresholdTicks: number, + format: 'chart' | 'mid', +): boolean { + if (!last) return false + if (current[0].tick - last[0].tick > hopoThresholdTicks) return false + if (isFretChord(current)) return false + if (!isFretChord(last) && isSameFretNote(current, last)) return false + if (format === 'mid' && isFretChord(last) && isInFretNote(current, last)) return false + return true +} diff --git a/src/chart/note-parsing-interfaces.ts b/src/chart/note-parsing-interfaces.ts index 949ebd5..146fd16 100644 --- a/src/chart/note-parsing-interfaces.ts +++ b/src/chart/note-parsing-interfaces.ts @@ -1,4 +1,5 @@ import type { MidiEvent } from 'midi-file' +import { defaultMetadata } from 'src/ini' import { Difficulty, Instrument, NotesData } from 'src/interfaces' import { ObjectValues } from 'src/utils' @@ -13,15 +14,24 @@ export interface IniChartModifiers { pro_drums: boolean } -export const defaultIniChartModifiers = { - song_length: 0, - hopo_frequency: 0, - eighthnote_hopo: false, - multiplier_note: 0, - sustain_cutoff_threshold: -1, - chord_snap_threshold: 0, - five_lane_drums: false, - pro_drums: false, +/** + * Projection of the 8 `song.ini` fields that influence chart parsing, with + * defaults derived from {@link defaultMetadata}. Exported so consumers who + * call `parseChartFile` directly can construct a `Partial` + * on top of known defaults without having to duplicate the values here. + * + * Kept as a projection (rather than a standalone literal) so these 8 defaults + * can never drift from the 40-field source of truth in {@link defaultMetadata}. + */ +export const defaultIniChartModifiers: IniChartModifiers = { + song_length: defaultMetadata.song_length, + hopo_frequency: defaultMetadata.hopo_frequency, + eighthnote_hopo: defaultMetadata.eighthnote_hopo, + multiplier_note: defaultMetadata.multiplier_note, + sustain_cutoff_threshold: defaultMetadata.sustain_cutoff_threshold, + chord_snap_threshold: defaultMetadata.chord_snap_threshold, + five_lane_drums: defaultMetadata.five_lane_drums, + pro_drums: defaultMetadata.pro_drums, } /** @@ -36,16 +46,22 @@ export const defaultIniChartModifiers = { */ export interface RawChartData { chartTicksPerBeat: number - metadata: { - name?: string - artist?: string - album?: string - genre?: string - year?: string - charter?: string - diff_guitar?: number - delay?: number - preview_start_time?: number + /** + * Song metadata. Parsers populate only what the source file carries + * (the [Song] section for .chart; nothing for .mid). `parseChartAndIni` + * overlays `song.ini` values on top of this, with ini winning where both + * are present, and populates `extraIniFields` from unknown ini keys. + */ + metadata: Partial & { + /** Unknown song.ini key/value pairs, preserved for round-trip writing. */ + extraIniFields?: { [key: string]: string } + /** + * `[Song].Offset` from the .chart file body, in milliseconds. Distinct + * from the ini-origin `delay` field — games recognize `Offset` only in + * [Song], and `delay` only in song.ini. Kept as a separate key so the + * two never collide on the ini-wins merge in `parseChartAndIni`. + */ + chart_offset?: number } /** * Vocal track data keyed by part name. diff --git a/src/chart/notes-parser.ts b/src/chart/notes-parser.ts index b5471c4..51bd263 100644 --- a/src/chart/notes-parser.ts +++ b/src/chart/notes-parser.ts @@ -22,6 +22,12 @@ import { VocalTrackData, } from './note-parsing-interfaces' import { parseLyricFlags, stripLyricSymbols } from './lyric-parser' +import { + isFretChordRawEvents, + isFretEventType, + isInFretNoteRawEvents, + isSameFretNoteRawEvents, +} from './natural-hopo' type TrackEvent = RawChartData['trackData'][number]['trackEvents'][number] type UntimedNoteEvent = Omit @@ -45,16 +51,6 @@ export function parseChartFile(data: Uint8Array, format: 'chart' | 'mid', partia : drumTracks.find(track => track.trackEvents.find(e => isCymbalOrTomMarker(e.type))) ? drumTypes.fourLanePro : drumTracks.find(track => track.trackEvents.find(e => e.type === eventTypes.fiveGreenDrum)) ? drumTypes.fiveLane : drumTypes.fourLane - let hasForcedNotes = false - outer: for (const track of rawChartData.trackData) { - if (track.instrument === 'drums') continue - for (const e of track.trackEvents) { - if (e.type === eventTypes.forceUnnatural || e.type === eventTypes.forceHopo || e.type === eventTypes.forceStrum) { - hasForcedNotes = true - break outer - } - } - } const normalizedVocalTracks = normalizeVocalTracks(rawChartData.vocalTracks, timedTempos, rawChartData.chartTicksPerBeat) // Evaluate trackData first — normalizedVocalTracks is used below for phrase-level hasLyrics check. @@ -89,12 +85,6 @@ export function parseChartFile(data: Uint8Array, format: 'chart' | 'mid', partia resolution: rawChartData.chartTicksPerBeat, drumType, metadata: rawChartData.metadata, - // Check phrase-level lyrics to decide hasLyrics — raw lyric events that - // get filtered (brackets, whitespace-only) should not count. - hasLyrics: Object.values(normalizedVocalTracks.parts).some(p => - p.notePhrases.some(ph => ph.lyrics.length > 0)), - hasVocals: Object.values(rawChartData.vocalTracks).some(v => v.vocalPhrases.length > 0), - hasForcedNotes, parseIssues: rawChartData.parseIssues, vocalTracks: normalizedVocalTracks, endEvents: setEventMsTimes(rawChartData.endEvents, timedTempos, rawChartData.chartTicksPerBeat), @@ -799,7 +789,7 @@ function resolveFretModifiers( let longestNote: TrackEvent | null = null for (const e of events) { const t = e.type - if (isFretNote(t)) { + if (isFretEventType(t)) { notes.push(e) if (!longestNote || e.length > longestNote.length) longestNote = e } else if (t === eventTypes.forceOpen) { @@ -826,7 +816,7 @@ function resolveFretModifiers( let w = 0 for (let r = 0; r < events.length; r++) { const et = events[r].type - if (!isFretNote(et) && et !== eventTypes.forceOpen) events[w++] = events[r] + if (!isFretEventType(et) && et !== eventTypes.forceOpen) events[w++] = events[r] } events.length = w events.push(longestNote) @@ -839,10 +829,10 @@ function resolveFretModifiers( const isNaturalHopo = !!lastNotes && effectiveNotes[0].tick - lastNotes[0].tick <= hopoThresholdTicks && - !isFretChord(effectiveNotes) && - !isSameFretNote(events, lastNotes) && + !isFretChordRawEvents(effectiveNotes) && + !isSameFretNoteRawEvents(events, lastNotes) && // This .mid exception is due to compatibility concerns with older games that primarily use .mid - !(format === 'mid' && isFretChord(lastNotes) && isInFretNote(effectiveNotes, lastNotes)) + !(format === 'mid' && isFretChordRawEvents(lastNotes) && isInFretNoteRawEvents(effectiveNotes, lastNotes)) const forceResult = hasForceTap ? noteFlags.tap : hasForceHopo ? noteFlags.hopo @@ -867,89 +857,6 @@ function resolveFretModifiers( return noteEventGroups } -function isFretNote(type: EventType) { - switch (type) { - case eventTypes.open: - case eventTypes.green: - case eventTypes.red: - case eventTypes.yellow: - case eventTypes.blue: - case eventTypes.orange: - case eventTypes.black3: - case eventTypes.black2: - case eventTypes.black1: - case eventTypes.white3: - case eventTypes.white2: - case eventTypes.white1: - return true - default: - return false - } -} - -function isSameFretNote(note1: TrackEvent[], note2: TrackEvent[]) { - for (const n1 of note1) { - if (!isFretNote(n1.type)) { - continue - } - - for (const n2 of note2) { - if (!isFretNote(n2.type)) { - continue - } - - if (n1.type !== n2.type) { - return false - } - } - } - - for (const n2 of note2) { - if (!isFretNote(n2.type)) { - continue - } - - for (const n1 of note1) { - if (!isFretNote(n1.type)) { - continue - } - - if (n2.type !== n1.type) { - return false - } - } - } - - return true -} - -function isFretChord(note: TrackEvent[]) { - let firstNoteType: EventType | null = null - for (const n of note) { - if (isFretNote(n.type)) { - if (firstNoteType === null) { - firstNoteType = n.type - } else if (firstNoteType !== n.type) { - return true - } - } - } - return false -} - -function isInFretNote(inNote: TrackEvent[], outerNote: TrackEvent[]) { - // True if every fret note type in `inNote` also appears in `outerNote`. - for (const n of inNote) { - if (!isFretNote(n.type)) continue - let found = false - for (const o of outerNote) { - if (o.type === n.type) { found = true; break } - } - if (!found) return false - } - return true -} - function getFretNoteTypeFromEventType(eventType: EventType): NoteType | null { switch (eventType) { case eventTypes.open: diff --git a/src/chart/parse-chart-and-ini.ts b/src/chart/parse-chart-and-ini.ts index 388ca5b..d461d32 100644 --- a/src/chart/parse-chart-and-ini.ts +++ b/src/chart/parse-chart-and-ini.ts @@ -1,6 +1,6 @@ import * as _ from 'lodash' -import { defaultMetadata, scanIni } from '../ini' +import { scanIni } from '../ini' import { FolderIssueType, MetadataIssueType } from '../interfaces' import { getExtension, hasChartExtension, hasChartName } from '../utils' import { defaultIniChartModifiers, IniChartModifiers } from './note-parsing-interfaces' @@ -29,15 +29,26 @@ export interface ParseChartAndIniResult { /** * The parsed chart, or `null` if a chart file could not be found or could * not be parsed. Inspect `chartFolderIssues` for the reason. + * + * `parsedChart.metadata` holds the normalized metadata: values from + * `song.ini` take precedence over the chart file's `[Song]` section, and + * unknown ini key/value pairs are preserved in `metadata.extraIniFields` + * for round-trip writing. */ parsedChart: ParsedChart | null + /** + * `true` if the folder contains a parseable `song.ini` (i.e. a file was + * present AND it had a readable `[Song]` section). Useful for downstream + * checks that only make sense when ini-derived metadata is available — + * e.g. "ini is missing a diff_X value" issues don't apply at all when the + * ini file doesn't exist. + */ + hasIni: boolean /** * Folder-level issues from chart file discovery and parsing * (`noChart`, `invalidChart`, `multipleChart`, `badChart`). */ chartFolderIssues: { folderIssue: FolderIssueType; description: string }[] - /** The metadata parsed from `song.ini`, or `null` if no ini was present. */ - iniMetadata: typeof defaultMetadata | null /** * Folder-level issues from ini scanning (`noMetadata`, `invalidIni`, * `invalidMetadata`, `badIniLine`, `multipleIniFiles`). @@ -45,8 +56,6 @@ export interface ParseChartAndIniResult { iniFolderIssues: { folderIssue: FolderIssueType; description: string }[] /** Validation issues with ini values. */ iniMetadataIssues: { metadataIssue: MetadataIssueType; description: string }[] - /** ini key/value pairs not in scan-chart's known list. */ - iniUnknownValues: { [key: string]: string } } /** @@ -66,7 +75,22 @@ export function parseChartAndIni(files: { fileName: string; data: Uint8Array }[] if (chartData) { try { const inner = parseChartFile(chartData, format!, iniChartModifiers) + + // Merge [Song]-section metadata with song.ini metadata onto a single + // `parsedChart.metadata`. song.ini wins where both provide a value — + // the ini file is authoritative and the chart file's [Song] block is + // a legacy overlap. Unknown ini keys are preserved in `extraIniFields` + // for round-trip writing. + const mergedMetadata: ParsedChart['metadata'] = { + ...inner.metadata, + ...(iniData.metadata ?? {}), + } + if (Object.keys(iniData.unknownIniValues).length > 0) { + mergedMetadata.extraIniFields = { ...iniData.unknownIniValues } + } + parsedChart = Object.assign({}, inner, { + metadata: mergedMetadata, chartBytes: chartData, format: format!, iniChartModifiers, @@ -81,11 +105,10 @@ export function parseChartAndIni(files: { fileName: string; data: Uint8Array }[] return { parsedChart, + hasIni: iniData.metadata !== null, chartFolderIssues, - iniMetadata: iniData.metadata, iniFolderIssues: iniData.folderIssues, iniMetadataIssues: iniData.metadataIssues, - iniUnknownValues: iniData.unknownIniValues, } } diff --git a/src/index.ts b/src/index.ts index 2805679..cce861d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,9 +12,10 @@ import { scanVideo } from './video' export * from './interfaces' export * from './chart/note-parsing-interfaces' export { parseChartFile } from './chart/notes-parser' -export { parseChartAndIni } from './chart' -export type { ParsedChart, ParseChartAndIniResult } from './chart' -export { scanIni } from './ini' +export { parseChartAndIni, createEmptyChart, writeChartFile, writeMidiFile, writeChartFolder } from './chart' +export type { ParsedChart, ParseChartAndIniResult, ChartDocument, ChartAsset } from './chart' +export { scanIni, writeIniFile } from './ini' +export type { IniMetadata } from './ini' export { calculateTrackHash } from './chart/track-hasher' /** @@ -48,9 +49,15 @@ export function scanChart( chart.chartHash = chartData.chartHash chart.notesData = chartData.notesData const instruments = chartData.notesData.instruments - if (parseResult.iniMetadata) { + // Missing/extra diff_* issues are ini-centric — they're about whether + // the song.ini file declares an appropriate difficulty rating for each + // charted instrument. Skip the checks entirely when no ini was parsed: + // "ini is missing X" isn't a meaningful complaint if there's no ini at + // all (the folder-level `noMetadata` issue already flags that). + if (parseResult.hasIni) { + const metadata = parseResult.parsedChart.metadata const checkMissingDifficulty = (instrument: Instrument, diffKey: keyof typeof defaultMetadata) => { - if (instruments.includes(instrument) && parseResult.iniMetadata![diffKey] === defaultMetadata[diffKey]) { + if (instruments.includes(instrument) && metadata[diffKey] === defaultMetadata[diffKey]) { chart.metadataIssues.push({ metadataIssue: 'missingValue', description: `Metadata is missing a "${diffKey}" value.` }) } } @@ -64,12 +71,12 @@ export function scanChart( checkMissingDifficulty('guitarcoopghl', 'diff_guitar_coop_ghl') checkMissingDifficulty('rhythmghl', 'diff_rhythm_ghl') checkMissingDifficulty('bassghl', 'diff_bassghl') - if (chartData.notesData.hasVocals && parseResult.iniMetadata.diff_vocals === defaultMetadata.diff_vocals) { + if (chartData.notesData.hasVocals && metadata.diff_vocals === defaultMetadata.diff_vocals) { chart.metadataIssues.push({ metadataIssue: 'missingValue', description: 'Metadata is missing a "diff_vocals" value.' }) } const checkExtraDifficulty = (instrument: Instrument, diffKey: keyof typeof defaultMetadata) => { - if (parseResult.iniMetadata![diffKey] !== defaultMetadata[diffKey] && !instruments.includes(instrument)) { + if (metadata[diffKey] !== defaultMetadata[diffKey] && !instruments.includes(instrument)) { chart.metadataIssues.push({ metadataIssue: 'extraValue', description: `Metadata contains "${diffKey}", but ${instrument} is not charted.`, @@ -86,7 +93,7 @@ export function scanChart( checkExtraDifficulty('guitarcoopghl', 'diff_guitar_coop_ghl') checkExtraDifficulty('rhythmghl', 'diff_rhythm_ghl') checkExtraDifficulty('bassghl', 'diff_bassghl') - if (parseResult.iniMetadata.diff_vocals !== defaultMetadata.diff_vocals && !chartData.notesData.hasVocals) { + if (metadata.diff_vocals !== defaultMetadata.diff_vocals && !chartData.notesData.hasVocals) { chart.metadataIssues.push({ metadataIssue: 'extraValue', description: 'Metadata contains "diff_vocals", but vocals are not charted.', @@ -95,17 +102,16 @@ export function scanChart( } } - if (parseResult.iniMetadata) { - // Use metadata from .ini file if it exists (filled in with defaults for properties that are not included) - _.assign(chart, parseResult.iniMetadata) - } else if (parseResult.parsedChart?.metadata) { - // Use metadata from .chart file if it exists - _.assign(chart, parseResult.parsedChart.metadata) + if (parseResult.parsedChart) { + // Apply the merged metadata (ini overlays [Song], defaults fill gaps when ini is present). + // `chart_offset` is [Song]-only and `extraIniFields` is a round-trip + // preservation bag — neither belongs on the top-level ScannedChart + // surface, so strip them before assigning. + _.assign(chart, _.omit(parseResult.parsedChart.metadata, 'extraIniFields', 'chart_offset')) + chart.chart_offset = parseResult.parsedChart.metadata.chart_offset ?? 0 } else { - // No metadata available chart.playable = false } - chart.chart_offset = parseResult.parsedChart?.metadata?.delay ?? 0 const imageData = scanImage(files) chart.folderIssues.push(...imageData.folderIssues) diff --git a/src/ini/index.ts b/src/ini/index.ts index ae8ae25..9114849 100644 --- a/src/ini/index.ts +++ b/src/ini/index.ts @@ -1 +1,2 @@ export * from './ini-scanner' +export * from './ini-writer' diff --git a/src/ini/ini-writer.ts b/src/ini/ini-writer.ts new file mode 100644 index 0000000..61707f4 --- /dev/null +++ b/src/ini/ini-writer.ts @@ -0,0 +1,46 @@ +import { defaultMetadata } from './ini-scanner' + +/** + * Metadata input shape accepted by `writeIniFile`. Matches the shape + * `parseChartAndIni` produces on `parsedChart.metadata`: any subset of the + * known ini fields, plus optional `extraIniFields` for unknown keys preserved + * for round-trip. + */ +export type IniMetadata = Partial & { + extraIniFields?: { [key: string]: string } +} + +/** + * Serialize metadata to a `song.ini` string with CRLF line endings. + * + * Emission rules: + * - Starts with a `[song]` header. + * - Known fields emit in the order defined by `defaultMetadata`. + * - Fields whose value is `undefined` are skipped. + * - Booleans format as `True`/`False` (matching Clone Hero convention). + * - `extraIniFields` are appended after the known fields, in insertion + * order. + */ +export function writeIniFile(metadata: IniMetadata): string { + const lines: string[] = ['[song]'] + + const keys = Object.keys(defaultMetadata) as (keyof typeof defaultMetadata)[] + for (const key of keys) { + const value = metadata[key] + if (value === undefined) continue + lines.push(`${key} = ${formatValue(value)}`) + } + + if (metadata.extraIniFields) { + for (const [key, value] of Object.entries(metadata.extraIniFields)) { + lines.push(`${key} = ${value}`) + } + } + + return lines.join('\r\n') + '\r\n' +} + +function formatValue(value: string | number | boolean): string { + if (typeof value === 'boolean') return value ? 'True' : 'False' + return String(value) +}