Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions src/__tests__/midi-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
})
})
47 changes: 47 additions & 0 deletions src/chart/midi-note-numbers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ export const drumsDiffStarts: Record<Difficulty, number> = {
expert: 96,
}

export const fiveFretDiffStarts: Record<Difficulty, number> = {
easy: 59,
medium: 71,
hard: 83,
expert: 95,
}

export const sixFretDiffStarts: Record<Difficulty, number> = {
easy: 58,
medium: 70,
hard: 82,
expert: 94,
}

/**
* Lane offsets from `drumsDiffStarts[difficulty]`. 2x-kick sits at -1 (the
* only lane below the base).
Expand All @@ -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
61 changes: 20 additions & 41 deletions src/chart/midi-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[] {
Expand Down Expand Up @@ -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)[ _](.*)$/
Expand Down Expand Up @@ -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
}
}

Expand Down
Loading