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 +} +