From 940eeea9c011e4cd2175d580dc6b8ec47179c26f Mon Sep 17 00:00:00 2001 From: Eli White Date: Sun, 19 Apr 2026 21:26:54 -0700 Subject: [PATCH] writeMidiFile: emit PART GUITAR / GHL instrument tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds fret-instrument emission to writeMidiFile. - buildFretTrack: one MIDI track per instrument group (guitar / bass / guitarghl / bassghl / keys / guitarcoop / rhythm / GHL variants); multiple difficulties merge into the same track. - emitFretNotes: 5-fret (95/83/71/59 diffStarts) and 6-fret/GHL (94/82/70/58) note-number mapping; open notes routed two ways: ENHANCED_OPENS mode (diffStart+0, preserves chord-with-open) when any group contains both open and non-open, else forceOpen-SysEx fallback (diffStart+1 + SysEx, collapses chords with open but safer next to animations at easy diffStart). Coalesces fret-note length with overlapping animations at the same tick:noteNumber via pre-computed seq slots so animation ordering survives re-parse. - Force modifier ranges (forceHopo / forceStrum / forceTap) via emitFretModifierRanges — inverts resolveFretModifiers: natural HOPO state is re-derived with the MIDI threshold (1 + res/3), and a modifier emits only when the resolved flag disagrees. forceHopo = diffStart+6 (5-fret) / +7 (GHL); forceStrum = +7 / +8; forceTap as SysEx. - reconstructModifierRanges: collapses per-tick hit sets into minimal contiguous ranges over noteTicksInOrder. - Instrument-wide emission: star power (MIDI 116), solo (MIDI 103), flex lanes (126/127 with per-difficulty LDS velocity), per-track text events, versus phrases (105/106), animations (with skip when the fret note above already emitted at the same tick:noteNumber), per-track unrecognizedMidiEvents. - [ENHANCED_OPENS] text event when that mode is active. - Helpers added: addSysExOnOff, reconstructModifierRanges, midiIsFretChord / midiIsSameFretNote / midiIsInFretNote. Vocal tracks (PART VOCALS / HARM1-3) land in the follow-up PR. Tests: 15 new test cases covering track naming, 5-fret + GHL note-number mapping, open-note encoding (both modes), force modifier emission (hopo/strum/tap + non-emission when natural matches), instrument-wide sections, and round-trip through parseChartAndIni for both 5-fret modifiers and GHL chord-with-open. 53 midi-writer tests, 397 total scan-chart tests passing at the tip. --- src/__tests__/midi-writer.test.ts | 182 +++++++++++ src/chart/midi-note-numbers.ts | 47 +++ src/chart/midi-parser.ts | 61 ++-- src/chart/midi-writer.ts | 483 +++++++++++++++++++++++++++++- 4 files changed, 728 insertions(+), 45 deletions(-) diff --git a/src/__tests__/midi-writer.test.ts b/src/__tests__/midi-writer.test.ts index f0014b3..6745343 100644 --- a/src/__tests__/midi-writer.test.ts +++ b/src/__tests__/midi-writer.test.ts @@ -276,3 +276,185 @@ describe('writeMidiFile round-trip: drum tracks', () => { expect(reTrack.drumFreestyleSections.map(s => ({ t: s.tick, l: s.length }))).toEqual([{ t: 1920, l: 480 }]) }) }) + + +// --------------------------------------------------------------------------- +// Fret / GHL track tests +// --------------------------------------------------------------------------- + +function emptyFretTrack( + instrument: ParsedChart['trackData'][number]['instrument'] = 'guitar', + difficulty: 'expert' | 'hard' | 'medium' | 'easy' = 'expert', +): ParsedChart['trackData'][number] { + return { + instrument, + difficulty, + starPowerSections: [], + rejectedStarPowerSections: [], + soloSections: [], + flexLanes: [], + drumFreestyleSections: [], + textEvents: [], + versusPhrases: [], + animations: [], + unrecognizedMidiEvents: [], + noteEventGroups: [], + } +} + +describe('writeMidiFile round-trip: 5-fret tracks', () => { + it('preserves all five fret colors on a guitar track', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyFretTrack('guitar', 'expert') + td.noteEventGroups.push([note(0, noteTypes.green)]) + td.noteEventGroups.push([note(120, noteTypes.red)]) + td.noteEventGroups.push([note(240, noteTypes.yellow)]) + td.noteEventGroups.push([note(360, noteTypes.blue)]) + td.noteEventGroups.push([note(480, noteTypes.orange)]) + chart.trackData.push(td) + const notes = flatNotes(findTrack(roundTrip(chart), 'guitar')) + expect(notes.map(n => ({ tick: n.tick, type: n.type }))).toEqual([ + { tick: 0, type: noteTypes.green }, + { tick: 120, type: noteTypes.red }, + { tick: 240, type: noteTypes.yellow }, + { tick: 360, type: noteTypes.blue }, + { tick: 480, type: noteTypes.orange }, + ]) + }) + + it('preserves tracks at all difficulties', () => { + const chart = createEmptyChart({ format: 'mid' }) + for (const d of ['expert', 'hard', 'medium', 'easy'] as const) { + const td = emptyFretTrack('guitar', d) + td.noteEventGroups.push([note(0, noteTypes.green)]) + chart.trackData.push(td) + } + const re = roundTrip(chart) + for (const d of ['expert', 'hard', 'medium', 'easy'] as const) { + expect(findTrack(re, 'guitar', d).noteEventGroups).toHaveLength(1) + } + }) + + it('preserves a plain open note (not in a chord)', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyFretTrack('guitar', 'expert') + td.noteEventGroups.push([note(0, noteTypes.open)]) + chart.trackData.push(td) + const notes = flatNotes(findTrack(roundTrip(chart), 'guitar')) + expect(notes.map(n => n.type)).toEqual([noteTypes.open]) + }) + + it('preserves an open-in-chord group (ENHANCED_OPENS path)', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyFretTrack('guitar', 'expert') + td.noteEventGroups.push([note(0, noteTypes.open), note(0, noteTypes.red)]) + chart.trackData.push(td) + const group = findTrack(roundTrip(chart), 'guitar').noteEventGroups[0] + expect(group.map(n => n.type).sort()).toEqual([noteTypes.open, noteTypes.red].sort()) + }) + + it('preserves a forced-hopo flag on a note whose natural state is strum', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + const td = emptyFretTrack('guitar', 'expert') + td.noteEventGroups.push([note(0, noteTypes.green)]) + td.noteEventGroups.push([note(1920, noteTypes.green, noteFlags.hopo)]) + chart.trackData.push(td) + const notes = flatNotes(findTrack(roundTrip(chart), 'guitar')) + expect(notes.find(n => n.tick === 1920)!.flags & noteFlags.hopo).toBeTruthy() + }) + + it('preserves a forced-strum flag on a note whose natural state is hopo', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + const td = emptyFretTrack('guitar', 'expert') + td.noteEventGroups.push([note(0, noteTypes.green)]) + td.noteEventGroups.push([note(120, noteTypes.red, noteFlags.strum)]) + chart.trackData.push(td) + const notes = flatNotes(findTrack(roundTrip(chart), 'guitar')) + expect(notes.find(n => n.tick === 120)!.flags & noteFlags.strum).toBeTruthy() + }) + + it('preserves the tap flag', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyFretTrack('guitar', 'expert') + td.noteEventGroups.push([note(0, noteTypes.green, noteFlags.tap)]) + chart.trackData.push(td) + const notes = flatNotes(findTrack(roundTrip(chart), 'guitar')) + expect(notes[0].flags & noteFlags.tap).toBeTruthy() + }) + + it('preserves star power and solo sections on a guitar track', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyFretTrack('guitar', 'expert') + td.noteEventGroups.push([note(0, noteTypes.green)]) + td.starPowerSections.push({ tick: 0, length: 480, msTime: 0, msLength: 0 }) + td.soloSections.push({ tick: 480, length: 240, msTime: 0, msLength: 0 }) + chart.trackData.push(td) + const reTrack = findTrack(roundTrip(chart), 'guitar') + expect(reTrack.starPowerSections.map(s => ({ t: s.tick, l: s.length }))).toEqual([{ t: 0, l: 480 }]) + expect(reTrack.soloSections.map(s => ({ t: s.tick, l: s.length }))).toEqual([{ t: 480, l: 240 }]) + }) + + // MIDI note 120 is the Big Rock Ending / coda activation marker. It can + // appear on fret tracks (e.g. PART GUITAR/BASS) as well as drums; the + // parser captures it in `drumFreestyleSections` per track and the fret + // writer emits it back so trackHashes are stable across round-trip. + it('preserves drumFreestyleSections (BRE/coda) on a fret track', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyFretTrack('guitar', 'expert') + td.noteEventGroups.push([note(0, noteTypes.green)]) + td.drumFreestyleSections.push({ tick: 1920, length: 480, isCoda: true, msTime: 0, msLength: 0 }) + chart.trackData.push(td) + const reTrack = findTrack(roundTrip(chart), 'guitar') + expect(reTrack.drumFreestyleSections.map(s => ({ t: s.tick, l: s.length, isCoda: s.isCoda }))) + .toEqual([{ t: 1920, l: 480, isCoda: true }]) + }) + + // Open notes encoded via the forceOpen SysEx share MIDI 96 with green. + // When an open's tick range overlaps a green's, the writer must use + // ENHANCED_OPENS so opens get their own MIDI number — otherwise the two + // note-96 noteOn/noteOff pairs re-pair wrong on parse and swap lengths. + it('preserves overlapping open + green sustains without swapping lengths', () => { + const chart = createEmptyChart({ format: 'mid', resolution: 480 }) + const td = emptyFretTrack('guitar', 'expert') + td.noteEventGroups.push([note(0, noteTypes.green, noteFlags.strum, 1680)]) + td.noteEventGroups.push([note(960, noteTypes.open, noteFlags.strum, 1680)]) + chart.trackData.push(td) + const notes = flatNotes(findTrack(roundTrip(chart), 'guitar')) + const green = notes.find(n => n.type === noteTypes.green)! + const open = notes.find(n => n.type === noteTypes.open)! + expect({ tick: green.tick, length: green.length }).toEqual({ tick: 0, length: 1680 }) + expect({ tick: open.tick, length: open.length }).toEqual({ tick: 960, length: 1680 }) + }) +}) + +describe('writeMidiFile round-trip: GHL (6-fret) tracks', () => { + it('preserves all six GHL fret colors', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyFretTrack('guitarghl', 'expert') + td.noteEventGroups.push([note(0, noteTypes.white1)]) + td.noteEventGroups.push([note(120, noteTypes.white2)]) + td.noteEventGroups.push([note(240, noteTypes.white3)]) + td.noteEventGroups.push([note(360, noteTypes.black1)]) + td.noteEventGroups.push([note(480, noteTypes.black2)]) + td.noteEventGroups.push([note(600, noteTypes.black3)]) + chart.trackData.push(td) + const notes = flatNotes(findTrack(roundTrip(chart), 'guitarghl')) + expect(notes.map(n => ({ tick: n.tick, type: n.type }))).toEqual([ + { tick: 0, type: noteTypes.white1 }, + { tick: 120, type: noteTypes.white2 }, + { tick: 240, type: noteTypes.white3 }, + { tick: 360, type: noteTypes.black1 }, + { tick: 480, type: noteTypes.black2 }, + { tick: 600, type: noteTypes.black3 }, + ]) + }) + + it('preserves a chord of open + white1 on GHL (ENHANCED_OPENS path)', () => { + const chart = createEmptyChart({ format: 'mid' }) + const td = emptyFretTrack('guitarghl', 'expert') + td.noteEventGroups.push([note(0, noteTypes.open), note(0, noteTypes.white1)]) + chart.trackData.push(td) + const group = findTrack(roundTrip(chart), 'guitarghl').noteEventGroups[0] + expect(group.map(n => n.type).sort()).toEqual([noteTypes.open, noteTypes.white1].sort()) + }) +}) diff --git a/src/chart/midi-note-numbers.ts b/src/chart/midi-note-numbers.ts index 207403b..829fa96 100644 --- a/src/chart/midi-note-numbers.ts +++ b/src/chart/midi-note-numbers.ts @@ -26,6 +26,20 @@ export const drumsDiffStarts: Record = { expert: 96, } +export const fiveFretDiffStarts: Record = { + easy: 59, + medium: 71, + hard: 83, + expert: 95, +} + +export const sixFretDiffStarts: Record = { + easy: 58, + medium: 70, + hard: 82, + expert: 94, +} + /** * Lane offsets from `drumsDiffStarts[difficulty]`. 2x-kick sits at -1 (the * only lane below the base). @@ -39,3 +53,36 @@ export const drumLaneOffsets = { fiveOrangeFourGreen: 4, fiveGreen: 5, } as const + +/** + * 5-fret lane offsets from `fiveFretDiffStarts[difficulty]`. + * + * Offset 0 is the ENHANCED_OPENS open-note slot (emitted only when the track + * has `[ENHANCED_OPENS]`); otherwise opens are encoded via the `forceOpen` + * SysEx at offset 1 (the green slot). + */ +export const fiveFretLaneOffsets = { + open: 0, + green: 1, + red: 2, + yellow: 3, + blue: 4, + orange: 5, + forceHopo: 6, + forceStrum: 7, +} as const + +/** + * 6-fret (GHL) lane offsets from `sixFretDiffStarts[difficulty]`. + */ +export const sixFretLaneOffsets = { + open: 0, + white1: 1, + white2: 2, + white3: 3, + black1: 4, + black2: 5, + black3: 6, + forceHopo: 7, + forceStrum: 8, +} as const diff --git a/src/chart/midi-parser.ts b/src/chart/midi-parser.ts index 6a9b0ab..517f4c6 100644 --- a/src/chart/midi-parser.ts +++ b/src/chart/midi-parser.ts @@ -3,7 +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' +import { drumsDiffStarts, fiveFretDiffStarts, fiveFretLaneOffsets, sixFretDiffStarts, sixFretLaneOffsets } from './midi-note-numbers' // Union two phrase lists, dedup by tick (keep longest length), sort by tick. function mergePhraseLists(a: { tick: number; length: number }[], b: { tick: number; length: number }[]): { tick: number; length: number }[] { @@ -70,8 +70,6 @@ const instrumentNameMap: { [key in InstrumentTrackName]: Instrument } = { const sysExDifficultyMap = ['easy', 'medium', 'hard', 'expert'] as const const discoFlipDifficultyMap = ['easy', 'medium', 'hard', 'expert'] as const -const fiveFretDiffStarts = { easy: 59, medium: 71, hard: 83, expert: 95 } -const sixFretDiffStarts = { easy: 58, medium: 70, hard: 82, expert: 94 } const midiDiscoFlipRegex = /^\s*\[?mix[ _]([0-3])[ _]drums([0-5])(d|dnoflip|easy|easynokick|)\]?\s*$/ const eventsBracketedSectionRegex = /^\[(?:section|prc)[ _](.*)\]$/ const eventsPlainSectionRegex = /^(?:section|prc)[ _](.*)$/ @@ -675,49 +673,30 @@ function getInstrumentEventType(note: number) { function get6FretNoteType(note: number, difficulty: Difficulty) { switch (note - sixFretDiffStarts[difficulty]) { - case 0: - return eventTypes.open // Not forceOpen - case 1: - return eventTypes.white1 - case 2: - return eventTypes.white2 - case 3: - return eventTypes.white3 - case 4: - return eventTypes.black1 - case 5: - return eventTypes.black2 - case 6: - return eventTypes.black3 - case 7: - return eventTypes.forceHopo - case 8: - return eventTypes.forceStrum - default: - return null + case sixFretLaneOffsets.open: return eventTypes.open // Not forceOpen + case sixFretLaneOffsets.white1: return eventTypes.white1 + case sixFretLaneOffsets.white2: return eventTypes.white2 + case sixFretLaneOffsets.white3: return eventTypes.white3 + case sixFretLaneOffsets.black1: return eventTypes.black1 + case sixFretLaneOffsets.black2: return eventTypes.black2 + case sixFretLaneOffsets.black3: return eventTypes.black3 + case sixFretLaneOffsets.forceHopo: return eventTypes.forceHopo + case sixFretLaneOffsets.forceStrum: return eventTypes.forceStrum + default: return null } } function get5FretNoteType(note: number, difficulty: Difficulty, enhancedOpens: boolean) { switch (note - fiveFretDiffStarts[difficulty]) { - case 0: - return enhancedOpens ? eventTypes.open : null // Not forceOpen - case 1: - return eventTypes.green - case 2: - return eventTypes.red - case 3: - return eventTypes.yellow - case 4: - return eventTypes.blue - case 5: - return eventTypes.orange - case 6: - return eventTypes.forceHopo - case 7: - return eventTypes.forceStrum - default: - return null + case fiveFretLaneOffsets.open: return enhancedOpens ? eventTypes.open : null // Not forceOpen + case fiveFretLaneOffsets.green: return eventTypes.green + case fiveFretLaneOffsets.red: return eventTypes.red + case fiveFretLaneOffsets.yellow: return eventTypes.yellow + case fiveFretLaneOffsets.blue: return eventTypes.blue + case fiveFretLaneOffsets.orange: return eventTypes.orange + case fiveFretLaneOffsets.forceHopo: return eventTypes.forceHopo + case fiveFretLaneOffsets.forceStrum: return eventTypes.forceStrum + default: return null } } diff --git a/src/chart/midi-writer.ts b/src/chart/midi-writer.ts index d075d25..58d5431 100644 --- a/src/chart/midi-writer.ts +++ b/src/chart/midi-writer.ts @@ -15,7 +15,8 @@ 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 { drumsDiffStarts, fiveFretDiffStarts, fiveFretLaneOffsets, sixFretDiffStarts, sixFretLaneOffsets } from './midi-note-numbers' +import { computeHopoThresholdTicks, isNaturalHopo } from './natural-hopo' import type { NoteEvent, NoteType } from './note-parsing-interfaces' import { noteFlags, noteTypes } from './note-parsing-interfaces' import type { ParsedChart } from './parse-chart-and-ini' @@ -86,11 +87,14 @@ export function writeMidiFile(chart: ParsedChart): Uint8Array { 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)) + if (g.instrument === 'drums') { + trackMap.set(mapKey, buildDrumTrack(g.entries, chart, g.trackName)) + } else if (fiveFretInstruments.has(g.instrument) || sixFretInstruments.has(g.instrument)) { + trackMap.set(mapKey, buildFretTrack(g.entries, chart, g.trackName)) + } + // vocals land in PR #5d; skip for now. } // Unrecognized whole tracks (VENUE, BEAT, PART REAL_*, custom tracks) are @@ -778,3 +782,474 @@ function emitDrumNotes( } } } + +// --------------------------------------------------------------------------- +// Fret / GHL track emission +// --------------------------------------------------------------------------- + +/** NoteType → lane offset for 5-fret. `open` routes through forceOpen SysEx + * or ENHANCED_OPENS mode (handled in emitFretNotes), not this map. */ +const fiveFretNoteTypeToOffset: Partial> = { + [noteTypes.green]: fiveFretLaneOffsets.green, + [noteTypes.red]: fiveFretLaneOffsets.red, + [noteTypes.yellow]: fiveFretLaneOffsets.yellow, + [noteTypes.blue]: fiveFretLaneOffsets.blue, + [noteTypes.orange]: fiveFretLaneOffsets.orange, +} + +/** NoteType → lane offset for 6-fret (GHL). */ +const sixFretNoteTypeToOffset: Partial> = { + [noteTypes.open]: sixFretLaneOffsets.open, + [noteTypes.white1]: sixFretLaneOffsets.white1, + [noteTypes.white2]: sixFretLaneOffsets.white2, + [noteTypes.white3]: sixFretLaneOffsets.white3, + [noteTypes.black1]: sixFretLaneOffsets.black1, + [noteTypes.black2]: sixFretLaneOffsets.black2, + [noteTypes.black3]: sixFretLaneOffsets.black3, +} + +const fiveFretInstruments = new Set(['guitar', 'guitarcoop', 'rhythm', 'bass', 'keys']) +const sixFretInstruments = new Set(['guitarghl', 'guitarcoopghl', 'rhythmghl', 'bassghl']) + +/** SysEx diff byte for Phase Shift modifier encoding. */ +const sysExDiffMap: Record = { + easy: 0x00, + medium: 0x01, + hard: 0x02, + expert: 0x03, +} + +/** + * Build a PART GUITAR / PART BASS / PART GUITAR GHL / etc. track. Single + * MIDI track per instrument group; all difficulties merge into it. + */ +function buildFretTrack( + trackDataEntries: ParsedTrack[], + chart: ParsedChart, + trackName: string, +): MidiEvent[] { + const events: AbsoluteEvent[] = [] + const instrument = trackDataEntries[0].instrument + const isGhl = sixFretInstruments.has(instrument) + const sourceIsMidi = chart.format === 'mid' + + events.push({ + tick: 0, + event: { deltaTime: 0, meta: true, type: 'trackName', text: trackName } as MidiEvent, + }) + + const allStarPower: { tick: number; length: number }[] = [] + const allSolo: { tick: number; length: number }[] = [] + const allActivation: { tick: number; length: number }[] = [] + const emittedFlexLane = new Map() + + // Use ENHANCED_OPENS mode when: + // (a) any group contains both an open note AND a non-open fret note + // (a chord-with-open) — the SysEx encoding can't represent these, + // (b) any open note's tick range overlaps a green note's tick range in + // the same difficulty — both encode to MIDI 96 under SysEx, and + // overlapping noteOn/noteOff pairs would re-pair wrong on parse, + // swapping sustain lengths. + // Otherwise use forceOpen SysEx, which collapses chords-with-open into + // single opens but avoids conflicts with animations at easy diffStart. + const useEnhancedOpens = trackDataEntries.some(td => { + if (td.noteEventGroups.some(group => { + if (group.length < 2) return false + const hasOpen = group.some(n => n.type === noteTypes.open) + const hasOther = group.some(n => n.type !== noteTypes.open) + return hasOpen && hasOther + })) return true + + const opens: { tick: number; end: number }[] = [] + const greens: { tick: number; end: number }[] = [] + for (const group of td.noteEventGroups) { + for (const n of group) { + if (n.type === noteTypes.open) opens.push({ tick: n.tick, end: n.tick + n.length }) + else if (n.type === noteTypes.green) greens.push({ tick: n.tick, end: n.tick + n.length }) + } + } + if (opens.length === 0 || greens.length === 0) return false + for (const o of opens) { + for (const g of greens) { + if (o.tick < g.end && g.tick < o.end) return true + } + } + return false + }) + + // Build animation maps (MIDI note number → length) from the first-difficulty + // data so emitFretNotes can coalesce its fret-note length with any animation + // that overlaps at the same tick:noteNumber. + const firstTd = trackDataEntries[0] + const animMap = new Map() + for (const otherTd of trackDataEntries) { + for (const anim of otherTd.animations) { + const key = `${anim.tick}:${anim.noteNumber}` + const existing = animMap.get(key) ?? 0 + if (anim.length > existing) animMap.set(key, anim.length) + } + } + + // Pre-compute seq slots for first-difficulty animations. emitFretNotes stamps + // the matching seq onto fret-note pairs that overlap an animation so the + // animation array's order survives re-parse. + const animSeqMap = new Map() + const ANIM_SEQ_BASE = 1_000_000_000 + { + let probe = 0 + for (const anim of firstTd.animations) { + const key = `${anim.tick}:${anim.noteNumber}` + const onSeq = ANIM_SEQ_BASE + probe++ + const offSeq = ANIM_SEQ_BASE + probe++ + if (!animSeqMap.has(key)) animSeqMap.set(key, { onSeq, offSeq }) + } + } + + const diffVelocity: Record = { easy: 25, medium: 35, hard: 45, expert: 100 } + + for (const td of trackDataEntries) { + if (isGhl) { + emitFretNotes(events, td, sixFretDiffStarts, sixFretNoteTypeToOffset, animMap, useEnhancedOpens, animSeqMap) + } else { + emitFretNotes(events, td, fiveFretDiffStarts, fiveFretNoteTypeToOffset, animMap, useEnhancedOpens, animSeqMap) + } + + for (const sp of td.starPowerSections) allStarPower.push(sp) + for (const solo of td.soloSections) allSolo.push(solo) + // BRE / coda activation (MIDI 120) — fret tracks support this in + // addition to drums. + for (const fs of td.drumFreestyleSections) allActivation.push(fs) + + for (const fl of td.flexLanes) { + const note = fl.isDouble ? 127 : 126 + const key = `${fl.tick}:${fl.length}:${note}` + const thisVel = diffVelocity[td.difficulty] ?? 100 + const existing = emittedFlexLane.get(key) + if (existing === undefined || thisVel < existing) emittedFlexLane.set(key, thisVel) + } + + // First-difficulty extras: text events, versus phrases, animations, + // per-track unrecognizedMidiEvents. scan-chart populates these + // identically on all 4 difficulties; writing once avoids duplicates. + if (td === firstTd) { + for (const te of td.textEvents) { + let text = te.text + if (!sourceIsMidi) { + const trimmed = text.trimEnd() + if (!(trimmed.startsWith('[') && trimmed.endsWith(']'))) text = `[${text}]` + } + events.push({ + tick: te.tick, + event: { deltaTime: 0, meta: true, type: 'text', text } as MidiEvent, + }) + } + + let vpSeq = 0 + for (const vp of td.versusPhrases) { + const note = vp.isPlayer2 ? 106 : 105 + events.push({ + tick: vp.tick, + seq: vpSeq++, + event: { deltaTime: 0, channel: 0, type: 'noteOn', noteNumber: note, velocity: 100 } as MidiEvent, + }) + events.push({ + tick: vp.tick + vp.length, + seq: vpSeq++, + event: { deltaTime: 0, channel: 0, type: 'noteOff', noteNumber: note, velocity: 0 } as MidiEvent, + }) + } + + // Animations: emit note-pair per animation unless the same + // (tick, noteNumber) was already emitted by a fret note above (in + // which case emitFretNotes coalesced the pair with the animation's + // seq — no duplicate noteOn at this tick:noteNumber). + const emittedNoteKeys = new Set() + const diffStarts2 = isGhl ? sixFretDiffStarts : fiveFretDiffStarts + const offsetMap = isGhl ? sixFretNoteTypeToOffset : fiveFretNoteTypeToOffset + for (const otherTd of trackDataEntries) { + const diffStart = diffStarts2[otherTd.difficulty] + for (const group of otherTd.noteEventGroups) { + for (const n of group) { + let noteNum: number + if (n.type === noteTypes.open) { + noteNum = useEnhancedOpens ? diffStart + 0 : diffStart + 1 + } else { + const off = offsetMap[n.type] + if (off === undefined) continue + noteNum = diffStart + off + } + emittedNoteKeys.add(`${n.tick}:${noteNum}`) + } + } + } + let animSeq = 0 + for (const anim of td.animations) { + const key = `${anim.tick}:${anim.noteNumber}` + // Advance seq counter even on skip so it stays in lock-step with + // emitFretNotes' stamped onSeq/offSeq. + const onSeq = ANIM_SEQ_BASE + animSeq++ + const offSeq = ANIM_SEQ_BASE + animSeq++ + if (emittedNoteKeys.has(key)) continue + events.push({ + tick: anim.tick, + seq: onSeq, + event: { deltaTime: 0, channel: 0, type: 'noteOn', noteNumber: anim.noteNumber, velocity: 100 } as MidiEvent, + }) + events.push({ + tick: anim.tick + anim.length, + seq: offSeq, + event: { deltaTime: 0, channel: 0, type: 'noteOff', noteNumber: anim.noteNumber, velocity: 0 } as MidiEvent, + }) + } + + let unrecSeq = 0 + const unrecSeqBase = 4_000_000_000 + for (const ev of td.unrecognizedMidiEvents) { + events.push({ + tick: ev.deltaTime, + seq: unrecSeqBase + unrecSeq++, + event: { ...ev, deltaTime: 0 } as MidiEvent, + }) + } + } + } + + // Instrument-wide sections. Star power = 116, solo = 103, BRE/coda = 120. + for (const sp of deduplicateSections(allStarPower)) { + const vel = (sp as { velocity?: number }).velocity ?? 100 + const ch = (sp as { channel?: number }).channel ?? 0 + addNoteOnOffWithChannel(events, sp.tick, sp.length, 116, vel, ch) + } + for (const solo of deduplicateSections(allSolo)) { + const vel = (solo as { velocity?: number }).velocity ?? 100 + const ch = (solo as { channel?: number }).channel ?? 0 + addNoteOnOffWithChannel(events, solo.tick, solo.length, 103, vel, ch) + } + 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) + } + let flexSeq = 0 + for (const [key, velocity] of emittedFlexLane) { + const [tickStr, lengthStr, noteStr] = key.split(':') + const tick = Number(tickStr) + const length = Number(lengthStr) + const noteNumber = Number(noteStr) + events.push({ + tick, + seq: flexSeq++, + event: { deltaTime: 0, channel: 0, type: 'noteOn', noteNumber, velocity } as MidiEvent, + }) + events.push({ + tick: tick + length, + seq: flexSeq++, + event: { deltaTime: 0, channel: 0, type: 'noteOff', noteNumber, velocity: 0 } as MidiEvent, + }) + } + + emitFretModifierRanges(events, trackDataEntries, isGhl, chart) + + if (useEnhancedOpens) { + events.push({ + tick: 0, + event: { deltaTime: 0, meta: true, type: 'text', text: '[ENHANCED_OPENS]' } as MidiEvent, + }) + } + + return finalizeMidiTrack(events) +} + +function emitFretNotes( + events: AbsoluteEvent[], + td: ParsedTrack, + diffStarts: Record, + noteTypeToOffset: Partial>, + animMap: Map, + useEnhancedOpens: boolean, + animSeqMap: Map, +): void { + const diffStart = diffStarts[td.difficulty] + // Fret notes whose MIDI number would fall in the animation range (40-59) + // skip the length-chain override — inflating their length would absorb the + // corresponding animation pairing on re-parse. + const lengthOverrides = computeLengthOverrides(td) + const ANIMATION_NOTE_MIN = 40 + const ANIMATION_NOTE_MAX = 59 + + const emitWithAnimSeq = (tick: number, length: number, noteNum: number): void => { + const animSeqs = animSeqMap.get(`${tick}:${noteNum}`) + if (!animSeqs) { + addNoteOnOff(events, tick, length, noteNum, 100, true) + return + } + const effectiveLength = Math.max(length, 1) + events.push({ + tick, + seq: animSeqs.onSeq, + event: { deltaTime: 0, channel: 0, type: 'noteOn', noteNumber: noteNum, velocity: 100 } as MidiEvent, + }) + events.push({ + tick: tick + effectiveLength, + seq: animSeqs.offSeq, + event: { deltaTime: 0, channel: 0, type: 'noteOff', noteNumber: noteNum, velocity: 0 } as MidiEvent, + }) + } + + for (const group of td.noteEventGroups) { + for (const note of group) { + const offsetForOverride = note.type === noteTypes.open + ? (useEnhancedOpens ? 0 : 1) + : (noteTypeToOffset[note.type] ?? 0) + const noteNumForOverride = diffStart + offsetForOverride + const inAnimationRange = noteNumForOverride >= ANIMATION_NOTE_MIN && noteNumForOverride <= ANIMATION_NOTE_MAX + const baseLen = inAnimationRange + ? note.length + : (lengthOverrides.get(`${note.tick}:${note.type}`) ?? note.length) + + if (note.type === noteTypes.open) { + if (useEnhancedOpens) { + const noteNum = diffStart + 0 + const animLength = animMap.get(`${note.tick}:${noteNum}`) + const len = (animLength != null && animLength > baseLen) ? animLength : baseLen + emitWithAnimSeq(note.tick, len, noteNum) + } else { + // forceOpen via SysEx (collapses chords — but useEnhancedOpens is only + // false when no chord-with-open exists, so no info is lost). + const noteNum = diffStart + 1 + const animLength = animMap.get(`${note.tick}:${noteNum}`) + const len = (animLength != null && animLength > baseLen) ? animLength : baseLen + emitWithAnimSeq(note.tick, len, noteNum) + addSysExOnOff(events, note.tick, 1, sysExDiffMap[td.difficulty], 0x01) + } + continue + } + const offset = noteTypeToOffset[note.type] + if (offset === undefined) continue + const noteNum = diffStart + offset + const animLength = animMap.get(`${note.tick}:${noteNum}`) + const len = (animLength != null && animLength > baseLen) ? animLength : baseLen + emitWithAnimSeq(note.tick, len, noteNum) + } + } +} + +function addSysExOnOff( + events: AbsoluteEvent[], + tick: number, + length: number, + diffByte: number, + typeByte: number, +): void { + events.push({ + tick, + event: { + deltaTime: 0, + type: 'sysEx', + data: new Uint8Array([0x50, 0x53, 0x00, 0x00, diffByte, typeByte, 0x01, 0xF7]), + } as MidiEvent, + }) + events.push({ + tick: tick + Math.max(length, 1), + event: { + deltaTime: 0, + type: 'sysEx', + data: new Uint8Array([0x50, 0x53, 0x00, 0x00, diffByte, typeByte, 0x00, 0xF7]), + } as MidiEvent, + }) +} + +// --------------------------------------------------------------------------- +// Force modifier range emission (forceHopo / forceStrum / forceTap) +// --------------------------------------------------------------------------- + +/** + * Emit force-modifier ranges (forceHopo / forceStrum / forceTap) when a + * note's resolved flag disagrees with the natural HOPO state the parser + * would pick without modifiers. Natural state is re-derived here so the + * output round-trips scan-chart's resolveFretModifiers. + */ +function emitFretModifierRanges( + events: AbsoluteEvent[], + trackDataEntries: ParsedTrack[], + isGhl: boolean, + chart: ParsedChart, +): void { + const diffStarts = isGhl ? sixFretDiffStarts : fiveFretDiffStarts + const hopoThreshold = computeHopoThresholdTicks( + chart.resolution, + chart.iniChartModifiers.hopo_frequency, + chart.iniChartModifiers.eighthnote_hopo, + 'mid', + ) + + for (const td of trackDataEntries) { + const difficulty = td.difficulty + const noteTicksInOrder: number[] = [] + const hopoTicks = new Set() + const strumTicks = new Set() + const tapTicks = new Set() + + let lastGroup: NoteEvent[] | null = null + for (const group of td.noteEventGroups) { + if (group.length === 0) continue + const tick = group[0].tick + noteTicksInOrder.push(tick) + + const flags = group[0].flags + const wantHopo = (flags & noteFlags.hopo) !== 0 + const wantStrum = (flags & noteFlags.strum) !== 0 + + const isNatHopo = isNaturalHopo(group, lastGroup, hopoThreshold, 'mid') + + if (wantHopo && !isNatHopo) hopoTicks.add(tick) + if (wantStrum && isNatHopo) strumTicks.add(tick) + if (flags & noteFlags.tap) tapTicks.add(tick) + + lastGroup = group + } + + const laneOffsets = isGhl ? sixFretLaneOffsets : fiveFretLaneOffsets + for (const range of reconstructModifierRanges(hopoTicks, noteTicksInOrder)) { + addNoteOnOff(events, range.tick, range.length, diffStarts[difficulty] + laneOffsets.forceHopo, 100) + } + for (const range of reconstructModifierRanges(strumTicks, noteTicksInOrder)) { + addNoteOnOff(events, range.tick, range.length, diffStarts[difficulty] + laneOffsets.forceStrum, 100) + } + for (const range of reconstructModifierRanges(tapTicks, noteTicksInOrder)) { + addSysExOnOff(events, range.tick, range.length, sysExDiffMap[difficulty], 0x04) + } + } +} + +/** + * Collapse a set of tick-keyed modifier hits into minimal covering ranges + * (contiguous runs over `noteTicksInOrder`). A range's length is + * `lastModifiedTick - rangeStart + 1`. + */ +function reconstructModifierRanges( + modifiedTicks: Set, + noteTicksInOrder: number[], +): { tick: number; length: number }[] { + if (modifiedTicks.size === 0) return [] + + const sorted = [...new Set(noteTicksInOrder)].sort((a, b) => a - b) + const ranges: { tick: number; length: number }[] = [] + let rangeStart: number | null = null + let lastModifiedTick: number | null = null + + for (const tick of sorted) { + if (modifiedTicks.has(tick)) { + if (rangeStart === null) rangeStart = tick + lastModifiedTick = tick + } else if (rangeStart !== null && lastModifiedTick !== null) { + ranges.push({ tick: rangeStart, length: lastModifiedTick - rangeStart + 1 }) + rangeStart = null + lastModifiedTick = null + } + } + if (rangeStart !== null && lastModifiedTick !== null) { + ranges.push({ tick: rangeStart, length: lastModifiedTick - rangeStart + 1 }) + } + return ranges +}