diff --git a/.changeset/smart-ducks-eat.md b/.changeset/smart-ducks-eat.md new file mode 100644 index 0000000..012abaa --- /dev/null +++ b/.changeset/smart-ducks-eat.md @@ -0,0 +1,5 @@ +--- +"scan-chart": minor +--- + +Add chord_snap_threshold diff --git a/readme.md b/readme.md index 89439e6..8ac8ff2 100644 --- a/readme.md +++ b/readme.md @@ -84,10 +84,16 @@ interface Chart { /** Overrides the .mid note number for Star Power on 5-Fret Guitar. Valid values are 103 and 116. Only applies to .mid charts. */ multiplier_note?: number /** - * For .mid charts, setting this causes any sustains shorter than the threshold (in number of ticks) to be reduced to length 0. + * For .mid charts, setting this causes any sustains not larger than the threshold (in number of ticks) to be reduced to length 0. * By default, this happens to .mid sustains shorter than 1/12 step. */ sustain_cutoff_threshold?: number + /** + * Notes at or closer than this threshold (in number of ticks) will be merged into a chord. + * All note and modifier ticks are set to the tick of the earliest merged note. + * All note sustains are set to the length of the shortest merged note. + */ + chord_snap_threshold?: number /** * The amount of time that should be skipped from the beginning of the video background in milliseconds. * A negative value will delay the start of the video by that many milliseconds. @@ -309,32 +315,6 @@ type NoteType = | 'blueTomOrCymbalMarker' | 'greenTomOrCymbalMarker' -type FolderIssueType = - | 'noMetadata' // This chart doesn't have "song.ini" - | 'invalidIni' // .ini file is not named "song.ini" - | 'invalidMetadata' // "song.ini" doesn't have a "[Song]" section - | 'badIniLine' // This line in "song.ini" couldn't be parsed - | 'multipleIniFiles' // This chart has multiple .ini files - | 'noAlbumArt' // This chart doesn't have album art - | 'albumArtSize' // This chart's album art is not 500x500 or 512x512 - | 'badAlbumArt' // This chart's album art couldn't be parsed - | 'multipleAlbumArt' // This chart has multiple album art files - | 'noAudio' // This chart doesn't have an audio file - | 'invalidAudio' // Audio file is not a valid audio stem name - | 'badAudio' // This chart's audio couldn't be parsed - | 'multipleAudio' // This chart has multiple audio files of the same stem - | 'noChart' // This chart doesn't have "notes.chart"/"notes.mid" - | 'invalidChart' // .chart/.mid file is not named "notes.chart"/"notes.mid" - | 'badChart' // This chart's .chart/.mid file couldn't be parsed - | 'multipleChart' // This chart has multiple .chart/.mid files - | 'badVideo' // This chart has a video background that will not work on Linux - | 'multipleVideo' // This chart has multiple video background files - -type MetadataIssueType = - | 'missingValue' // Metadata is missing a required value - | 'invalidValue' // Metadata property was set to an unsupported value - | 'extraValue' // Metadata contains a property that should not be included - interface ParsedChart { resolution: number drumType: DrumType | null diff --git a/src/chart/chart-scanner.ts b/src/chart/chart-scanner.ts index a3fd519..a02e1d6 100644 --- a/src/chart/chart-scanner.ts +++ b/src/chart/chart-scanner.ts @@ -4,6 +4,7 @@ import { md5 } from 'js-md5' import * as _ from 'lodash' import { base64url } from 'rfc4648' +import { defaultMetadata } from 'src/ini' import { ChartIssueType, Difficulty, FolderIssueType, getInstrumentType, Instrument, instrumentTypes, NotesData } from '../interfaces' import { getExtension, hasChartExtension, hasChartName, msToExactTime } from '../utils' import { IniChartModifiers, NoteEvent, noteFlags, NoteType, noteTypes } from './note-parsing-interfaces' @@ -454,7 +455,6 @@ function findChartIssues( } } - if (instrumentType !== instrumentTypes.drums) { // brokenNote { for (let i = 1; i < track.noteEventGroups.length; i++) { @@ -473,6 +473,7 @@ function findChartIssues( } } + if (instrumentType !== instrumentTypes.drums) { // badSustainGap, babySustain { /** Sustain gaps at the end of notes already checked in the for loop. `startTime` is inclusive, `endTime` is exclusive. */ @@ -526,18 +527,47 @@ function typeCount(noteGroup: NoteEvent[], types: NoteType[]) { } function getChartHash(chartBytes: Uint8Array, iniChartModifiers: IniChartModifiers) { - const iniChartModifierSize = 4 + 1 + 4 + 4 + 1 + 1 - const buffer = new ArrayBuffer(chartBytes.length + iniChartModifierSize) + const hashedIniModifiers = ( + [ + { name: 'hopo_frequency', value: iniChartModifiers.hopo_frequency }, + { name: 'eighthnote_hopo', value: iniChartModifiers.eighthnote_hopo }, + { name: 'multiplier_note', value: iniChartModifiers.multiplier_note }, + { name: 'sustain_cutoff_threshold', value: iniChartModifiers.sustain_cutoff_threshold }, + { name: 'chord_snap_threshold', value: iniChartModifiers.chord_snap_threshold }, + { name: 'five_lane_drums', value: iniChartModifiers.five_lane_drums }, + { name: 'pro_drums', value: iniChartModifiers.pro_drums }, + ] as const + ) + .filter(modifier => modifier.value !== defaultMetadata[modifier.name]) + .map(modifier => ({ + name: new TextEncoder().encode(modifier.name), + value: int32ToUint8Array( + typeof modifier.value === 'number' ? modifier.value + : modifier.value === true ? 1 + : 0, + ), + })) + + const hashedIniModifiersLength = _.sumBy(hashedIniModifiers, modifier => modifier.name.length + modifier.value.length) + const buffer = new ArrayBuffer(chartBytes.length + hashedIniModifiersLength) const uint8Array = new Uint8Array(buffer) uint8Array.set(chartBytes) - const view = new DataView(buffer, chartBytes.length) - view.setInt32(0, iniChartModifiers.hopo_frequency) - view.setInt8(4, iniChartModifiers.eighthnote_hopo ? 1 : 0) - view.setInt32(5, iniChartModifiers.multiplier_note) - view.setInt32(9, iniChartModifiers.sustain_cutoff_threshold) - view.setInt8(13, iniChartModifiers.five_lane_drums ? 1 : 0) - view.setInt8(14, iniChartModifiers.pro_drums ? 1 : 0) + let offset = chartBytes.length + for (const modifier of hashedIniModifiers) { + uint8Array.set(modifier.name, offset) + offset += modifier.name.length + uint8Array.set(modifier.value, offset) + offset += modifier.value.length + } return base64url.stringify(blake3(uint8Array)) } + +function int32ToUint8Array(num: number) { + const buffer = new ArrayBuffer(4) + const view = new DataView(buffer) + view.setInt32(0, num, true) + + return new Uint8Array(buffer) +} diff --git a/src/chart/note-parsing-interfaces.ts b/src/chart/note-parsing-interfaces.ts index 725af9b..7a68fbc 100644 --- a/src/chart/note-parsing-interfaces.ts +++ b/src/chart/note-parsing-interfaces.ts @@ -7,6 +7,7 @@ export interface IniChartModifiers { eighthnote_hopo: boolean multiplier_note: number sustain_cutoff_threshold: number + chord_snap_threshold: number five_lane_drums: boolean pro_drums: boolean } diff --git a/src/chart/notes-parser.ts b/src/chart/notes-parser.ts index 73eb60a..2056384 100644 --- a/src/chart/notes-parser.ts +++ b/src/chart/notes-parser.ts @@ -1,11 +1,12 @@ import * as _ from 'lodash' -import { DrumType, drumTypes } from 'src/interfaces' +import { DrumType, drumTypes, Instrument } from 'src/interfaces' import { parseNotesFromChart } from './chart-parser' import { parseNotesFromMidi } from './midi-parser' import { EventType, eventTypes, IniChartModifiers, NoteEvent, noteFlags, NoteType, noteTypes, RawChartData } from './note-parsing-interfaces' -type TimedTrackEvent = RawChartData['trackData'][number]['trackEvents'][number] & { msTime: number; msLength: number } +type TrackEvent = RawChartData['trackData'][number]['trackEvents'][number] +type UntimedNoteEvent = Omit export type ParsedChart = ReturnType @@ -41,50 +42,44 @@ export function parseChartFile(data: Uint8Array, format: 'chart' | 'mid', iniCha hasLyrics: rawChartData.hasLyrics, hasVocals: rawChartData.hasVocals, hasForcedNotes, - endEvents: getTimedEvents(rawChartData.endEvents, timedTempos, rawChartData.chartTicksPerBeat), + endEvents: setEventMsTimes(rawChartData.endEvents, timedTempos, rawChartData.chartTicksPerBeat), tempos: timedTempos, - timeSignatures: getTimedEvents(rawChartData.timeSignatures, timedTempos, rawChartData.chartTicksPerBeat), - sections: getTimedEvents(rawChartData.sections, timedTempos, rawChartData.chartTicksPerBeat), + timeSignatures: setEventMsTimes(rawChartData.timeSignatures, timedTempos, rawChartData.chartTicksPerBeat), + sections: setEventMsTimes(rawChartData.sections, timedTempos, rawChartData.chartTicksPerBeat), trackData: _.chain(rawChartData.trackData) .map(track => ({ instrument: track.instrument, difficulty: track.difficulty, starPowerSections: _.chain(track.starPowerSections) - .thru(events => getTimedEvents(events, timedTempos, rawChartData.chartTicksPerBeat)) + .thru(events => setEventMsTimes(events, timedTempos, rawChartData.chartTicksPerBeat)) .thru(events => sortAndFixInvalidEventOverlaps(events)) .value(), rejectedStarPowerSections: _.chain(track.rejectedStarPowerSections) - .thru(events => getTimedEvents(events, timedTempos, rawChartData.chartTicksPerBeat)) + .thru(events => setEventMsTimes(events, timedTempos, rawChartData.chartTicksPerBeat)) .value(), soloSections: _.chain(track.soloSections) - .thru(events => getTimedEvents(events, timedTempos, rawChartData.chartTicksPerBeat)) + .thru(events => setEventMsTimes(events, timedTempos, rawChartData.chartTicksPerBeat)) .thru(events => sortAndFixInvalidEventOverlaps(events)) .value(), flexLanes: _.chain(track.flexLanes) - .thru(events => getTimedEvents(events, timedTempos, rawChartData.chartTicksPerBeat)) + .thru(events => setEventMsTimes(events, timedTempos, rawChartData.chartTicksPerBeat)) .thru(events => sortAndFixInvalidFlexLaneOverlaps(events)) .value(), - drumFreestyleSections: getTimedEvents(track.drumFreestyleSections, timedTempos, rawChartData.chartTicksPerBeat), - trackEventGroups: _.chain(track.trackEvents) - .thru(events => getTimedEvents(events, timedTempos, rawChartData.chartTicksPerBeat)) - .thru(events => trimSustains(events, iniChartModifiers, rawChartData.chartTicksPerBeat, format)) + drumFreestyleSections: setEventMsTimes(track.drumFreestyleSections, timedTempos, rawChartData.chartTicksPerBeat), + noteEventGroups: _.chain(track.trackEvents) + .thru(events => trimSustains(events, iniChartModifiers.sustain_cutoff_threshold, rawChartData.chartTicksPerBeat, format)) .groupBy(note => note.tick) .values() + .thru(eventGroups => + track.instrument === 'drums' ? + resolveDrumModifiers(eventGroups, drumType!, format) + : resolveFretModifiers(eventGroups, iniChartModifiers, rawChartData.chartTicksPerBeat, format), + ) + .thru(noteGroups => snapChords(noteGroups, iniChartModifiers.chord_snap_threshold, track.instrument)) + .tap(noteGroups => sortAndFixInvalidNoteOverlaps(noteGroups)) + .thru(events => setEventGroupMsTimes(events, timedTempos, rawChartData.chartTicksPerBeat)) .value(), })) - .map(track => ({ - ...track, - noteEventGroups: - track.instrument === 'drums' ? - resolveDrumModifiers(track.trackEventGroups, drumType!, format) - : resolveFretModifiers(track.trackEventGroups, iniChartModifiers, rawChartData.chartTicksPerBeat, format), - })) - .tap(tracks => tracks.forEach(track => sortAndFixInvalidNoteOverlaps(track.noteEventGroups))) - .map(track => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - delete (track as any).trackEventGroups // Save memory - return track as Omit - }) .value(), } } @@ -123,15 +118,28 @@ function isCymbalOrTomMarker(type: EventType) { } } -function getTimedEvents( +function setEventGroupMsTimes( + events: T[][], + tempos: { tick: number; millibeatsPerMinute: number; msTime: number }[], + chartTicksPerBeat: number, +) { + return setEventOrEventGroupMsTimes(events, tempos, chartTicksPerBeat) as (T & { msTime: number; msLength: number })[][] +} +function setEventMsTimes( events: T[], tempos: { tick: number; millibeatsPerMinute: number; msTime: number }[], chartTicksPerBeat: number, -): (T & { msTime: number; msLength: number })[] { +) { + return setEventOrEventGroupMsTimes(events, tempos, chartTicksPerBeat) as (T & { msTime: number; msLength: number })[] +} +function setEventOrEventGroupMsTimes( + events: T[] | T[][], + tempos: { tick: number; millibeatsPerMinute: number; msTime: number }[], + chartTicksPerBeat: number, +) { let lastTempoIndex = 0 - const newEvents: (T & { msTime: number; msLength: number })[] = [] - for (const event of events) { + const processNextEvent = (event: T) => { while (tempos[lastTempoIndex + 1] && tempos[lastTempoIndex + 1].tick <= event.tick) { lastTempoIndex++ } @@ -154,35 +162,46 @@ function getTimedEvents( } else { newEvent.msLength = 0 } - newEvents.push(newEvent) } - return newEvents + if (Array.isArray(events[0])) { + for (const eventGroup of events as T[][]) { + for (const event of eventGroup) { + processNextEvent(event) + } + } + } else { + for (const event of events as T[]) { + processNextEvent(event) + } + } + return events as (T & { msTime: number; msLength: number })[][] | (T & { msTime: number; msLength: number })[] } function trimSustains( - trackEvents: { tick: number; length: number; type: EventType; msTime: number; msLength: number }[], - iniChartModifiers: IniChartModifiers, + trackEvents: { tick: number; length: number; type: EventType }[], + sustain_cutoff_threshold: number, chartTicksPerBeat: number, format: 'chart' | 'mid', ) { const sustainThresholdTicks = - iniChartModifiers.sustain_cutoff_threshold !== -1 ? iniChartModifiers.sustain_cutoff_threshold + sustain_cutoff_threshold !== -1 ? sustain_cutoff_threshold : format === 'mid' ? Math.floor(chartTicksPerBeat / 3) + 1 : 0 + if (sustainThresholdTicks > 0) { for (const event of trackEvents) { if (event.length <= sustainThresholdTicks) { event.length = 0 - event.msLength = 0 + } } } return trackEvents } -function resolveDrumModifiers(trackEventGroups: TimedTrackEvent[][], drumType: DrumType, format: 'chart' | 'mid'): NoteEvent[][] { - const noteEventGroups: NoteEvent[][] = [] +function resolveDrumModifiers(trackEventGroups: TrackEvent[][], drumType: DrumType, format: 'chart' | 'mid'): UntimedNoteEvent[][] { + const noteEventGroups: UntimedNoteEvent[][] = [] const discoFlipEventTypes = [eventTypes.discoFlipOff, eventTypes.discoFlipOn, eventTypes.discoNoFlipOn] as const let activeDiscoFlip: (typeof discoFlipEventTypes)[number] = eventTypes.discoFlipOff @@ -201,16 +220,14 @@ function resolveDrumModifiers(trackEventGroups: TimedTrackEvent[][], drumType: D continue // Skip any event groups with only modifiers } - const noteEventGroup: NoteEvent[] = [] + const noteEventGroup: UntimedNoteEvent[] = [] const flamFlag = modifiers.find(e => e === eventTypes.forceFlam) ? noteFlags.flam : noteFlags.none for (const kick of kicks) { const kickTypeFlag = kick.type === eventTypes.kick ? noteFlags.none : noteFlags.doubleKick noteEventGroup.push({ tick: kick.tick, - msTime: kick.msTime, length: kick.length, - msLength: kick.msLength, type: noteTypes.kick, flags: flamFlag | kickTypeFlag | getGhostOrAccentFlags(kick.type, modifiers), }) @@ -229,9 +246,7 @@ function resolveDrumModifiers(trackEventGroups: TimedTrackEvent[][], drumType: D const baseFlags = flamFlag | discoFlag noteEventGroup.push({ tick: note.tick, - msTime: note.msTime, length: note.length, - msLength: note.msLength, type, flags: baseFlags | getTomOrCymbalFlags(note.type, modifiers, drumType, format) | getGhostOrAccentFlags(note.type, modifiers), }) @@ -394,20 +409,20 @@ function getGhostOrAccentFlags(eventType: EventType, modifiers: EventType[]) { } function resolveFretModifiers( - trackEventGroups: TimedTrackEvent[][], + trackEventGroups: TrackEvent[][], iniChartModifiers: IniChartModifiers, chartTicksPerBeat: number, format: 'chart' | 'mid', -): NoteEvent[][] { +): UntimedNoteEvent[][] { const hopoThresholdTicks = iniChartModifiers.hopo_frequency || (iniChartModifiers.eighthnote_hopo ? Math.floor(1 + chartTicksPerBeat / 2) : Math.floor(format === 'mid' ? 1 + chartTicksPerBeat / 3 : (65 / 192) * chartTicksPerBeat)) - const noteEventGroups: NoteEvent[][] = [] + const noteEventGroups: UntimedNoteEvent[][] = [] - let lastNotes: TimedTrackEvent[] | null = null + let lastNotes: TrackEvent[] | null = null // trackEventGroups only contain notes and note modifiers for (let i = 0; i < trackEventGroups.length; i++) { const events = trackEventGroups[i] @@ -445,9 +460,7 @@ function resolveFretModifiers( noteEventGroups.push( notes.map(n => ({ tick: n.tick, - msTime: n.msTime, length: n.length, - msLength: n.msLength, type: getFretNoteTypeFromEventType(n.type)!, // Should be the only event types at this point flags: forceResult, })), @@ -479,7 +492,7 @@ function isFretNote(type: EventType) { } } -function isSameFretNote(note1: TimedTrackEvent[], note2: TimedTrackEvent[]) { +function isSameFretNote(note1: TrackEvent[], note2: TrackEvent[]) { for (const n1 of note1) { if (!isFretNote(n1.type)) { continue @@ -515,7 +528,7 @@ function isSameFretNote(note1: TimedTrackEvent[], note2: TimedTrackEvent[]) { return true } -function isFretChord(note: TimedTrackEvent[]) { +function isFretChord(note: TrackEvent[]) { let firstNoteType: EventType | null = null for (const n of note) { if (isFretNote(n.type)) { @@ -529,7 +542,7 @@ function isFretChord(note: TimedTrackEvent[]) { return false } -function isInFretNote(inNote: TimedTrackEvent[], outerNote: TimedTrackEvent[]) { +function isInFretNote(inNote: TrackEvent[], outerNote: TrackEvent[]) { return ( _.differenceBy( inNote.filter(n => isFretNote(n.type)), @@ -570,6 +583,68 @@ function getFretNoteTypeFromEventType(eventType: EventType): NoteType | null { } } +function snapChords(noteGroups: UntimedNoteEvent[][], chord_snap_threshold: number, instrument: Instrument) { + if (chord_snap_threshold <= 0 || noteGroups.length === 0) { + return noteGroups + } + + const newNoteGroups: UntimedNoteEvent[][] = [noteGroups[0]] + + for (let i = 1; i < noteGroups.length; i++) { + const noteGroup = noteGroups[i] + const lastNoteGroup = _.last(newNoteGroups)! + + if (noteGroup[0].tick - lastNoteGroup[0].tick >= chord_snap_threshold) { + newNoteGroups.push(noteGroup) + } else { + // Resolve flag differences between the note groups + if (instrument === 'drums') { + for (const note of noteGroup) { + if (note.type === noteTypes.kick) { + const lastKickFlags = lastNoteGroup.find(n => n.type === noteTypes.kick)?.flags ?? null + note.flags = lastKickFlags === null ? note.flags : lastKickFlags + } else if (note.type === noteTypes.redDrum) { + const lastRedDrumFlags = lastNoteGroup.find(n => n.type === noteTypes.redDrum)?.flags ?? null + note.flags = lastRedDrumFlags === null ? note.flags : lastRedDrumFlags + } else if (note.type === noteTypes.yellowDrum) { + const lastYellowDrumFlags = lastNoteGroup.find(n => n.type === noteTypes.yellowDrum)?.flags ?? null + note.flags = lastYellowDrumFlags === null ? note.flags : lastYellowDrumFlags + } else if (note.type === noteTypes.blueDrum) { + const lastBlueDrumFlags = lastNoteGroup.find(n => n.type === noteTypes.blueDrum)?.flags ?? null + note.flags = lastBlueDrumFlags === null ? note.flags : lastBlueDrumFlags + } else if (note.type === noteTypes.greenDrum) { + const lastGreenDrumFlags = lastNoteGroup.find(n => n.type === noteTypes.greenDrum)?.flags ?? null + note.flags = lastGreenDrumFlags === null ? note.flags : lastGreenDrumFlags + } + + // Handle edge case with resolving disco and discoNoflip modifier differences on red and yellow drum notes + if (note.type === noteTypes.redDrum || note.type === noteTypes.yellowDrum) { + const lastRedDrumFlags = lastNoteGroup.find(n => n.type === noteTypes.redDrum)?.flags ?? null + const lastYellowDrumFlags = lastNoteGroup.find(n => n.type === noteTypes.yellowDrum)?.flags ?? null + if (lastRedDrumFlags !== null || lastYellowDrumFlags !== null) { + const discoNoteFlags = noteFlags.disco | noteFlags.discoNoflip + const lastDiscoEventFlags = ((lastRedDrumFlags ?? 0) | (lastYellowDrumFlags ?? 0)) & discoNoteFlags + note.flags &= ~discoNoteFlags + note.flags |= lastDiscoEventFlags + } + } + } + } else { + const lastNoteGroupFlags = lastNoteGroup[0].flags + noteGroup.forEach(n => (n.flags = lastNoteGroupFlags)) + } + + // Snap notes to previous note group (this can cause stacked notes, but that's resolved later) + for (const note of noteGroup) { + note.tick = lastNoteGroup[0].tick + lastNoteGroup.push(note) + } + } + } + + return newNoteGroups +} + function sortAndFixInvalidFlexLaneOverlaps(events: { tick: number; length: number; isDouble: boolean; msTime: number; msLength: number }[]) { events.sort((a, b) => { if (a.tick !== b.tick) { @@ -621,10 +696,10 @@ function sortAndFixInvalidEventOverlaps(events: { tick: number; length: number; return events } -function sortAndFixInvalidNoteOverlaps(noteGroups: NoteEvent[][]) { +function sortAndFixInvalidNoteOverlaps(noteGroups: UntimedNoteEvent[][]) { for (const noteGroup of noteGroups) { noteGroup.sort((a, b) => a.type - b.type || b.length - a.length || b.flags - a.flags) // Longest sustain is kept for duplicates - let removedNotes: NoteEvent[] | null = null + let removedNotes: UntimedNoteEvent[] | null = null for (let i = 1; i < noteGroup.length; i++) { if (noteGroup[i].type === noteGroup[i - 1].type) { ;(removedNotes ??= []).push(noteGroup[i]) @@ -636,16 +711,14 @@ function sortAndFixInvalidNoteOverlaps(noteGroups: NoteEvent[][]) { } } - const previousNotesOfType = new Map() + const previousNotesOfType = new Map() for (const noteGroup of noteGroups) { for (const note of noteGroup) { const previousNoteOfType = previousNotesOfType.get(note.type) previousNotesOfType.set(note.type, note) if (previousNoteOfType && previousNoteOfType.tick + previousNoteOfType.length > note.tick) { note.length = Math.max(note.length, previousNoteOfType.length - (note.tick - previousNoteOfType.tick)) - note.msLength = Math.max(note.msLength, previousNoteOfType.msLength - (note.msTime - previousNoteOfType.msTime)) previousNoteOfType.length = note.tick - previousNoteOfType.tick - previousNoteOfType.msLength = note.msTime - previousNoteOfType.msTime } } } diff --git a/src/ini/ini-scanner.ts b/src/ini/ini-scanner.ts index fb3865e..19018e1 100644 --- a/src/ini/ini-scanner.ts +++ b/src/ini/ini-scanner.ts @@ -53,6 +53,7 @@ export const defaultMetadata = { eighthnote_hopo: false, multiplier_note: 0, sustain_cutoff_threshold: -1, + chord_snap_threshold: 0, video_start_time: 0, five_lane_drums: false, pro_drums: false, @@ -81,6 +82,7 @@ const integerProperties: MetaNumberKey[] = [ 'hopo_frequency', 'multiplier_note', 'sustain_cutoff_threshold', + 'chord_snap_threshold', 'video_start_time', ] const requiredProperties: MetaStringKey[] = ['name', 'artist', 'album', 'genre', 'year', 'charter'] @@ -186,6 +188,7 @@ function extractSongMetadata(songSection: { [key: string]: string }): { eighthnote_hopo: getIniBoolean(songSection, 'eighthnote_hopo'), multiplier_note: getIniInteger(songSection, 'multiplier_note', 'star_power_note'), sustain_cutoff_threshold: getIniInteger(songSection, 'sustain_cutoff_threshold'), + chord_snap_threshold: getIniInteger(songSection, 'chord_snap_threshold'), video_start_time: getIniInteger(songSection, 'video_start_time'), five_lane_drums: getIniBoolean(songSection, 'five_lane_drums'), pro_drums: getIniBoolean(songSection, 'pro_drums'), diff --git a/src/interfaces.ts b/src/interfaces.ts index c48d9e1..ffa02f7 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -75,6 +75,12 @@ export interface ScannedChart { * By default, this happens to .mid sustains shorter than 1/12 step. */ sustain_cutoff_threshold?: number + /** + * Notes at or closer than this threshold (in number of ticks) will be merged into a chord. + * All note and modifier ticks are set to the tick of the earliest merged note. + * All note sustains are set to the length of the shortest merged note. + */ + chord_snap_threshold?: number /** * The amount of time that should be skipped from the beginning of the video background in milliseconds. * A negative value will delay the start of the video by that many milliseconds.