From 84b0e833debbce9d21e47272f6c1e2b83ea9ab35 Mon Sep 17 00:00:00 2001 From: Eli White Date: Sun, 19 Apr 2026 18:45:06 -0700 Subject: [PATCH 1/6] Add createEmptyChart() counterpart to parseChartAndIni Builds a minimal valid ParsedChart from scratch: default 480 resolution, 120 BPM at tick 0, 4/4 time signature at tick 0, empty tracks/sections/ metadata/vocal parts/unrecognized events. chartBytes defaults to an empty Uint8Array (no source bytes), format defaults to 'chart', iniChartModifiers to the library defaults. Options let callers override resolution, bpm, timeSignature, and format. Useful for programmatic chart generation (e.g. downstream code that builds charts up from scratch rather than parsing source bytes). --- src/__tests__/create-chart.test.ts | 66 ++++++++++++++++++++++++++++++ src/chart/create-chart.ts | 55 +++++++++++++++++++++++++ src/chart/index.ts | 1 + src/index.ts | 2 +- 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/create-chart.test.ts create mode 100644 src/chart/create-chart.ts diff --git a/src/__tests__/create-chart.test.ts b/src/__tests__/create-chart.test.ts new file mode 100644 index 0000000..e299857 --- /dev/null +++ b/src/__tests__/create-chart.test.ts @@ -0,0 +1,66 @@ +/** + * 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.unrecognizedEventsTrackTextEvents).toEqual([]) + expect(chart.unrecognizedEventsTrackMidiEvents).toEqual([]) + expect(chart.unrecognizedMidiTracks).toEqual([]) + expect(chart.unrecognizedChartSections).toEqual([]) + expect(chart.unrecognizedSyncTrackEvents).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/chart/create-chart.ts b/src/chart/create-chart.ts new file mode 100644 index 0000000..43abddb --- /dev/null +++ b/src/chart/create-chart.ts @@ -0,0 +1,55 @@ +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: [], + unrecognizedEventsTrackTextEvents: [], + unrecognizedEventsTrackMidiEvents: [], + unrecognizedMidiTracks: [], + unrecognizedChartSections: [], + tempos: [{ tick: 0, beatsPerMinute: bpm, msTime: 0 }], + timeSignatures: [{ tick: 0, numerator, denominator, msTime: 0, msLength: 0 }], + unrecognizedSyncTrackEvents: [], + sections: [], + trackData: [], + chartBytes: new Uint8Array(0), + format, + iniChartModifiers: defaultIniChartModifiers, + } +} diff --git a/src/chart/index.ts b/src/chart/index.ts index 47eaa40..23e5c25 100644 --- a/src/chart/index.ts +++ b/src/chart/index.ts @@ -1,2 +1,3 @@ export * from './chart-scanner' +export * from './create-chart' export * from './parse-chart-and-ini' diff --git a/src/index.ts b/src/index.ts index e7a46c7..52adc3e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ import { scanVideo } from './video' export * from './interfaces' export * from './chart/note-parsing-interfaces' export { parseChartFile } from './chart/notes-parser' -export { parseChartAndIni } from './chart' +export { parseChartAndIni, createEmptyChart } from './chart' export type { ParsedChart, ParseChartAndIniResult } from './chart' export { scanIni } from './ini' export { calculateTrackHash } from './chart/track-hasher' From 75b92a6ee38cb5cad6f2cfeab947eb6c13efa287 Mon Sep 17 00:00:00 2001 From: Eli White Date: Sun, 19 Apr 2026 18:46:40 -0700 Subject: [PATCH 2/6] Add writeIniFile() for song.ini emission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serializes IniMetadata (Partial + extraIniFields) back to song.ini text with: - [song] header - Known fields emitted in the canonical defaultMetadata order - Undefined values skipped - Booleans as True/False (Clone Hero convention) - extraIniFields appended in insertion order - CRLF line endings Derives its field order from Object.keys(defaultMetadata) rather than hardcoding a separate FIELD_ORDER list, so new fields added to defaultMetadata automatically participate in writing. Tests exercise writeIniFile only via round-trip through parseChartAndIni, per reviewer feedback: build a metadata object, write it, re-parse, and assert on parsedChart.metadata. No assertions about the serialized ini text itself (CRLF, field order, True/False formatting, quoting) — those are implementation details. --- src/__tests__/ini-writer.test.ts | 134 +++++++++++++++++++++++++++++++ src/index.ts | 1 + src/ini/index.ts | 1 + src/ini/ini-writer.ts | 46 +++++++++++ 4 files changed, 182 insertions(+) create mode 100644 src/__tests__/ini-writer.test.ts create mode 100644 src/ini/ini-writer.ts diff --git a/src/__tests__/ini-writer.test.ts b/src/__tests__/ini-writer.test.ts new file mode 100644 index 0000000..667a932 --- /dev/null +++ b/src/__tests__/ini-writer.test.ts @@ -0,0 +1,134 @@ +/** + * Round-trip tests for writeIniFile. + * + * Exercise writeIniFile through parseChartAndIni: build an ini metadata + * object, write it, pair with a minimal notes.chart, re-parse, and assert + * parsedChart.metadata matches what we wrote. No assertions about the + * serialized ini text itself — the parser is the source of truth for + * observable behavior. + */ + +import { describe, expect, it } from 'vitest' + +import { parseChartAndIni } from '../chart/parse-chart-and-ini' +import { defaultMetadata } from '../ini/ini-scanner' +import { writeIniFile } from '../ini/ini-writer' + +/** Minimal valid notes.chart bytes — enough to parse, empty of content. */ +const EMPTY_CHART = new TextEncoder().encode( + [ + '[Song]', + '{', + ' Resolution = 480', + '}', + '[SyncTrack]', + '{', + ' 0 = TS 4', + ' 0 = B 120000', + '}', + '[Events]', + '{', + '}', + ].join('\r\n'), +) + +type IniInput = Parameters[0] + +function roundTrip(metadata: IniInput) { + const iniText = writeIniFile(metadata) + const result = parseChartAndIni([ + { fileName: 'notes.chart', data: EMPTY_CHART }, + { fileName: 'song.ini', data: new TextEncoder().encode(iniText) }, + ]) + if (!result.parsedChart) throw new Error('round-trip produced no parsedChart') + return result.parsedChart.metadata +} + +describe('writeIniFile round-trip', () => { + it('empty metadata survives a round trip', () => { + const out = roundTrip({}) + expect(out.extraIniFields).toBeUndefined() + }) + + it('preserves string fields', () => { + const input = { + name: 'Round Trip', + artist: 'Tester', + album: 'Test Album', + genre: 'Rock', + year: '2023', + charter: 'Me', + } + const out = roundTrip(input) + for (const key of Object.keys(input) as (keyof typeof input)[]) { + expect(out[key]).toBe(input[key]) + } + }) + + it('preserves boolean fields with correct type', () => { + const input = { pro_drums: true, five_lane_drums: false, modchart: false, end_events: true } + const out = roundTrip(input) + expect(out.pro_drums).toBe(true) + expect(out.five_lane_drums).toBe(false) + expect(out.modchart).toBe(false) + expect(out.end_events).toBe(true) + }) + + it('preserves numeric fields', () => { + const input = { diff_drums: 5, diff_guitar: 3, delay: -250, song_length: 180000, hopo_frequency: 170 } + const out = roundTrip(input) + expect(out.diff_drums).toBe(5) + expect(out.diff_guitar).toBe(3) + expect(out.delay).toBe(-250) + expect(out.song_length).toBe(180000) + expect(out.hopo_frequency).toBe(170) + }) + + it('undefined fields are filled in from defaultMetadata on re-parse', () => { + // writeIniFile skips undefined fields; the re-parser then fills them in + // from defaultMetadata. Verify set fields survive and unset fields land + // on their documented defaults. + const out = roundTrip({ name: 'Song', artist: undefined, album: 'Album' }) + expect(out.name).toBe('Song') + expect(out.album).toBe('Album') + expect(out.artist).toBe(defaultMetadata.artist) + }) + + it('preserves extraIniFields for unknown ini keys', () => { + const out = roundTrip({ + name: 'N', + extraIniFields: { rating: '1', vocal_gender: 'male' }, + }) + expect(out.name).toBe('N') + expect(out.extraIniFields).toEqual({ rating: '1', vocal_gender: 'male' }) + }) + + it('round-trips a full metadata set with mixed types', () => { + const input = { + 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 = roundTrip(input) + for (const key of Object.keys(input) as (keyof typeof input)[]) { + if (key === 'extraIniFields') continue + expect(out[key as keyof typeof defaultMetadata]).toEqual(input[key]) + } + expect(out.extraIniFields).toEqual(input.extraIniFields) + }) +}) diff --git a/src/index.ts b/src/index.ts index 52adc3e..c10c441 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export { parseChartFile } from './chart/notes-parser' export { parseChartAndIni, createEmptyChart } from './chart' export type { ParsedChart, ParseChartAndIniResult } from './chart' export { scanIni } from './ini' +export type { IniMetadata } from './ini' export { calculateTrackHash } from './chart/track-hasher' /** 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) +} From 78bd890fc5d05aec10f8895d04c796fcb4f8b5ec Mon Sep 17 00:00:00 2001 From: Eli White Date: Sun, 19 Apr 2026 18:50:44 -0700 Subject: [PATCH 3/6] Add writeChartFile() with Song/SyncTrack/Events/unrecognized-section emission Port of the non-instrument-track half of the chart writer into scan-chart: - [Song] emits the subset of metadata the chart body supports (Name, Artist, Charter, Album, Genre, Year, Resolution, Offset, PreviewStart, Difficulty). Other fields live exclusively in song.ini. - [SyncTrack] emits tempos as `B millibeats` and time signatures as `TS numerator [denominator-exponent]`, sorted by tick with TS before B at the same tick. - [Events] emits section markers (wrapped as [section name] to survive the parser's greedy trailing-\] regex), end events, unrecognized global events (with bracket-stripping when source is .mid so round-trip to .chart emits naked text), and vocal phrase_start/phrase_end/lyric events from the normalized vocalTracks.parts.vocals. - Unrecognized chart sections are re-emitted verbatim. Instrument tracks ([ExpertSingle] etc.) land in a follow-up PR. --- src/__tests__/chart-writer.test.ts | 191 ++++++++++++++++++++++++ src/chart/chart-writer.ts | 224 +++++++++++++++++++++++++++++ src/chart/index.ts | 1 + 3 files changed, 416 insertions(+) create mode 100644 src/__tests__/chart-writer.test.ts create mode 100644 src/chart/chart-writer.ts diff --git a/src/__tests__/chart-writer.test.ts b/src/__tests__/chart-writer.test.ts new file mode 100644 index 0000000..9afa46b --- /dev/null +++ b/src/__tests__/chart-writer.test.ts @@ -0,0 +1,191 @@ +/** + * Round-trip tests for writeChartFile: Song / SyncTrack / Events / unrecognized + * sections. Instrument-track tests land with the follow-up PR that ports + * serializeTrackSection. + * + * All tests exercise the writer only through parseChartAndIni: build a + * ParsedChart, write it out, re-parse, and assert on the resulting + * ParsedChart. No assertions about the serialized .chart text (CRLF, + * quoting, field order, section ordering) — the parser is the source of + * truth for observable behavior. + */ + +import { describe, expect, it } from 'vitest' + +import { writeChartFile } from '../chart/chart-writer' +import { createEmptyChart } from '../chart/create-chart' +import { parseChartAndIni, type ParsedChart } from '../chart/parse-chart-and-ini' + +function roundTrip(chart: ParsedChart): ParsedChart { + const bytes = new TextEncoder().encode(writeChartFile(chart)) + const result = parseChartAndIni([{ fileName: 'notes.chart', data: bytes }]) + if (!result.parsedChart) { + throw new Error(`round-trip produced no parsedChart: ${JSON.stringify(result.chartFolderIssues)}`) + } + return result.parsedChart +} + +describe('writeChartFile round-trip: [Song] metadata', () => { + it('preserves chart resolution', () => { + const re = roundTrip(createEmptyChart({ resolution: 192 })) + expect(re.resolution).toBe(192) + }) + + it('preserves string metadata fields', () => { + const chart = createEmptyChart() + chart.metadata.name = 'My Song' + chart.metadata.artist = 'Some Band' + chart.metadata.album = 'Greatest Hits' + chart.metadata.charter = 'Me' + chart.metadata.genre = 'Rock' + chart.metadata.year = '2024' + const re = roundTrip(chart) + expect(re.metadata).toMatchObject({ + name: 'My Song', + artist: 'Some Band', + album: 'Greatest Hits', + charter: 'Me', + genre: 'Rock', + year: '2024', + }) + }) + + it('preserves chart_offset', () => { + const chart = createEmptyChart() + chart.metadata.chart_offset = 250 + expect(roundTrip(chart).metadata.chart_offset).toBe(250) + }) + + it('preserves preview_start_time', () => { + const chart = createEmptyChart() + chart.metadata.preview_start_time = 30000 + expect(roundTrip(chart).metadata.preview_start_time).toBe(30000) + }) + + it('preserves diff_* difficulty fields', () => { + const chart = createEmptyChart() + chart.metadata.diff_guitar = 5 + expect(roundTrip(chart).metadata.diff_guitar).toBe(5) + }) + + it('does not leak ini `delay` into chart_offset', () => { + // delay is ini-only; writing a chart with no chart_offset and a `delay` + // value must not surface as chart_offset after round-trip. + const chart = createEmptyChart() + chart.metadata.delay = 999 + expect(roundTrip(chart).metadata.chart_offset).toBeUndefined() + }) + + it('does not emit a chart_offset for the value 0', () => { + // 0 is the default in-game behavior; the writer skips it so we don't + // round-trip 0 as a meaningful Offset. + const chart = createEmptyChart() + chart.metadata.chart_offset = 0 + expect(roundTrip(chart).metadata.chart_offset).toBeUndefined() + }) +}) + +describe('writeChartFile round-trip: [SyncTrack]', () => { + it('preserves the default tempo and time signature on an empty chart', () => { + const re = roundTrip(createEmptyChart()) + expect(re.tempos.map(t => ({ tick: t.tick, bpm: t.beatsPerMinute }))).toEqual([{ tick: 0, bpm: 120 }]) + expect(re.timeSignatures.map(ts => ({ tick: ts.tick, n: ts.numerator, d: ts.denominator }))).toEqual([ + { tick: 0, n: 4, d: 4 }, + ]) + }) + + it('preserves non-4/4 time signatures', () => { + const chart = createEmptyChart({ timeSignature: { numerator: 6, denominator: 8 } }) + expect(roundTrip(chart).timeSignatures[0]).toMatchObject({ numerator: 6, denominator: 8 }) + }) + + it('preserves multiple tempo changes', () => { + const chart = createEmptyChart({ bpm: 140 }) + chart.tempos.push({ tick: 1920, beatsPerMinute: 200, msTime: 0 }) + const re = roundTrip(chart) + expect(re.tempos.map(t => ({ tick: t.tick, bpm: t.beatsPerMinute }))).toEqual([ + { tick: 0, bpm: 140 }, + { tick: 1920, bpm: 200 }, + ]) + }) + + it('preserves multiple time-signature changes', () => { + const chart = createEmptyChart() + chart.timeSignatures.push({ tick: 3840, numerator: 7, denominator: 8, msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + expect(re.timeSignatures.map(ts => ({ t: ts.tick, n: ts.numerator, d: ts.denominator }))).toEqual([ + { t: 0, n: 4, d: 4 }, + { t: 3840, n: 7, d: 8 }, + ]) + }) + + it('preserves fractional BPM', () => { + const chart = createEmptyChart({ bpm: 137.5 }) + expect(roundTrip(chart).tempos[0].beatsPerMinute).toBe(137.5) + }) + + it('preserves tempo + TS events that share a 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 re = roundTrip(chart) + expect(re.tempos.find(t => t.tick === 960)?.beatsPerMinute).toBe(150) + expect(re.timeSignatures.find(ts => ts.tick === 960)).toMatchObject({ numerator: 3, denominator: 4 }) + }) +}) + +describe('writeChartFile round-trip: [Events]', () => { + it('preserves sections at the right ticks with correct names', () => { + const chart = createEmptyChart() + chart.sections.push({ tick: 0, name: 'Intro', msTime: 0, msLength: 0 }) + chart.sections.push({ tick: 1920, name: 'Verse 1', msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + expect(re.sections.map(s => ({ tick: s.tick, name: s.name }))).toEqual([ + { tick: 0, name: 'Intro' }, + { tick: 1920, name: 'Verse 1' }, + ]) + }) + + it('preserves section names with special characters', () => { + const chart = createEmptyChart() + chart.sections.push({ tick: 0, name: '[BREAKDOWN]', msTime: 0, msLength: 0 }) + expect(roundTrip(chart).sections[0].name).toBe('[BREAKDOWN]') + }) + + it('preserves end events', () => { + const chart = createEmptyChart() + chart.endEvents.push({ tick: 9600, msTime: 0, msLength: 0 }) + expect(roundTrip(chart).endEvents.map(e => e.tick)).toEqual([9600]) + }) + + it('preserves unrecognized global events', () => { + const chart = createEmptyChart({ format: 'chart' }) + chart.unrecognizedEventsTrackTextEvents.push({ tick: 0, text: 'music_start', msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + expect(re.unrecognizedEventsTrackTextEvents.map(e => ({ tick: e.tick, text: e.text }))).toEqual([ + { tick: 0, text: 'music_start' }, + ]) + }) + + it('does not duplicate an end event that also appears in unrecognizedEventsTrackTextEvents', () => { + const chart = createEmptyChart() + chart.endEvents.push({ tick: 1000, msTime: 0, msLength: 0 }) + chart.unrecognizedEventsTrackTextEvents.push({ tick: 1000, text: 'end', msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + expect(re.endEvents.map(e => e.tick)).toEqual([1000]) + expect(re.unrecognizedEventsTrackTextEvents.filter(e => e.text === 'end')).toHaveLength(0) + }) +}) + +describe('writeChartFile round-trip: unrecognized chart sections', () => { + it('preserves unrecognized sections with arbitrary content', () => { + const chart = createEmptyChart() + chart.unrecognizedChartSections.push({ + name: 'MysteryBlock', + lines: ['0 = foo', '100 = bar'], + }) + expect(roundTrip(chart).unrecognizedChartSections).toEqual([ + { name: 'MysteryBlock', lines: ['0 = foo', '100 = bar'] }, + ]) + }) +}) diff --git a/src/chart/chart-writer.ts b/src/chart/chart-writer.ts new file mode 100644 index 0000000..6518d85 --- /dev/null +++ b/src/chart/chart-writer.ts @@ -0,0 +1,224 @@ +/** + * `.chart` file writer — serializes a ParsedChart back to chart text. + * + * This PR covers the non-instrument-track half of the writer: + * - `[Song]` section + * - `[SyncTrack]` section (tempo + time-signature events) + * - `[Events]` section (sections, endEvents, unrecognized global events, + * vocal phrase/lyric events) + * - Pass-through of unrecognizedChartSections + * + * Instrument track emission (`[ExpertSingle]` etc.) lands in a follow-up PR. + */ + +import type { ParsedChart } from './parse-chart-and-ini' + +/** + * Serialize a {@link ParsedChart} to `.chart` file text (CRLF line endings). + * Emits `[Song]`, `[SyncTrack]`, and `[Events]` sections plus any chart sections + * the parser didn't recognize (preserved verbatim for round-trip). + * + * Note: instrument track sections (`[ExpertSingle]`, `[HardDrums]`, etc.) are + * emitted by a follow-up PR. This entry point currently skips them. + */ +export function writeChartFile(chart: ParsedChart): string { + const sections: string[][] = [] + sections.push(serializeSongSection(chart)) + sections.push(serializeSyncTrack(chart)) + sections.push(serializeEventsSection(chart)) + + // Re-emit any [Section] blocks the parser didn't recognize as standard + // (Song/SyncTrack/Events) or as a track section. Stored verbatim by + // scan-chart's unrecognizedChartSections fallback for round-trip preservation. + 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) + } + + // Flatten without using spread (which would exceed stack size on large arrays). + 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, these 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' + for (const ge of chart.unrecognizedEventsTrackTextEvents) { + 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 }) + } + + // 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 +} diff --git a/src/chart/index.ts b/src/chart/index.ts index 23e5c25..4661673 100644 --- a/src/chart/index.ts +++ b/src/chart/index.ts @@ -1,3 +1,4 @@ export * from './chart-scanner' +export * from './chart-writer' export * from './create-chart' export * from './parse-chart-and-ini' From 914ea70f2abaa4582cb29937acc549ba1e87c19a Mon Sep 17 00:00:00 2001 From: Eli White Date: Sun, 19 Apr 2026 18:56:22 -0700 Subject: [PATCH 4/6] writeChartFile: emit instrument track sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes writeChartFile with per-track section emission: - [ExpertSingle], [HardDrums], etc. named from instrument + difficulty. - Drum tracks: base notes (N 0..4 or 0..5 for 5-lane), cymbal markers (N 66/67/68) only in fourLanePro, accent (N 34..38) and ghost (N 40..44) markers matching the emitted note's eventType, one N 109 flam per group, double kick as N 32 only. - 5-lane round-trip: greenDrum+cymbal emits as N 4 (orange) while plain greenDrum stays at N 5; blueDrums coinciding with greenDrum+cymbal emit as N 5 to keep the parser's drumType detection on fiveLane. - Fret/GHL tracks: notes via the 5-fret and 6-fret maps, tap as N 6, forceUnnatural (N 5) when the note's hopo/strum flag disagrees with the natural-HOPO state. - Star power S 2, activation lanes S 64, flex lanes S 65/66, versus phrases S 0/1, solo sections as E 'solo'/'soloend' (tick + length - 1 for soloend to round-trip). - Per-track text events (including disco flip mix markers). - Disco-flip state transitions per difficulty from red/yellow drum disco/discoNoflip flags → 'mix drums0[d|dnoflip]' text events. - Coda events: if no 'coda' in unrecognizedEvents, synthesize from drumFreestyleSections where isCoda=true. - hasForcedNotes backstop: if hasForcedNotes is set but no forceUnnatural emitted, append N 5 0 at a vacant tick in the first fret track to preserve the flag on round-trip. --- src/__tests__/chart-writer.test.ts | 217 +++++++++++++++++-- src/chart/chart-writer.ts | 336 +++++++++++++++++++++++++++-- 2 files changed, 524 insertions(+), 29 deletions(-) diff --git a/src/__tests__/chart-writer.test.ts b/src/__tests__/chart-writer.test.ts index 9afa46b..17692c0 100644 --- a/src/__tests__/chart-writer.test.ts +++ b/src/__tests__/chart-writer.test.ts @@ -1,30 +1,89 @@ /** - * Round-trip tests for writeChartFile: Song / SyncTrack / Events / unrecognized - * sections. Instrument-track tests land with the follow-up PR that ports - * serializeTrackSection. + * Round-trip tests for writeChartFile. * * All tests exercise the writer only through parseChartAndIni: build a * ParsedChart, write it out, re-parse, and assert on the resulting * ParsedChart. No assertions about the serialized .chart text (CRLF, - * quoting, field order, section ordering) — the parser is the source of - * truth for observable behavior. + * quoting, field order, section ordering, specific N numbers, etc.) — + * the parser is the source of truth for observable behavior. */ import { describe, expect, it } from 'vitest' import { writeChartFile } from '../chart/chart-writer' import { createEmptyChart } from '../chart/create-chart' +import { noteFlags, noteTypes, NoteEvent } from '../chart/note-parsing-interfaces' import { parseChartAndIni, type ParsedChart } from '../chart/parse-chart-and-ini' -function roundTrip(chart: ParsedChart): ParsedChart { - const bytes = new TextEncoder().encode(writeChartFile(chart)) - const result = parseChartAndIni([{ fileName: 'notes.chart', data: bytes }]) +function roundTrip(chart: ParsedChart, iniText?: string): ParsedChart { + const files: { fileName: string; data: Uint8Array }[] = [ + { fileName: 'notes.chart', data: new TextEncoder().encode(writeChartFile(chart)) }, + ] + if (iniText !== undefined) { + files.push({ fileName: 'song.ini', data: new TextEncoder().encode(iniText) }) + } + const result = parseChartAndIni(files) if (!result.parsedChart) { throw new Error(`round-trip produced no parsedChart: ${JSON.stringify(result.chartFolderIssues)}`) } return result.parsedChart } +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 } +} + +/** Flatten a track's noteEventGroups into `{ tick, type, flags, length }` tuples for easy comparison. */ +function flatNotes(track: ParsedChart['trackData'][number]) { + return track.noteEventGroups.flatMap(g => + g.map(n => ({ tick: n.tick, type: n.type, flags: n.flags, length: n.length })), + ) +} + +function findTrack(chart: ParsedChart, instrument: ParsedChart['trackData'][number]['instrument'], difficulty = 'expert') { + const t = chart.trackData.find(t => t.instrument === instrument && t.difficulty === difficulty) + if (!t) throw new Error(`no ${difficulty} ${instrument} track in round-tripped chart`) + return t +} + describe('writeChartFile round-trip: [Song] metadata', () => { it('preserves chart resolution', () => { const re = roundTrip(createEmptyChart({ resolution: 192 })) @@ -69,16 +128,12 @@ describe('writeChartFile round-trip: [Song] metadata', () => { }) it('does not leak ini `delay` into chart_offset', () => { - // delay is ini-only; writing a chart with no chart_offset and a `delay` - // value must not surface as chart_offset after round-trip. const chart = createEmptyChart() chart.metadata.delay = 999 expect(roundTrip(chart).metadata.chart_offset).toBeUndefined() }) it('does not emit a chart_offset for the value 0', () => { - // 0 is the default in-game behavior; the writer skips it so we don't - // round-trip 0 as a meaningful Offset. const chart = createEmptyChart() chart.metadata.chart_offset = 0 expect(roundTrip(chart).metadata.chart_offset).toBeUndefined() @@ -189,3 +244,141 @@ describe('writeChartFile round-trip: unrecognized chart sections', () => { ]) }) }) + +// --------------------------------------------------------------------------- +// Track section round-trip tests +// --------------------------------------------------------------------------- + +describe('writeChartFile round-trip: drum tracks', () => { + it('preserves a per-difficulty drum track (expert)', () => { + const chart = createEmptyChart() + const track = addDrumTrack(chart, 'expert') + track.noteEventGroups.push([note(480, noteTypes.redDrum)]) + const re = roundTrip(chart) + expect(findTrack(re, 'drums', 'expert').noteEventGroups).toHaveLength(1) + }) + + it('preserves base 4-lane drum notes with ticks and lengths', () => { + 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 re = roundTrip(chart) + const notes = flatNotes(findTrack(re, 'drums')) + expect(notes).toEqual([ + expect.objectContaining({ tick: 0, type: noteTypes.kick, length: 0 }), + expect.objectContaining({ tick: 480, type: noteTypes.redDrum, length: 240 }), + expect.objectContaining({ tick: 960, type: noteTypes.yellowDrum, length: 0 }), + expect.objectContaining({ tick: 1440, type: noteTypes.blueDrum, length: 0 }), + expect.objectContaining({ tick: 1920, type: noteTypes.greenDrum, length: 0 }), + ]) + }) + + it('preserves double-kick (not as a regular kick)', () => { + const chart = createEmptyChart() + const track = addDrumTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.kick, noteFlags.doubleKick)]) + const re = roundTrip(chart) + const notes = flatNotes(findTrack(re, 'drums')) + expect(notes).toHaveLength(1) + expect(notes[0].flags & noteFlags.doubleKick).toBeTruthy() + }) + + it('preserves cymbal/accent/ghost flags in fourLanePro', () => { + const chart = createEmptyChart() + chart.drumType = 1 + const track = addDrumTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.redDrum, noteFlags.accent)]) + track.noteEventGroups.push([note(480, noteTypes.yellowDrum, noteFlags.cymbal)]) + track.noteEventGroups.push([note(960, noteTypes.blueDrum, noteFlags.ghost)]) + track.noteEventGroups.push([note(1440, noteTypes.greenDrum, noteFlags.cymbal)]) + const re = roundTrip(chart, '[Song]\npro_drums = True\n') + const notes = flatNotes(findTrack(re, 'drums')) + expect(notes[0].flags & noteFlags.accent).toBeTruthy() + expect(notes[1].flags & noteFlags.cymbal).toBeTruthy() + expect(notes[2].flags & noteFlags.ghost).toBeTruthy() + expect(notes[3].flags & noteFlags.cymbal).toBeTruthy() + }) + + // Flam (N 109) round-trip lives in the MIDI writer tests: the .chart parser + // doesn't recognize N 109, so flam doesn't survive a .chart round-trip. + + it('preserves star power, solo sections, flex lanes, and activation lanes', () => { + 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: false, msTime: 0, msLength: 0 }) + track.flexLanes.push({ tick: 1440, length: 480, isDouble: true, msTime: 0, msLength: 0 }) + track.drumFreestyleSections.push({ tick: 1920, length: 480, isCoda: false, msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + const reTrack = findTrack(re, '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: false }, + { t: 1440, l: 480, d: true }, + ]) + expect(reTrack.drumFreestyleSections.map(s => ({ t: s.tick, l: s.length }))).toEqual([{ t: 1920, l: 480 }]) + }) +}) + +describe('writeChartFile round-trip: 5-fret tracks', () => { + it('preserves base 5-fret notes on a guitar track', () => { + const chart = createEmptyChart() + const track = addFretTrack(chart, 'guitar') + 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 re = roundTrip(chart) + const notes = flatNotes(findTrack(re, 'guitar')) + expect(notes.map(n => ({ tick: n.tick, type: n.type }))).toEqual([ + { tick: 0, type: noteTypes.green }, + { tick: 100, type: noteTypes.red }, + { tick: 200, type: noteTypes.yellow }, + { tick: 300, type: noteTypes.blue }, + { tick: 400, type: noteTypes.orange }, + { tick: 500, type: noteTypes.open }, + ]) + }) + + it('preserves the tap flag', () => { + const chart = createEmptyChart() + const track = addFretTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.green, noteFlags.tap)]) + const re = roundTrip(chart) + const notes = flatNotes(findTrack(re, 'guitar')) + expect(notes[0].flags & noteFlags.tap).toBeTruthy() + }) + + it('preserves a forced-hopo flag on a note whose natural state is strum', () => { + const chart = createEmptyChart({ resolution: 480 }) + const track = addFretTrack(chart) + // Two greens far apart — neither is natural HOPO. Flag the second → round-trip keeps the HOPO flag. + track.noteEventGroups.push([note(0, noteTypes.green)]) + track.noteEventGroups.push([note(1920, noteTypes.green, noteFlags.hopo)]) + const re = roundTrip(chart) + const notes = flatNotes(findTrack(re, 'guitar')) + const hopoNote = notes.find(n => n.tick === 1920)! + expect(hopoNote.flags & noteFlags.hopo).toBeTruthy() + }) + + it('preserves star power and solo sections on a guitar track', () => { + const chart = createEmptyChart({ resolution: 480 }) + const track = addFretTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.green)]) + track.starPowerSections.push({ tick: 0, length: 960, msTime: 0, msLength: 0 }) + track.soloSections.push({ tick: 480, length: 480, msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + const reTrack = findTrack(re, 'guitar') + 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 }]) + }) +}) diff --git a/src/chart/chart-writer.ts b/src/chart/chart-writer.ts index 6518d85..f4fa682 100644 --- a/src/chart/chart-writer.ts +++ b/src/chart/chart-writer.ts @@ -1,25 +1,23 @@ /** * `.chart` file writer — serializes a ParsedChart back to chart text. * - * This PR covers the non-instrument-track half of the writer: - * - `[Song]` section - * - `[SyncTrack]` section (tempo + time-signature events) - * - `[Events]` section (sections, endEvents, unrecognized global events, - * vocal phrase/lyric events) - * - Pass-through of unrecognizedChartSections - * - * Instrument track emission (`[ExpertSingle]` etc.) lands in a follow-up PR. + * 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]`, and `[Events]` sections plus any chart sections - * the parser didn't recognize (preserved verbatim for round-trip). - * - * Note: instrument track sections (`[ExpertSingle]`, `[HardDrums]`, etc.) are - * emitted by a follow-up PR. This entry point currently skips them. + * 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[][] = [] @@ -27,9 +25,12 @@ export function writeChartFile(chart: ParsedChart): string { sections.push(serializeSyncTrack(chart)) sections.push(serializeEventsSection(chart)) - // Re-emit any [Section] blocks the parser didn't recognize as standard - // (Song/SyncTrack/Events) or as a track section. Stored verbatim by - // scan-chart's unrecognizedChartSections fallback for round-trip preservation. + 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}`) @@ -37,7 +38,6 @@ export function writeChartFile(chart: ParsedChart): string { sections.push(sec) } - // Flatten without using spread (which would exceed stack size on large arrays). const out: string[] = [] for (const section of sections) { for (const line of section) out.push(line) @@ -155,6 +155,7 @@ function serializeEventsSection(chart: ParsedChart): string[] { // .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.unrecognizedEventsTrackTextEvents) { let text = ge.text if (sourceIsMidi) { @@ -166,6 +167,19 @@ function serializeEventsSection(chart: ParsedChart): string[] { // 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 @@ -222,3 +236,291 @@ function serializeEventsSection(chart: ParsedChart): string[] { 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 +} + From 96cb353396b442215b492835e88fa1e5b61fb87e Mon Sep 17 00:00:00 2001 From: Eli White Date: Sun, 19 Apr 2026 18:59:45 -0700 Subject: [PATCH 5/6] Add writeMidiFile() with TEMPO/EVENTS/unrecognized-track emission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of the core MIDI writer infrastructure: - writeMidiFile(chart) entry point: builds a Format-1 MIDI and returns Uint8Array via midi-file's writeMidi - TEMPO TRACK: trackName + setTempo (from chart.tempos) + timeSignature (from chart.timeSignatures), all at their absolute ticks - EVENTS track: trackName + section text events (unwrapped so YARG's NormalizeTextEvent doesn't strip names containing ]) + [end] events + global events (bracket-wrap when source is .chart so MIDI output follows convention) + [coda] derived from drumFreestyleSections when not already present in unrecognizedEvents - Unrecognized MIDI tracks: verbatim pass-through with abs-tick → delta-tick conversion; duplicate track names suffixed to keep the internal trackMap unique - finalizeMidiTrack helper: sorts by tick with a type-priority tiebreaker, converts absolute ticks to delta times, appends endOfTrack Instrument tracks (PART DRUMS, PART GUITAR, …) and vocal tracks (PART VOCALS, HARM1/2/3) land in follow-up PRs. --- src/__tests__/midi-writer.test.ts | 131 +++++++++++++++ src/chart/index.ts | 1 + src/chart/midi-writer.ts | 259 ++++++++++++++++++++++++++++++ 3 files changed, 391 insertions(+) create mode 100644 src/__tests__/midi-writer.test.ts create mode 100644 src/chart/midi-writer.ts diff --git a/src/__tests__/midi-writer.test.ts b/src/__tests__/midi-writer.test.ts new file mode 100644 index 0000000..c8d3af1 --- /dev/null +++ b/src/__tests__/midi-writer.test.ts @@ -0,0 +1,131 @@ +/** + * Round-trip tests for writeMidiFile. + * + * All tests exercise the writer only through parseChartAndIni: build a + * ParsedChart, write it out as MIDI, re-parse, and assert on the resulting + * ParsedChart. No assertions about the raw MIDI structure (track count, + * track names, note numbers, setTempo microseconds, text-event brackets, + * etc.) — the parser is the source of truth for observable behavior. + */ + +import type { MidiEvent } from '@geomitron/midi-file' +import { describe, expect, it } from 'vitest' + +import { createEmptyChart } from '../chart/create-chart' +import { writeMidiFile } from '../chart/midi-writer' +import { parseChartAndIni, type ParsedChart } from '../chart/parse-chart-and-ini' + +function roundTrip(chart: ParsedChart, iniText?: string): ParsedChart { + const files: { fileName: string; data: Uint8Array }[] = [ + { fileName: 'notes.mid', data: writeMidiFile(chart) }, + ] + if (iniText !== undefined) { + files.push({ fileName: 'song.ini', data: new TextEncoder().encode(iniText) }) + } + const result = parseChartAndIni(files) + if (!result.parsedChart) { + throw new Error(`round-trip produced no parsedChart: ${JSON.stringify(result.chartFolderIssues)}`) + } + return result.parsedChart +} + +describe('writeMidiFile round-trip: resolution + [SyncTrack]', () => { + it('preserves chart resolution', () => { + const re = roundTrip(createEmptyChart({ resolution: 192 })) + expect(re.resolution).toBe(192) + }) + + it('preserves the default tempo on an empty chart', () => { + const re = roundTrip(createEmptyChart({ bpm: 120 })) + expect(re.tempos.map(t => ({ tick: t.tick, bpm: Math.round(t.beatsPerMinute) }))).toEqual([{ tick: 0, bpm: 120 }]) + }) + + it('preserves fractional BPM within microsecondsPerBeat rounding', () => { + const re = roundTrip(createEmptyChart({ bpm: 137.5 })) + // setTempo is stored as integer microseconds/beat, so fractional BPM + // round-trips with tiny quantization — check within 0.01 BPM. + expect(re.tempos[0].beatsPerMinute).toBeCloseTo(137.5, 1) + }) + + it('preserves multiple tempo changes at their ticks', () => { + const chart = createEmptyChart({ resolution: 480, bpm: 120 }) + chart.tempos.push({ tick: 960, beatsPerMinute: 180, msTime: 0 }) + const re = roundTrip(chart) + expect(re.tempos.map(t => ({ tick: t.tick, bpm: Math.round(t.beatsPerMinute) }))).toEqual([ + { tick: 0, bpm: 120 }, + { tick: 960, bpm: 180 }, + ]) + }) + + it('preserves non-4/4 time signatures', () => { + const chart = createEmptyChart({ timeSignature: { numerator: 6, denominator: 8 } }) + expect(roundTrip(chart).timeSignatures[0]).toMatchObject({ numerator: 6, denominator: 8 }) + }) + + it('preserves multiple time-signature changes', () => { + const chart = createEmptyChart({ timeSignature: { numerator: 7, denominator: 8 } }) + chart.timeSignatures.push({ tick: 3840, numerator: 3, denominator: 4, msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + expect(re.timeSignatures.map(ts => ({ t: ts.tick, n: ts.numerator, d: ts.denominator }))).toEqual([ + { t: 0, n: 7, d: 8 }, + { t: 3840, n: 3, d: 4 }, + ]) + }) +}) + +describe('writeMidiFile round-trip: EVENTS track', () => { + it('preserves sections at the right ticks with correct names', () => { + const chart = createEmptyChart() + chart.sections.push({ tick: 0, name: 'Intro', msTime: 0, msLength: 0 }) + chart.sections.push({ tick: 1920, name: 'Verse 1', msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + expect(re.sections.map(s => ({ tick: s.tick, name: s.name }))).toEqual([ + { tick: 0, name: 'Intro' }, + { tick: 1920, name: 'Verse 1' }, + ]) + }) + + it('preserves end events', () => { + const chart = createEmptyChart() + chart.endEvents.push({ tick: 1920, msTime: 0, msLength: 0 }) + expect(roundTrip(chart).endEvents.map(e => e.tick)).toEqual([1920]) + }) + + it('preserves unrecognized EVENTS events on .mid source', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.unrecognizedEventsTrackTextEvents.push({ tick: 480, text: 'crowd_noclap', msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + expect(re.unrecognizedEventsTrackTextEvents.map(e => ({ tick: e.tick, text: e.text }))).toEqual([ + { tick: 480, text: 'crowd_noclap' }, + ]) + }) +}) + +describe('writeMidiFile round-trip: unrecognized MIDI tracks', () => { + it('preserves an unrecognized track by name', () => { + const chart = createEmptyChart() + chart.unrecognizedMidiTracks.push({ + trackName: 'CUSTOM', + events: [ + { deltaTime: 0, meta: true, type: 'trackName', text: 'CUSTOM' } as MidiEvent, + { deltaTime: 240, meta: true, type: 'text', text: 'hello' } as MidiEvent, + { deltaTime: 240, meta: true, type: 'endOfTrack' } as MidiEvent, + ], + }) + expect(roundTrip(chart).unrecognizedMidiTracks.map(t => t.trackName)).toEqual(['CUSTOM']) + }) + + it('preserves multiple unrecognized tracks with the same name', () => { + const chart = createEmptyChart() + for (let i = 0; i < 3; i++) { + chart.unrecognizedMidiTracks.push({ + trackName: 'CUSTOM', + events: [ + { deltaTime: 0, meta: true, type: 'trackName', text: 'CUSTOM' } as MidiEvent, + { deltaTime: 0, meta: true, type: 'endOfTrack' } as MidiEvent, + ], + }) + } + expect(roundTrip(chart).unrecognizedMidiTracks).toHaveLength(3) + }) +}) diff --git a/src/chart/index.ts b/src/chart/index.ts index 4661673..f7761fe 100644 --- a/src/chart/index.ts +++ b/src/chart/index.ts @@ -1,4 +1,5 @@ export * from './chart-scanner' export * from './chart-writer' export * from './create-chart' +export * from './midi-writer' export * from './parse-chart-and-ini' diff --git a/src/chart/midi-writer.ts b/src/chart/midi-writer.ts new file mode 100644 index 0000000..265f599 --- /dev/null +++ b/src/chart/midi-writer.ts @@ -0,0 +1,259 @@ +/** + * MIDI binary writer — serializes a ParsedChart back to a Format-1 `.mid` file. + * + * This PR establishes the writer infrastructure: + * - `writeMidiFile` entry point + * - TEMPO TRACK (tempo + time-signature meta events) + * - EVENTS track (sections + end events + unrecognized global events + coda) + * - Unrecognized MIDI tracks (verbatim pass-through) + * - `finalizeMidiTrack` shared helper (sort + absolute→delta time conversion) + * + * Instrument tracks (PART DRUMS, PART GUITAR, etc.) and vocal tracks + * (PART VOCALS, HARM1/2/3) are emitted by follow-up PRs. + */ + +import type { MidiData, MidiEvent } from '@geomitron/midi-file' +import { writeMidi } from '@geomitron/midi-file' + +import type { ParsedChart } from './parse-chart-and-ini' + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +/** A MIDI event tagged with its absolute tick (for sort-then-delta finalization). */ +export interface AbsoluteEvent { + tick: number + event: MidiEvent + /** Stable sort tiebreaker — preserves source ordering within the same tick. */ + seq?: number +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Serialize a {@link ParsedChart} to `.mid` bytes. + * + * Output track layout: + * 0 — TEMPO TRACK (BPM + time signatures) + * 1 — EVENTS (sections, end events, global events, coda) + * N — Unrecognized MIDI tracks (verbatim pass-through) + * + * Instrument tracks (PART DRUMS, PART GUITAR, …) and vocal tracks + * (PART VOCALS, HARM1/2/3) are emitted by follow-up PRs — this entry point + * currently skips `chart.trackData` and `chart.vocalTracks`. + */ +export function writeMidiFile(chart: ParsedChart): Uint8Array { + const trackMap = new Map() + + trackMap.set('TEMPO TRACK', buildTempoTrack(chart)) + trackMap.set('EVENTS', buildEventsTrack(chart)) + + // Unrecognized whole tracks (VENUE, BEAT, PART REAL_*, custom tracks) are + // round-tripped verbatim. + let dupSuffix = 0 + for (const ut of chart.unrecognizedMidiTracks) { + let mapKey = ut.trackName + while (trackMap.has(mapKey)) mapKey = `${ut.trackName}__dup${dupSuffix++}` + trackMap.set(mapKey, buildUnrecognizedTrack(ut.events)) + } + + const tracks = [...trackMap.values()] + const midiData: MidiData = { + header: { + format: 1, + numTracks: tracks.length, + ticksPerBeat: chart.resolution, + }, + tracks, + } + return new Uint8Array(writeMidi(midiData)) +} + +// --------------------------------------------------------------------------- +// Track builders +// --------------------------------------------------------------------------- + +function buildTempoTrack(chart: ParsedChart): MidiEvent[] { + const events: AbsoluteEvent[] = [] + + events.push({ + tick: 0, + event: { deltaTime: 0, meta: true, type: 'trackName', text: 'TEMPO TRACK' } as MidiEvent, + }) + + for (const tempo of chart.tempos) { + events.push({ + tick: tempo.tick, + event: { + deltaTime: 0, + meta: true, + type: 'setTempo', + microsecondsPerBeat: Math.round(60_000_000 / tempo.beatsPerMinute), + } as MidiEvent, + }) + } + + for (const ts of chart.timeSignatures) { + events.push({ + tick: ts.tick, + event: { + deltaTime: 0, + meta: true, + type: 'timeSignature', + numerator: ts.numerator, + denominator: ts.denominator, + metronome: 24, + thirtyseconds: 8, + } as MidiEvent, + }) + } + + return finalizeMidiTrack(events) +} + +function buildEventsTrack(chart: ParsedChart): MidiEvent[] { + const events: AbsoluteEvent[] = [] + + events.push({ + tick: 0, + event: { deltaTime: 0, meta: true, type: 'trackName', text: 'EVENTS' } as MidiEvent, + }) + + // Sections emit UNWRAPPED as `section name` (not `[section name]`). YARG's + // NormalizeTextEvent strips content between the first `[` and first `]`, + // which would lose data for names that contain `]`. Unwrapped form preserves + // names with `]` and names starting with `[`. The only case that's inherently + // lossy under YARG normalization is names containing both `[` and `]` — + // those can't round-trip regardless of wrapping. + for (const section of chart.sections) { + events.push({ + tick: section.tick, + event: { deltaTime: 0, meta: true, type: 'text', text: `section ${section.name}` } as MidiEvent, + }) + } + + for (const endEvent of chart.endEvents) { + events.push({ + tick: endEvent.tick, + event: { deltaTime: 0, meta: true, type: 'text', text: '[end]' } as MidiEvent, + }) + } + + // Global events (crowd events, music_start/end, coda, etc.). `.chart` + // stores them unwrapped; `.mid` stores them bracket-wrapped. When the + // source was `.chart`, wrap on output so the MIDI output follows convention. + const sourceIsMidi = chart.format === 'mid' + for (const ge of chart.unrecognizedEventsTrackTextEvents) { + let text = ge.text + if (!sourceIsMidi) { + const trimmed = text.trimEnd() + if (!(trimmed.startsWith('[') && trimmed.endsWith(']'))) { + text = `[${text}]` + } + } + events.push({ + tick: ge.tick, + event: { deltaTime: 0, meta: true, type: 'text', text } as MidiEvent, + }) + } + + // Coda events: derive from drumFreestyleSections only if none already in + // unrecognizedEventsTrackTextEvents. The parser splits [coda] into both + // places, but we only need one. + const hasCodaInGlobalEvents = chart.unrecognizedEventsTrackTextEvents.some(ge => { + const trimmed = ge.text.trim() + return trimmed === '[coda]' || trimmed === 'coda' + }) + if (!hasCodaInGlobalEvents) { + const codaTicks = new Set() + for (const track of chart.trackData) { + for (const fs of track.drumFreestyleSections) { + if (fs.isCoda) codaTicks.add(fs.tick) + } + } + for (const tick of codaTicks) { + events.push({ + tick, + event: { deltaTime: 0, meta: true, type: 'text', text: '[coda]' } as MidiEvent, + }) + } + } + + return finalizeMidiTrack(events) +} + +/** + * Re-emit a parsed unrecognized track verbatim. + * + * Events arrive with `deltaTime = absolute tick` (scan-chart's + * `convertToAbsoluteTime` post-processing). midi-file's writer expects delta + * timing, so convert back here. + * + * If the input MIDI was malformed (non-monotonic absolute ticks → negative + * deltas), midi-file's writeVarInt will throw. We let that bubble up so the + * caller can record it as a per-chart failure rather than silently reorder + * events to "fix" the malformed source. + */ +function buildUnrecognizedTrack(events: MidiEvent[]): MidiEvent[] { + let prevTick = 0 + const out: MidiEvent[] = [] + for (const e of events) { + const absTick = e.deltaTime + out.push({ ...e, deltaTime: absTick - prevTick }) + prevTick = absTick + } + return out +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/** + * Sort events by absolute tick (with a type-priority tiebreaker) and convert + * to delta-time encoding. Appends an `endOfTrack` meta event. + * + * Sort priority at the same tick: trackName → timeSignature → setTempo → + * noteOff → sysEx → noteOn → text/lyrics → other → endOfTrack. This matches + * Clone Hero's expected event ordering. Events with an explicit `seq` tag + * sort AFTER untagged events at the same tick (so instrument-track emitters + * can sequence paired events deterministically via `seq`). + */ +export function finalizeMidiTrack(events: AbsoluteEvent[]): MidiEvent[] { + const eventPriority = (e: MidiEvent): number => { + switch (e.type) { + case 'trackName': return 0 + case 'timeSignature': return 1 + case 'setTempo': return 2 + case 'noteOff': return 3 + case 'sysEx': case 'endSysEx': return 4 + case 'noteOn': return 5 + case 'text': case 'lyrics': return 6 + case 'endOfTrack': return 8 + default: return 7 + } + } + events.sort((a, b) => { + if (a.tick !== b.tick) return a.tick - b.tick + const aHasSeq = a.seq !== undefined + const bHasSeq = b.seq !== undefined + if (!aHasSeq && !bHasSeq) return eventPriority(a.event) - eventPriority(b.event) + if (!aHasSeq) return -1 + if (!bHasSeq) return 1 + return (a.seq as number) - (b.seq as number) + }) + + let prevTick = 0 + const midiEvents: MidiEvent[] = [] + for (const { tick, event } of events) { + event.deltaTime = tick - prevTick + prevTick = tick + midiEvents.push(event) + } + + midiEvents.push({ deltaTime: 0, meta: true, type: 'endOfTrack' } as MidiEvent) + return midiEvents +} From fac4d59194eb93c11a86de21cc18b1c1400b3b94 Mon Sep 17 00:00:00 2001 From: Eli White Date: Sun, 19 Apr 2026 21:18:40 -0700 Subject: [PATCH 6/6] writeMidiFile: emit PART DRUMS instrument tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds drum emission to writeMidiFile: - buildDrumTrack: groups one or more ParsedTrackData entries (one per difficulty) into a single PART DRUMS MIDI track. Emits trackName, delegates per-difficulty notes to emitDrumNotes, collects and dedupes instrument-wide star power (MIDI 116) / solo (MIDI 103) / activation lanes (MIDI 120), emits flex lanes (MIDI 126/127 with per-difficulty LDS velocity), passes through per-track text events and unrecognizedMidiEvents from the first difficulty. - emitDrumNotes: per-difficulty note emission with full modifier support: base drum notes (MIDI 96..100 expert / 84..88 hard / 72..76 medium / 60..64 easy); double-kick as MIDI 95 only, emitted AFTER regular kick at the same tick (YARG insertion order quirk); tom markers (MIDI 110/111/112) only in fourLanePro; accent (velocity 127) / ghost (velocity 1) encoding; one flam marker (MIDI 109) per group; 5-lane green+cymbal remapped to offset 4 (orange pad); blue-at-same-tick-as- green+cymbal remapped to offset 5 so fiveLane detection succeeds; fourLanePro green+tom fallback to offset 5 when a conflicting green+cymbal exists at the same tick across difficulties; disco-flip state-transition text events (`[mix drums0[d|dnoflip]]`). - fourLanePro sentinel greenTomMarker: when drumType=1 but no tom markers were emitted (all yellow/blue defaulted to cymbal, green-toms used offset-5 encoding), emit a MIDI 112 at a safe tick so scan-chart's drumType detection still picks fourLanePro. - [ENABLE_CHART_DYNAMICS] text event at tick 0 when any accent or ghost was emitted. - computeLengthOverrides: prevents scan-chart's trimSustains from collapsing adjacent short-sustain drum-note chains by attributing the combined chain length to the first note. - Shared helpers: addNoteOnOff, addNoteOnOffWithChannel (with zero-length seq-pairing to survive finalizeMidiTrack's sort), deduplicateSections, instrumentTrackNames table. Fret tracks (PART GUITAR, GHL) and vocal tracks (PART VOCALS, HARM1/2/3) remain no-ops until the follow-up PRs land — the writeMidiFile entry iterates trackData but skips any non-drum instrument group. --- src/__tests__/midi-writer.test.ts | 151 ++++++++- src/chart/midi-note-numbers.ts | 41 +++ src/chart/midi-parser.ts | 2 +- src/chart/midi-writer.ts | 543 +++++++++++++++++++++++++++++- 4 files changed, 723 insertions(+), 14 deletions(-) create mode 100644 src/chart/midi-note-numbers.ts diff --git a/src/__tests__/midi-writer.test.ts b/src/__tests__/midi-writer.test.ts index c8d3af1..f0014b3 100644 --- a/src/__tests__/midi-writer.test.ts +++ b/src/__tests__/midi-writer.test.ts @@ -13,6 +13,7 @@ import { describe, expect, it } from 'vitest' import { createEmptyChart } from '../chart/create-chart' import { writeMidiFile } from '../chart/midi-writer' +import { noteFlags, noteTypes } from '../chart/note-parsing-interfaces' import { parseChartAndIni, type ParsedChart } from '../chart/parse-chart-and-ini' function roundTrip(chart: ParsedChart, iniText?: string): ParsedChart { @@ -29,6 +30,47 @@ function roundTrip(chart: ParsedChart, iniText?: string): ParsedChart { return result.parsedChart } +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 findTrack( + chart: ParsedChart, + instrument: ParsedChart['trackData'][number]['instrument'], + difficulty = 'expert', +) { + const t = chart.trackData.find(t => t.instrument === instrument && t.difficulty === difficulty) + if (!t) throw new Error(`no ${difficulty} ${instrument} track in round-tripped chart`) + return t +} + +function flatNotes(track: ParsedChart['trackData'][number]) { + return track.noteEventGroups.flatMap(g => + g.map(n => ({ tick: n.tick, type: n.type, flags: n.flags, length: n.length })), + ) +} + +const PRO_DRUMS_INI = '[Song]\npro_drums = True\n' + describe('writeMidiFile round-trip: resolution + [SyncTrack]', () => { it('preserves chart resolution', () => { const re = roundTrip(createEmptyChart({ resolution: 192 })) @@ -42,8 +84,6 @@ describe('writeMidiFile round-trip: resolution + [SyncTrack]', () => { it('preserves fractional BPM within microsecondsPerBeat rounding', () => { const re = roundTrip(createEmptyChart({ bpm: 137.5 })) - // setTempo is stored as integer microseconds/beat, so fractional BPM - // round-trips with tiny quantization — check within 0.01 BPM. expect(re.tempos[0].beatsPerMinute).toBeCloseTo(137.5, 1) }) @@ -129,3 +169,110 @@ describe('writeMidiFile round-trip: unrecognized MIDI tracks', () => { expect(roundTrip(chart).unrecognizedMidiTracks).toHaveLength(3) }) }) + +describe('writeMidiFile round-trip: drum tracks', () => { + it('preserves base 4-lane drum notes at the right ticks', () => { + 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 notes = flatNotes(findTrack(roundTrip(chart), 'drums')) + expect(notes.map(n => ({ tick: n.tick, type: n.type }))).toEqual([ + { tick: 0, type: noteTypes.kick }, + { tick: 120, type: noteTypes.redDrum }, + { tick: 240, type: noteTypes.yellowDrum }, + { tick: 360, type: noteTypes.blueDrum }, + { tick: 480, type: noteTypes.greenDrum }, + ]) + }) + + it('preserves tracks across all difficulties on a single chart', () => { + const chart = createEmptyChart({ format: 'mid' }) + for (const d of ['expert', 'hard', 'medium', 'easy'] as const) { + const td = emptyDrumTrack(d) + td.noteEventGroups.push([note(0, noteTypes.kick)]) + chart.trackData.push(td) + } + const re = roundTrip(chart) + for (const d of ['expert', 'hard', 'medium', 'easy'] as const) { + expect(findTrack(re, 'drums', d).noteEventGroups).toHaveLength(1) + } + }) + + it('preserves double-kick (not as a regular kick)', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([note(0, noteTypes.kick, noteFlags.doubleKick)]) + chart.trackData.push(td) + const notes = flatNotes(findTrack(roundTrip(chart), 'drums')) + expect(notes).toHaveLength(1) + expect(notes[0].flags & noteFlags.doubleKick).toBeTruthy() + }) + + it('preserves accent and ghost flags', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([note(0, noteTypes.redDrum, noteFlags.accent)]) + td.noteEventGroups.push([note(240, noteTypes.yellowDrum, noteFlags.ghost)]) + chart.trackData.push(td) + const notes = flatNotes(findTrack(roundTrip(chart), 'drums')) + expect(notes[0].flags & noteFlags.accent).toBeTruthy() + expect(notes[1].flags & noteFlags.ghost).toBeTruthy() + }) + + it('preserves cymbal/tom distinction in fourLanePro', () => { + const chart = createEmptyChart({ format: 'mid' }) + chart.drumType = 1 // fourLanePro + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([note(0, noteTypes.yellowDrum, noteFlags.cymbal)]) + td.noteEventGroups.push([note(120, noteTypes.yellowDrum, noteFlags.tom)]) + td.noteEventGroups.push([note(240, noteTypes.blueDrum, noteFlags.cymbal)]) + td.noteEventGroups.push([note(360, noteTypes.blueDrum, noteFlags.tom)]) + td.noteEventGroups.push([note(480, noteTypes.greenDrum, noteFlags.cymbal)]) + td.noteEventGroups.push([note(600, noteTypes.greenDrum, noteFlags.tom)]) + chart.trackData.push(td) + const notes = flatNotes(findTrack(roundTrip(chart, PRO_DRUMS_INI), 'drums')) + expect(notes[0].flags & noteFlags.cymbal).toBeTruthy() + expect(notes[1].flags & noteFlags.tom).toBeTruthy() + expect(notes[2].flags & noteFlags.cymbal).toBeTruthy() + expect(notes[3].flags & noteFlags.tom).toBeTruthy() + expect(notes[4].flags & noteFlags.cymbal).toBeTruthy() + expect(notes[5].flags & noteFlags.tom).toBeTruthy() + }) + + it('preserves the flam flag on a chord group', () => { + 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 group = findTrack(roundTrip(chart), 'drums').noteEventGroups[0] + expect(group.some(n => (n.flags & noteFlags.flam) !== 0)).toBe(true) + }) + + it('preserves star power, solo sections, flex lanes, and activation lanes', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + const td = emptyDrumTrack('expert') + td.noteEventGroups.push([note(0, noteTypes.kick)]) + td.starPowerSections.push({ tick: 0, length: 960, msTime: 0, msLength: 0 }) + td.soloSections.push({ tick: 480, length: 480, msTime: 0, msLength: 0 }) + td.flexLanes.push({ tick: 960, length: 480, isDouble: false, msTime: 0, msLength: 0 }) + td.flexLanes.push({ tick: 1440, length: 480, isDouble: true, msTime: 0, msLength: 0 }) + td.drumFreestyleSections.push({ tick: 1920, length: 480, isCoda: false, msTime: 0, msLength: 0 }) + chart.trackData.push(td) + const reTrack = findTrack(roundTrip(chart), '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: false }, + { t: 1440, l: 480, d: true }, + ]) + expect(reTrack.drumFreestyleSections.map(s => ({ t: s.tick, l: s.length }))).toEqual([{ t: 1920, l: 480 }]) + }) +}) diff --git a/src/chart/midi-note-numbers.ts b/src/chart/midi-note-numbers.ts new file mode 100644 index 0000000..207403b --- /dev/null +++ b/src/chart/midi-note-numbers.ts @@ -0,0 +1,41 @@ +/** + * 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, +} + +/** + * 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 diff --git a/src/chart/midi-parser.ts b/src/chart/midi-parser.ts index e84961d..6a9b0ab 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 } 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 }[] { @@ -71,7 +72,6 @@ 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)[ _](.*)$/ diff --git a/src/chart/midi-writer.ts b/src/chart/midi-writer.ts index 265f599..d075d25 100644 --- a/src/chart/midi-writer.ts +++ b/src/chart/midi-writer.ts @@ -1,28 +1,33 @@ /** * MIDI binary writer — serializes a ParsedChart back to a Format-1 `.mid` file. * - * This PR establishes the writer infrastructure: - * - `writeMidiFile` entry point + * Currently emits: * - TEMPO TRACK (tempo + time-signature meta events) * - EVENTS track (sections + end events + unrecognized global events + coda) + * - PART DRUMS instrument tracks (4-lane / 4-lane-pro / 5-lane with full + * modifier support) * - Unrecognized MIDI tracks (verbatim pass-through) - * - `finalizeMidiTrack` shared helper (sort + absolute→delta time conversion) * - * Instrument tracks (PART DRUMS, PART GUITAR, etc.) and vocal tracks - * (PART VOCALS, HARM1/2/3) are emitted by follow-up PRs. + * PART GUITAR / GHL / PART VOCALS / HARM1-3 land in follow-up PRs. */ import type { MidiData, MidiEvent } from '@geomitron/midi-file' import { writeMidi } from '@geomitron/midi-file' +import type { Difficulty } from '../interfaces' +import { drumsDiffStarts } from './midi-note-numbers' +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] + // --------------------------------------------------------------------------- // Internal types // --------------------------------------------------------------------------- /** A MIDI event tagged with its absolute tick (for sort-then-delta finalization). */ -export interface AbsoluteEvent { +interface AbsoluteEvent { tick: number event: MidiEvent /** Stable sort tiebreaker — preserves source ordering within the same tick. */ @@ -39,11 +44,11 @@ export interface AbsoluteEvent { * Output track layout: * 0 — TEMPO TRACK (BPM + time signatures) * 1 — EVENTS (sections, end events, global events, coda) + * N — PART DRUMS (one track per drum instrument group, all difficulties) * N — Unrecognized MIDI tracks (verbatim pass-through) * - * Instrument tracks (PART DRUMS, PART GUITAR, …) and vocal tracks - * (PART VOCALS, HARM1/2/3) are emitted by follow-up PRs — this entry point - * currently skips `chart.trackData` and `chart.vocalTracks`. + * Fret tracks (PART GUITAR, GHL) and vocal tracks (PART VOCALS, HARM1/2/3) + * are emitted by follow-up PRs — this entry point skips them for now. */ export function writeMidiFile(chart: ParsedChart): Uint8Array { const trackMap = new Map() @@ -51,9 +56,45 @@ export function writeMidiFile(chart: ParsedChart): Uint8Array { 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) { + // Drums only in this PR; fret/vocal tracks land in PR #5c and #5d. + if (g.instrument !== 'drums') continue + let mapKey = g.trackName + while (trackMap.has(mapKey)) mapKey = `${g.trackName}__dup${dupSuffix++}` + trackMap.set(mapKey, buildDrumTrack(g.entries, chart, g.trackName)) + } + // Unrecognized whole tracks (VENUE, BEAT, PART REAL_*, custom tracks) are // round-tripped verbatim. - let dupSuffix = 0 for (const ut of chart.unrecognizedMidiTracks) { let mapKey = ut.trackName while (trackMap.has(mapKey)) mapKey = `${ut.trackName}__dup${dupSuffix++}` @@ -222,7 +263,7 @@ function buildUnrecognizedTrack(events: MidiEvent[]): MidiEvent[] { * sort AFTER untagged events at the same tick (so instrument-track emitters * can sequence paired events deterministically via `seq`). */ -export function finalizeMidiTrack(events: AbsoluteEvent[]): MidiEvent[] { +function finalizeMidiTrack(events: AbsoluteEvent[]): MidiEvent[] { const eventPriority = (e: MidiEvent): number => { switch (e.type) { case 'trackName': return 0 @@ -257,3 +298,483 @@ export function finalizeMidiTrack(events: AbsoluteEvent[]): MidiEvent[] { 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', +} + +// --------------------------------------------------------------------------- +// 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: { ...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) + } + } + } +}