diff --git a/src/chart/chart-scanner.ts b/src/chart/chart-scanner.ts index 9619199..92cf656 100644 --- a/src/chart/chart-scanner.ts +++ b/src/chart/chart-scanner.ts @@ -7,6 +7,7 @@ import { base64url } from 'rfc4648' import { defaultMetadata } from 'src/ini' import { ChartIssueType, Difficulty, getInstrumentType, Instrument, instrumentTypes, NotesData } from '../interfaces' import { msToExactTime } from '../utils' +import { computeHopoThresholdTicks, isNaturalHopo } from './natural-hopo' import { IniChartModifiers, NoteEvent, noteFlags, NoteType, noteTypes } from './note-parsing-interfaces' import { ParsedChart } from './parse-chart-and-ini' import { calculateTrackHash, pruneEmptyPhrases } from './track-hasher' @@ -590,75 +591,6 @@ function int32ToUint8Array(num: number) { return new Uint8Array(buffer) } -// --------------------------------------------------------------------------- -// Natural HOPO detection (post-parse, operates on NoteEvent) -// -// Inverse of `resolveFretModifiers` in notes-parser.ts. The parser applies -// force events to produce per-note flags; here we re-derive whether a note -// would naturally be a HOPO so scanChart can detect flags that disagree with -// natural state (i.e., notes whose behavior came from a force event). -// --------------------------------------------------------------------------- - -const fretNoteTypeSet = new Set([ - noteTypes.open, noteTypes.green, noteTypes.red, noteTypes.yellow, noteTypes.blue, noteTypes.orange, - noteTypes.black1, noteTypes.black2, noteTypes.black3, - noteTypes.white1, noteTypes.white2, noteTypes.white3, -]) - -function isFretChord(group: NoteEvent[]): boolean { - let firstType: NoteType | null = null - for (const n of group) { - if (!fretNoteTypeSet.has(n.type)) continue - if (firstType === null) firstType = n.type - else if (firstType !== n.type) return true - } - return false -} - -function isSameFretNote(a: NoteEvent[], b: NoteEvent[]): boolean { - const aT: NoteType[] = [] - for (const n of a) if (fretNoteTypeSet.has(n.type)) aT.push(n.type) - const bT: NoteType[] = [] - for (const n of b) if (fretNoteTypeSet.has(n.type)) bT.push(n.type) - if (aT.length !== bT.length) return false - const s = new Set(bT) - for (const t of aT) if (!s.has(t)) return false - return true -} - -function isInFretNote(inner: NoteEvent[], outer: NoteEvent[]): boolean { - const o = new Set() - for (const n of outer) if (fretNoteTypeSet.has(n.type)) o.add(n.type) - for (const n of inner) if (fretNoteTypeSet.has(n.type) && !o.has(n.type)) return false - return true -} - -function computeHopoThresholdTicks( - resolution: number, - iniHopoFreq: number, - eighthnoteHopo: boolean, - format: 'chart' | 'mid', -): number { - if (iniHopoFreq) return iniHopoFreq - if (eighthnoteHopo) return Math.floor(1 + resolution / 2) - return Math.floor(format === 'mid' ? 1 + resolution / 3 : (65 / 192) * resolution) -} - -function isNaturalHopo( - current: NoteEvent[], - last: NoteEvent[] | null, - hopoThresholdTicks: number, - format: 'chart' | 'mid', -): boolean { - if (!last) return false - if (current[0].tick - last[0].tick > hopoThresholdTicks) return false - if (isFretChord(current)) return false - if (!isFretChord(last) && isSameFretNote(current, last)) return false - // .mid-specific exception for back-compat with older games. - if (format === 'mid' && isFretChord(last) && isInFretNote(current, last)) return false - return true -} - /** * Included for legacy testing purposes */ diff --git a/src/chart/natural-hopo.ts b/src/chart/natural-hopo.ts new file mode 100644 index 0000000..7ccac80 --- /dev/null +++ b/src/chart/natural-hopo.ts @@ -0,0 +1,174 @@ +/** + * Natural-HOPO helpers — shared by the parser, scanner, and writers. + * + * All three places need to answer the same structural questions about a fret + * group (is it a chord? does it equal the previous group? is it a subset of + * the previous group?) and whether the group is a "natural HOPO" (would + * resolve to HOPO without any force modifiers): + * + * - Parser (resolveFretModifiers in notes-parser.ts) decides the group's + * resolved hopo/strum flag at parse time. Operates on `TrackEvent[]` + * (pre-resolution; `.type` is `EventType`). + * - Scanner (chart-scanner.ts) re-derives `hasForcedNotes` after the + * fact. Operates on `NoteEvent[]` (post-resolution; `.type` is `NoteType`). + * - Writers (chart-writer.ts, midi-writer.ts) decide whether to emit a + * force-* modifier — only when the resolved flag disagrees with natural. + * Operates on `NoteEvent[]`. + * + * `NoteType` and `EventType` use different numeric values for the same fret + * colors, so the helpers are parameterized over a per-enum "is this a fret + * note?" predicate, with thin wrappers exported for each concrete type. + */ + +import type { EventType, NoteEvent, RawChartData } from './note-parsing-interfaces' +import { eventTypes, noteTypes, NoteType } from './note-parsing-interfaces' + +type TrackEvent = RawChartData['trackData'][number]['trackEvents'][number] + +// --------------------------------------------------------------------------- +// Per-enum "is this a fret note?" predicates. +// --------------------------------------------------------------------------- + +const fretNoteTypes = new Set([ + noteTypes.open, noteTypes.green, noteTypes.red, noteTypes.yellow, noteTypes.blue, noteTypes.orange, + noteTypes.black1, noteTypes.black2, noteTypes.black3, + noteTypes.white1, noteTypes.white2, noteTypes.white3, +]) +const fretEventTypes = new Set([ + eventTypes.open, eventTypes.green, eventTypes.red, eventTypes.yellow, eventTypes.blue, eventTypes.orange, + eventTypes.black1, eventTypes.black2, eventTypes.black3, + eventTypes.white1, eventTypes.white2, eventTypes.white3, +]) + +export const isFretNoteType = (t: NoteType): boolean => fretNoteTypes.has(t) +export const isFretEventType = (t: EventType): boolean => fretEventTypes.has(t) + +// --------------------------------------------------------------------------- +// Generic fret-group helpers. +// +// Each takes the group plus an `isFret` predicate that matches the group's +// element-type enum. Internal — use the NoteEvent / TrackEvent specializations +// exported below. +// --------------------------------------------------------------------------- + +function isFretChordGeneric( + group: E[], + isFret: (t: T) => boolean, +): boolean { + let firstType: T | null = null + for (const n of group) { + if (!isFret(n.type)) continue + if (firstType === null) firstType = n.type + else if (firstType !== n.type) return true + } + return false +} + +function isSameFretNoteGeneric( + a: E[], + b: E[], + isFret: (t: T) => boolean, +): boolean { + const aT: T[] = [] + for (const n of a) if (isFret(n.type)) aT.push(n.type) + const bT: T[] = [] + for (const n of b) if (isFret(n.type)) bT.push(n.type) + if (aT.length !== bT.length) return false + const s = new Set(bT) + for (const t of aT) if (!s.has(t)) return false + return true +} + +function isInFretNoteGeneric( + inner: E[], + outer: E[], + isFret: (t: T) => boolean, +): boolean { + const o = new Set() + for (const n of outer) if (isFret(n.type)) o.add(n.type) + for (const n of inner) if (isFret(n.type) && !o.has(n.type)) return false + return true +} + +// --------------------------------------------------------------------------- +// NoteEvent specializations — used by the scanner and writers. +// --------------------------------------------------------------------------- + +export function isFretChord(group: NoteEvent[]): boolean { + return isFretChordGeneric(group, isFretNoteType) +} +export function isSameFretNote(a: NoteEvent[], b: NoteEvent[]): boolean { + return isSameFretNoteGeneric(a, b, isFretNoteType) +} +export function isInFretNote(inner: NoteEvent[], outer: NoteEvent[]): boolean { + return isInFretNoteGeneric(inner, outer, isFretNoteType) +} + +// --------------------------------------------------------------------------- +// TrackEvent specializations — used by the parser's resolveFretModifiers. +// --------------------------------------------------------------------------- + +export function isFretChordRawEvents(group: TrackEvent[]): boolean { + return isFretChordGeneric(group, isFretEventType) +} +export function isSameFretNoteRawEvents(a: TrackEvent[], b: TrackEvent[]): boolean { + return isSameFretNoteGeneric(a, b, isFretEventType) +} +export function isInFretNoteRawEvents(inner: TrackEvent[], outer: TrackEvent[]): boolean { + return isInFretNoteGeneric(inner, outer, isFretEventType) +} + +// --------------------------------------------------------------------------- +// HOPO threshold + NoteEvent-based natural-HOPO rule. +// +// The parser does its own natural-HOPO check inline (different variable +// shape — effectiveNotes vs events, etc.), and calls the individual +// *RawEvents helpers above. Scanner + writers use the NoteEvent form +// through this wrapper. +// --------------------------------------------------------------------------- + +/** + * Compute the natural-HOPO threshold in ticks. Mirrors the formula the parser + * uses in `resolveFretModifiers`: + * + * - if `iniHopoFreq` is set (non-zero), it wins outright + * - else if `eighthnoteHopo`, use `floor(1 + resolution/2)` + * - else, the default differs by format: + * - `.mid` : `floor(1 + resolution/3)` + * - `.chart`: `floor((65/192) * resolution)` + */ +export function computeHopoThresholdTicks( + resolution: number, + iniHopoFreq: number, + eighthnoteHopo: boolean, + format: 'chart' | 'mid', +): number { + if (iniHopoFreq) return iniHopoFreq + if (eighthnoteHopo) return Math.floor(1 + resolution / 2) + return Math.floor(format === 'mid' ? 1 + resolution / 3 : (65 / 192) * resolution) +} + +/** + * True if `current` would resolve to HOPO with no force modifiers. Rules: + * + * 1. No previous group → not a natural HOPO. + * 2. Gap from previous group > threshold → strum. + * 3. Current is a chord → strum. + * 4. Previous is a single note and current is the same single note → strum. + * 5. `.mid` only: previous is a chord and current is a subset of it → strum + * (back-compat exception for older games). + * 6. Otherwise → natural HOPO. + */ +export function isNaturalHopo( + current: NoteEvent[], + last: NoteEvent[] | null, + hopoThresholdTicks: number, + format: 'chart' | 'mid', +): boolean { + if (!last) return false + if (current[0].tick - last[0].tick > hopoThresholdTicks) return false + if (isFretChord(current)) return false + if (!isFretChord(last) && isSameFretNote(current, last)) return false + if (format === 'mid' && isFretChord(last) && isInFretNote(current, last)) return false + return true +} diff --git a/src/chart/notes-parser.ts b/src/chart/notes-parser.ts index 5893a6b..51bd263 100644 --- a/src/chart/notes-parser.ts +++ b/src/chart/notes-parser.ts @@ -22,6 +22,12 @@ import { VocalTrackData, } from './note-parsing-interfaces' import { parseLyricFlags, stripLyricSymbols } from './lyric-parser' +import { + isFretChordRawEvents, + isFretEventType, + isInFretNoteRawEvents, + isSameFretNoteRawEvents, +} from './natural-hopo' type TrackEvent = RawChartData['trackData'][number]['trackEvents'][number] type UntimedNoteEvent = Omit @@ -783,7 +789,7 @@ function resolveFretModifiers( let longestNote: TrackEvent | null = null for (const e of events) { const t = e.type - if (isFretNote(t)) { + if (isFretEventType(t)) { notes.push(e) if (!longestNote || e.length > longestNote.length) longestNote = e } else if (t === eventTypes.forceOpen) { @@ -810,7 +816,7 @@ function resolveFretModifiers( let w = 0 for (let r = 0; r < events.length; r++) { const et = events[r].type - if (!isFretNote(et) && et !== eventTypes.forceOpen) events[w++] = events[r] + if (!isFretEventType(et) && et !== eventTypes.forceOpen) events[w++] = events[r] } events.length = w events.push(longestNote) @@ -823,10 +829,10 @@ function resolveFretModifiers( const isNaturalHopo = !!lastNotes && effectiveNotes[0].tick - lastNotes[0].tick <= hopoThresholdTicks && - !isFretChord(effectiveNotes) && - !isSameFretNote(events, lastNotes) && + !isFretChordRawEvents(effectiveNotes) && + !isSameFretNoteRawEvents(events, lastNotes) && // This .mid exception is due to compatibility concerns with older games that primarily use .mid - !(format === 'mid' && isFretChord(lastNotes) && isInFretNote(effectiveNotes, lastNotes)) + !(format === 'mid' && isFretChordRawEvents(lastNotes) && isInFretNoteRawEvents(effectiveNotes, lastNotes)) const forceResult = hasForceTap ? noteFlags.tap : hasForceHopo ? noteFlags.hopo @@ -851,89 +857,6 @@ function resolveFretModifiers( return noteEventGroups } -function isFretNote(type: EventType) { - switch (type) { - case eventTypes.open: - case eventTypes.green: - case eventTypes.red: - case eventTypes.yellow: - case eventTypes.blue: - case eventTypes.orange: - case eventTypes.black3: - case eventTypes.black2: - case eventTypes.black1: - case eventTypes.white3: - case eventTypes.white2: - case eventTypes.white1: - return true - default: - return false - } -} - -function isSameFretNote(note1: TrackEvent[], note2: TrackEvent[]) { - for (const n1 of note1) { - if (!isFretNote(n1.type)) { - continue - } - - for (const n2 of note2) { - if (!isFretNote(n2.type)) { - continue - } - - if (n1.type !== n2.type) { - return false - } - } - } - - for (const n2 of note2) { - if (!isFretNote(n2.type)) { - continue - } - - for (const n1 of note1) { - if (!isFretNote(n1.type)) { - continue - } - - if (n2.type !== n1.type) { - return false - } - } - } - - return true -} - -function isFretChord(note: TrackEvent[]) { - let firstNoteType: EventType | null = null - for (const n of note) { - if (isFretNote(n.type)) { - if (firstNoteType === null) { - firstNoteType = n.type - } else if (firstNoteType !== n.type) { - return true - } - } - } - return false -} - -function isInFretNote(inNote: TrackEvent[], outerNote: TrackEvent[]) { - // True if every fret note type in `inNote` also appears in `outerNote`. - for (const n of inNote) { - if (!isFretNote(n.type)) continue - let found = false - for (const o of outerNote) { - if (o.type === n.type) { found = true; break } - } - if (!found) return false - } - return true -} - function getFretNoteTypeFromEventType(eventType: EventType): NoteType | null { switch (eventType) { case eventTypes.open: