Skip to content
Merged
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
41 changes: 40 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,24 @@ Note: running this will print usage information. Add command line arguments to t

```ts
/**
* Scans `files` as a chart folder, and returns a `ScannedChart` object.
* Parses a chart folder's `notes.{mid,chart}` and `song.ini` into a `ParsedChart`.
* No hashing or audio/image scanning.
*/
function parseChartAndIni(files: { fileName: string; data: Uint8Array }[]): ParseChartAndIniResult

/**
* Validates, hashes, and asset-scans the parsed chart folder. Pair with
* `parseChartAndIni()` to get the input. Returns the same `ScannedChart`
* shape as the previous `scanChartFolder()` did.
*/
function scanChart(files: { fileName: string; data: Uint8Array }[], parseResult: ParseChartAndIniResult, config?: ScanChartFolderConfig): ScannedChart

/**
* @deprecated Back-compat shim equivalent to
* `scanChart(files, parseChartAndIni(files), config)`. Prefer the two-step form.
*/
function scanChartFolder(files: { fileName: string; data: Uint8Array }[], config?: ScanChartFolderConfig): ScannedChart

function parseChartFile(data: Uint8Array, format: 'chart' | 'mid', iniChartModifiers: IniChartModifiers): ParsedChart
function calculateTrackHash(parsedChart: ParsedChart, instrument: Instrument, difficulty: Difficulty): { hash: string, btrack: Uint8Array }

Expand All @@ -45,6 +60,21 @@ interface ScanChartFolderConfig {
includeBTrack: boolean
}

interface ParseChartAndIniResult {
/** The parsed chart, or `null` if a chart file could not be found or could not be parsed. Inspect `chartFolderIssues` for the reason. */
parsedChart: ParsedChart | null
/** Folder-level issues from chart file discovery and parsing (`noChart`, `invalidChart`, `multipleChart`, `badChart`). */
chartFolderIssues: { folderIssue: FolderIssueType; description: string }[]
/** The metadata parsed from `song.ini`, or `null` if no ini was present. */
iniMetadata: { /* same shape as defaultMetadata */ } | null
/** Folder-level issues from ini scanning (`noMetadata`, `invalidIni`, `invalidMetadata`, `badIniLine`, `multipleIniFiles`). */
iniFolderIssues: { folderIssue: FolderIssueType; description: string }[]
/** Validation issues with ini values. */
iniMetadataIssues: { metadataIssue: MetadataIssueType; description: string }[]
/** ini key/value pairs not in scan-chart's known list. */
iniUnknownValues: { [key: string]: string }
}

interface ScannedChart {
/** An MD5 hash of the names and binary contents of every file in the chart. */
md5: string
Expand Down Expand Up @@ -350,6 +380,15 @@ type NoteType =
| 'greenTomOrCymbalMarker'

interface ParsedChart {
/**
* The raw bytes of the source chart file. Needed by `scanParsedChart` to
* compute `chartHash` (which is `blake3(chartBytes ++ ini-modifier name/value pairs)`).
*/
chartBytes: Uint8Array
/** The format the chart was parsed from. */
format: 'chart' | 'mid'
/** The fully-resolved ini modifiers that influenced parsing. */
iniChartModifiers: IniChartModifiers
resolution: number
drumType: DrumType | null
metadata: {
Expand Down
207 changes: 86 additions & 121 deletions src/chart/chart-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,141 +5,106 @@ 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 { ChartIssueType, Difficulty, getInstrumentType, Instrument, instrumentTypes, NotesData } from '../interfaces'
import { msToExactTime } from '../utils'
import { IniChartModifiers, NoteEvent, noteFlags, NoteType, noteTypes } from './note-parsing-interfaces'
import { parseChartFile, ParsedChart } from './notes-parser'
import { ParsedChart } from './parse-chart-and-ini'
import { calculateTrackHash, pruneEmptyPhrases } from './track-hasher'

const LEADING_SILENCE_THRESHOLD_MS = 1000
const MIN_SUSTAIN_GAP_MS = 40
const MIN_SUSTAIN_MS = 100
const NPS_GROUP_SIZE_MS = 1000

export function scanChart(files: { fileName: string; data: Uint8Array }[], iniChartModifiers: IniChartModifiers, includeBTrack = false) {
const { chartData, format, folderIssues } = findChartData(files)

if (chartData) {
try {
const result = parseChartFile(chartData, format, iniChartModifiers)
const trackHashes = result.trackData.map(t => {
const hash = calculateTrackHash(result, t.instrument, t.difficulty)
return {
instrument: t.instrument,
difficulty: t.difficulty,
hash: hash.hash,
btrack: includeBTrack ? hash.btrack : null,
}
})
/**
* Compute the chart-derived hashes and `notesData` from an already-parsed
* chart. The output is the same chart-only subset of `ScannedChart` that
* the previous `scanChart(files, …)` returned; this version takes a
* `ParsedChart` so callers can reuse a parse result without redoing it.
*
* `trackHashes` is byte-stable across releases — see the spec in track-hasher.ts.
*/
export function scanParsedChart(parsedChart: ParsedChart, includeBTrack = false) {
const result = parsedChart
const iniChartModifiers = result.iniChartModifiers

let [hasTapNotes, hasOpenNotes, has2xKick] = [false, false, false]
for (const track of result.trackData) {
for (const noteGroup of track.noteEventGroups) {
for (const note of noteGroup) {
if (note.flags & noteFlags.tap) {
hasTapNotes = true
}
if (note.flags & noteFlags.doubleKick) {
has2xKick = true
}
if (note.type === noteTypes.open) {
hasOpenNotes = true
}
}
const trackHashes = result.trackData.map(t => {
const hash = calculateTrackHash(result, t.instrument, t.difficulty)
return {
instrument: t.instrument,
difficulty: t.difficulty,
hash: hash.hash,
btrack: includeBTrack ? hash.btrack : null,
}
})

let [hasTapNotes, hasOpenNotes, has2xKick] = [false, false, false]
for (const track of result.trackData) {
for (const noteGroup of track.noteEventGroups) {
for (const note of noteGroup) {
if (note.flags & noteFlags.tap) {
hasTapNotes = true
}
if (note.flags & noteFlags.doubleKick) {
has2xKick = true
}
if (note.type === noteTypes.open) {
hasOpenNotes = true
}
}

return {
chartHash: getChartHash(chartData, iniChartModifiers),
notesData: {
instruments: _.chain(result.trackData)
.map(t => t.instrument)
.uniq()
.value(),
drumType: result.drumType,
hasSoloSections:
_.chain(result.trackData)
.map(t => t.soloSections.length)
.max()
.value() > 0,
hasLyrics: result.hasLyrics,
hasVocals: result.hasVocals,
hasForcedNotes: result.hasForcedNotes,
hasTapNotes,
hasOpenNotes,
has2xKick,
hasFlexLanes:
_.chain(result.trackData)
.map(t => t.flexLanes.length)
.max()
.value() > 0,
chartIssues: findChartIssues(result, iniChartModifiers.song_length, trackHashes),
noteCounts: result.trackData.map(t => ({
instrument: t.instrument,
difficulty: t.difficulty,
count: t.instrument === 'drums' ? _.sumBy(t.noteEventGroups, 'length') : t.noteEventGroups.length,
})),
maxNps: result.trackData.map(t => ({
instrument: t.instrument,
difficulty: t.difficulty,
...findMaxNps(t.noteEventGroups),
})),
trackHashes,
tempoMapHash: md5
.create()
.update(result.tempos.map(t => `${t.tick}_${t.beatsPerMinute * 1000}`).join(':'))
.update(result.timeSignatures.map(t => `${t.tick}_${t.numerator}_${t.denominator}`).join(':'))
.hex(),
tempoMarkerCount: result.tempos.length,
effectiveLength: _.chain(result.trackData)
.thru(tracks => ({
min: _.min(tracks.map(track => _.first(track.noteEventGroups)?.[0]?.msTime)),
max: _.max(tracks.map(track => _.last(track.noteEventGroups)?.[0]?.msTime)),
}))
.thru(({ min, max }) => (min !== undefined && max !== undefined ? _.round(max - min, 3) : iniChartModifiers.song_length))
.value(),
},
metadata: result.metadata,
folderIssues,
}
} catch (err) {
folderIssues.push({ folderIssue: 'badChart', description: typeof err === 'string' ? err : (err?.message ?? JSON.stringify(err)) })
}
}

return { chartHash: null, notesData: null, metadata: null, folderIssues }
}

function findChartData(files: { fileName: string; data: Uint8Array }[]) {
const folderIssues: { folderIssue: FolderIssueType; description: string }[] = []

const chartFiles = _.chain(files)
.filter(f => hasChartExtension(f.fileName))
.orderBy([f => hasChartName(f.fileName), f => getExtension(f.fileName).toLowerCase() === 'mid'], ['desc', 'desc'])
.value()

for (const file of chartFiles) {
if (!hasChartName(file.fileName)) {
folderIssues.push({
folderIssue: 'invalidChart',
description: `"${file.fileName}" is not named "notes.${getExtension(file.fileName).toLowerCase()}".`,
})
}
}

if (chartFiles.length > 1) {
folderIssues.push({ folderIssue: 'multipleChart', description: 'This chart has multiple .chart/.mid files.' })
}

if (chartFiles.length === 0) {
folderIssues.push({ folderIssue: 'noChart', description: 'This chart doesn\'t have "notes.chart"/"notes.mid".' })
return { chartData: null, format: null, folderIssues }
} else {
return {
chartData: chartFiles[0].data,
format: (getExtension(chartFiles[0].fileName).toLowerCase() === 'mid' ? 'mid' : 'chart') as 'mid' | 'chart',
folderIssues,
}
return {
chartHash: getChartHash(result.chartBytes, iniChartModifiers),
notesData: {
instruments: _.chain(result.trackData)
.map(t => t.instrument)
.uniq()
.value(),
drumType: result.drumType,
hasSoloSections:
_.chain(result.trackData)
.map(t => t.soloSections.length)
.max()
.value() > 0,
hasLyrics: result.hasLyrics,
hasVocals: result.hasVocals,
hasForcedNotes: result.hasForcedNotes,
hasTapNotes,
hasOpenNotes,
has2xKick,
hasFlexLanes:
_.chain(result.trackData)
.map(t => t.flexLanes.length)
.max()
.value() > 0,
chartIssues: findChartIssues(result, iniChartModifiers.song_length, trackHashes),
noteCounts: result.trackData.map(t => ({
instrument: t.instrument,
difficulty: t.difficulty,
count: t.instrument === 'drums' ? _.sumBy(t.noteEventGroups, 'length') : t.noteEventGroups.length,
})),
maxNps: result.trackData.map(t => ({
instrument: t.instrument,
difficulty: t.difficulty,
...findMaxNps(t.noteEventGroups),
})),
trackHashes,
tempoMapHash: md5
.create()
.update(result.tempos.map(t => `${t.tick}_${t.beatsPerMinute * 1000}`).join(':'))
.update(result.timeSignatures.map(t => `${t.tick}_${t.numerator}_${t.denominator}`).join(':'))
.hex(),
tempoMarkerCount: result.tempos.length,
effectiveLength: _.chain(result.trackData)
.thru(tracks => ({
min: _.min(tracks.map(track => _.first(track.noteEventGroups)?.[0]?.msTime)),
max: _.max(tracks.map(track => _.last(track.noteEventGroups)?.[0]?.msTime)),
}))
.thru(({ min, max }) => (min !== undefined && max !== undefined ? _.round(max - min, 3) : iniChartModifiers.song_length))
.value(),
},
}
}

Expand Down
1 change: 1 addition & 0 deletions src/chart/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './chart-scanner'
export * from './parse-chart-and-ini'
Loading