From ec22459826351176a4e4a30db25c4fc114d3ce53 Mon Sep 17 00:00:00 2001 From: Eli White Date: Sun, 19 Apr 2026 21:31:16 -0700 Subject: [PATCH] Extract shared natural-HOPO helpers into natural-hopo.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chart scanner, chart writer, and MIDI writer each had their own near-identical copies of isFretChord / isSameFretNote / isInFretNote / isNaturalHopo over NoteEvent[], and the parser had the same three helpers again over TrackEvent[]. This consolidates all five callsites into src/chart/natural-hopo.ts. The helpers are generic ( + an isFret predicate); thin wrappers are exported for each concrete group type: - NoteEvent-based (scanner + writers): isFretChord / isSameFretNote / isInFretNote, plus the combined isNaturalHopo. - TrackEvent-based (parser's resolveFretModifiers): isFretChordRawEvents / isSameFretNoteRawEvents / isInFretNoteRawEvents. The parser still builds its natural-HOPO check inline — it compares effectiveNotes to lastNotes while passing the raw pre-coalesced events to isSameFretNoteRawEvents, a subtlety no NoteEvent callsite has. The isFretNoteType / isFretEventType predicates are also exported, so the parser's two standalone isFretNote(...) callsites (filtering event lists outside the group helpers) share the same definition too. The parser's four local helpers (isFretNote, isSameFretNote, isFretChord, isInFretNote) are deleted. Net: ~70 LOC of duplication removed across scanner, parser, and (in the writer branches further up the stack) the two writers. One file owns every natural-HOPO rule. This commit updates the scanner + parser; the writers move to the shared helpers in the two branches further up the stack that own them. --- src/chart/chart-scanner.ts | 70 +-------------- src/chart/natural-hopo.ts | 174 +++++++++++++++++++++++++++++++++++++ src/chart/notes-parser.ts | 99 +++------------------ 3 files changed, 186 insertions(+), 157 deletions(-) create mode 100644 src/chart/natural-hopo.ts 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: