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/__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/__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/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/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..4661673 100644 --- a/src/chart/index.ts +++ b/src/chart/index.ts @@ -1,2 +1,4 @@ export * from './chart-scanner' +export * from './chart-writer' +export * from './create-chart' export * from './parse-chart-and-ini' diff --git a/src/index.ts b/src/index.ts index e7a46c7..c10c441 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,9 +12,10 @@ import { scanVideo } from './video' export * from './interfaces' export * from './chart/note-parsing-interfaces' export { parseChartFile } from './chart/notes-parser' -export { parseChartAndIni } from './chart' +export { 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) +}