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
5 changes: 5 additions & 0 deletions .changeset/purple-berries-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'scan-chart': minor
---

Include lyrics information
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "scan-chart",
"name": "@eliwhite/scan-chart",
"version": "8.0.1",
"author": "Geo",
"license": "MIT",
Expand All @@ -9,7 +9,7 @@
"description": "A library that scans charts for rhythm games like Clone Hero.",
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"release": "npm run build && changeset publish",
"release": "pnpm run build && changeset publish",
"test": "npx tsx src/test.ts",
"lint": "tsc"
},
Expand Down
6 changes: 6 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,12 @@ interface ParsedChart {
msTime: number
msLength: number
}[]
lyrics: {
tick: number
msTime: number
msLength: number
text: string
}[]
sections: {
tick: number
name: string
Expand Down
48 changes: 48 additions & 0 deletions src/chart/chart-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ export function parseNotesFromChart(data: Uint8Array): RawChartData {
},
hasLyrics: !!fileSections['Events']?.find(line => line.includes('"lyric ')),
hasVocals: false, // Vocals are unsupported in .chart
lyrics: _.chain(fileSections['Events'])
.map(line => /^(\d+) = E "lyric (.+?)"$/.exec(line))
.compact()
.map(([, stringTick, lyricText]) => ({
tick: Number(stringTick),
length: 0, // Chart lyric events typically don't have length
text: lyricText,
}))
.value(),
vocalPhrases: getChartVocalPhrases(fileSections['Events'] ?? []),
tempos: _.chain(fileSections['SyncTrack'])
.map(line => /^(\d+) = B (\d+)$/.exec(line))
.compact()
Expand Down Expand Up @@ -449,3 +459,41 @@ function mergeSoloEvents(events: { tick: number; type: EventType; length: number

return events
}

/**
* Extracts vocal phrase boundaries from phrase_start/phrase_end events in the [Events] section.
*/
function getChartVocalPhrases(eventLines: string[]): { tick: number; length: number }[] {
const phraseStartRegex = /^(\d+) = E "phrase_start"$/
const phraseEndRegex = /^(\d+) = E "phrase_end"$/

const starts: number[] = []
const ends: number[] = []

for (const line of eventLines) {
const startMatch = phraseStartRegex.exec(line)
if (startMatch) {
starts.push(Number(startMatch[1]))
continue
}
const endMatch = phraseEndRegex.exec(line)
if (endMatch) {
ends.push(Number(endMatch[1]))
}
}

// Pair each phrase_start with the next phrase_end
const phrases: { tick: number; length: number }[] = []
let endIdx = 0
for (const start of starts) {
while (endIdx < ends.length && ends[endIdx] <= start) {
endIdx++
}
if (endIdx < ends.length) {
phrases.push({ tick: start, length: ends[endIdx] - start })
endIdx++
}
}

return phrases
}
9 changes: 9 additions & 0 deletions src/chart/chart-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ export function scanChart(files: { fileName: string; data: Uint8Array }[], iniCh
.value() > 0,
hasLyrics: result.hasLyrics,
hasVocals: result.hasVocals,
lyrics: result.lyrics.map(lyric => ({
msTime: _.round(lyric.msTime, 3),
msLength: _.round(lyric.msLength, 3),
text: lyric.text,
})),
vocalPhrases: result.vocalPhrases.map(phrase => ({
msTime: _.round(phrase.msTime, 3),
msLength: _.round(phrase.msLength, 3),
})),
hasForcedNotes: result.hasForcedNotes,
hasTapNotes,
hasOpenNotes,
Expand Down
41 changes: 41 additions & 0 deletions src/chart/midi-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@ export function parseNotesFromMidi(data: Uint8Array, iniChartModifiers: IniChart
metadata: {}, // .mid does not have a mechanism for storing song metadata
hasLyrics: !!vocalsTrack?.trackEvents.find(e => e.type === 'lyrics' || e.type === 'text'),
hasVocals: !!vocalsTrack?.trackEvents.find(e => e.type === 'noteOn' && e.noteNumber <= 84 && e.noteNumber >= 36),
lyrics: _.chain(vocalsTrack?.trackEvents)
.filter((e): e is MidiTextEvent => e.type === 'lyrics' || (e.type === 'text' && e.text.trim() !== ''))
.map(e => ({
tick: e.deltaTime,
length: 0, // MIDI lyric events typically don't have length, they're instantaneous
text: e.text.trim(),
}))
.value(),
vocalPhrases: getVocalPhrases(vocalsTrack?.trackEvents ?? []),
tempos: _.chain(midiFile.tracks[0])
.filter((e): e is MidiSetTempoEvent => e.type === 'setTempo')
.map(e => ({
Expand Down Expand Up @@ -702,3 +711,35 @@ function fixFlexLaneLds(events: { [key in Difficulty]: MidiTrackEvent[] }) {

return events
}

/**
* Extracts vocal phrase boundaries from MIDI notes 105 and 106 on the PART VOCALS track.
* These notes define phrase regions as note-on/note-off pairs.
*/
function getVocalPhrases(trackEvents: MidiEvent[]): { tick: number; length: number }[] {
const phraseStarts: Map<number, number> = new Map() // noteNumber -> startTick
const phrases: { tick: number; length: number }[] = []

for (const event of trackEvents) {
if (event.type === 'noteOn' && (event.noteNumber === 105 || event.noteNumber === 106)) {
if (event.velocity > 0) {
phraseStarts.set(event.noteNumber, event.deltaTime)
} else {
// velocity 0 noteOn = noteOff
const startTick = phraseStarts.get(event.noteNumber)
if (startTick !== undefined) {
phrases.push({ tick: startTick, length: event.deltaTime - startTick })
phraseStarts.delete(event.noteNumber)
}
}
} else if (event.type === 'noteOff' && (event.noteNumber === 105 || event.noteNumber === 106)) {
const startTick = phraseStarts.get(event.noteNumber)
if (startTick !== undefined) {
phrases.push({ tick: startTick, length: event.deltaTime - startTick })
phraseStarts.delete(event.noteNumber)
}
}
}

return _.sortBy(phrases, 'tick')
}
11 changes: 11 additions & 0 deletions src/chart/note-parsing-interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ export interface RawChartData {
}
hasLyrics: boolean
hasVocals: boolean
lyrics: {
tick: number
length: number
text: string
}[]
/** Vocal phrase boundaries from MIDI notes 105/106 or .chart phrase_start/phrase_end events. */
vocalPhrases: {
tick: number
/** Number of ticks */
length: number
}[]
tempos: {
tick: number
/** double, rounded to 12 decimal places */
Expand Down
2 changes: 2 additions & 0 deletions src/chart/notes-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export function parseChartFile(data: Uint8Array, format: 'chart' | 'mid', partia
hasLyrics: rawChartData.hasLyrics,
hasVocals: rawChartData.hasVocals,
hasForcedNotes,
lyrics: setEventMsTimes(rawChartData.lyrics, timedTempos, rawChartData.chartTicksPerBeat),
vocalPhrases: setEventMsTimes(rawChartData.vocalPhrases, timedTempos, rawChartData.chartTicksPerBeat),
endEvents: setEventMsTimes(rawChartData.endEvents, timedTempos, rawChartData.chartTicksPerBeat),
tempos: timedTempos,
timeSignatures: setEventMsTimes(rawChartData.timeSignatures, timedTempos, rawChartData.chartTicksPerBeat),
Expand Down
17 changes: 17 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,23 @@ export interface NotesData {
/** The notes-per-second in this region. */
nps: number
}[]
/** Lyric events with timing information. */
lyrics: {
tick: number
/** Time of the lyric in milliseconds. Rounded to 3 decimal places. */
msTime: number
/** Length of the lyric in milliseconds. Generally 0 as lyrics are instantaneous. */
msLength: number
/** The lyric text. */
text: string
}[]
/** Vocal phrase boundaries that define lyric line groupings. From MIDI notes 105/106 or .chart phrase_start/phrase_end events. */
vocalPhrases: {
/** Time of the phrase start in milliseconds. Rounded to 3 decimal places. */
msTime: number
/** Duration of the phrase in milliseconds. Rounded to 3 decimal places. */
msLength: number
}[]
/**
* Hashes of each track. This is specifically designed to change if and only if the chart changes in a way that impacts scoring or difficulty.
* This means it is useful for games to use this to determine which charts should share the same leaderboard.
Expand Down