Skip to content
Closed
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
70 changes: 1 addition & 69 deletions src/chart/chart-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<NoteType>([
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<NoteType>()
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
*/
Expand Down
174 changes: 174 additions & 0 deletions src/chart/natural-hopo.ts
Original file line number Diff line number Diff line change
@@ -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<NoteType>([
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<EventType>([
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<T, E extends { type: T }>(
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<T, E extends { type: T }>(
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<T, E extends { type: T }>(
inner: E[],
outer: E[],
isFret: (t: T) => boolean,
): boolean {
const o = new Set<T>()
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
}
99 changes: 11 additions & 88 deletions src/chart/notes-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NoteEvent, 'msTime' | 'msLength'>
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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:
Expand Down