UNPKG

ultrastar2ass

Version:

Convert Ultrastar karaoke files to ASS files

300 lines (245 loc) 8.71 kB
import { SyllabesConfig } from './types'; export default class UltrastarParser { config: SyllabesConfig; meta: any; track: any; track_duet: any; first_pass: boolean; // only to keep track of first pass if we have a spitted start time with the first sentence found constructor(config: SyllabesConfig) { this.config = config; this.meta = { title: null, artist: null, mp3: null, bpm: null, gap: 0, duet: false }; this.track = []; this.track_duet = []; this.first_pass = true; } /** * Parse an Ultrastar text file * @param {string} file Ultrastar text file content */ parse(file: string) { // Let's parse the file line by line const lines = file.replace(/\r+/g, '').split('\n'); let sentenceID = 1; // Current sentence ID let trackID = 1; // Current track ID let relative = false; // Relative or absolute beats notation for the syllables let beatsCount = 0; // Count of the elapsed beats let beatDuration = null; // Duration of a beat (milliseconds) let currentStart = null; // Start of the current sentence (milliseconds) let previousEnd = null; // End of the previous sentence (milliseconds) let syllables = []; // Syllables list of the current sentence // Parse each line of the file until the end (or a "E" line) for (let i = 0; i < lines.length; i++) { // Delete the trailing spaces const line = lines[i].replace(/^\s*/, ''); // Ignore the line if it's empty if (line.replace(/\s*/g, '').length == 0) { continue; } // Metadata line if (line[0] == '#') { // Regex parsing of the line let matches:string[] = line.match(/(\w+):(.+)/); // Ignore the line if it's invalid if (matches == null || matches.length == 0) { continue; } // Split of the regex result matches = matches[0].split(':'); const keyword: string = matches[0].toLowerCase(); let value: any = matches[1]; // Float conversion of the BPM / GAP if (keyword == 'bpm' || keyword == 'gap') { value = parseFloat(value.replace(',', '.')); } // Beat duration calculation from the BPM if (keyword == 'bpm') { // multiple by 4 because a bpm in ultrastar is in fact a quarter beat beatDuration = (60000) / (value * 4); } // Override the config offset if absent, and set the first sentence start as the GAP value if (keyword == 'gap') { if (this.config.offset) { value += this.config.offset; } currentStart = value; } // Mark the syllables beat notation as relative if (keyword == 'relative' && value.toLowerCase() == 'yes') { relative = true; } // Save the data if it isn't empty if (value != '') { this.meta[keyword] = value; } continue; } // Player change or end of file when a sentence isn't finished if ((line == 'P2' || line == 'P 2' || line[0] == 'E') && syllables.length > 0) { // Create a new sentence const sentence = this.makeSentence(sentenceID, syllables, currentStart, null, previousEnd); if (currentStart != null) { currentStart = null; } // Add the sentence to the current track if (trackID == 1) { this.track.push(sentence); } else { this.track_duet.push(sentence); } // Increment the sentence ID, reset the current sentence syllables sentenceID++; syllables = []; } // Lyrics player change if (line == 'P2' || line == 'P 2') { trackID = 2; this.meta.duet = true; continue; } // Syllable line if ([':', '*', 'F', 'R', 'G'].indexOf(line[0]) > -1) { // Regex parsing of the line let matches:string[] = line.match(/^[:*FRG] (-?\d+) (\d+) (-?\d+) (.+)/); // Ignore the line if it's invalid if (matches == null || matches.length == 0) { continue; } const syllable: any = { type: 'normal' }; // Get the syllable text syllable.text = matches[0].split(' ').splice(4).join(' '); // Split of the regex result matches = matches[0].split(' ').splice(1, 3); // Add the start time of the syllable, with absolute or relative beats if (!relative) { syllable.start = Math.floor(this.meta.gap + parseInt(matches[0]) * beatDuration); } else { syllable.start = Math.floor(this.meta.gap + (beatsCount + parseInt(matches[0])) * beatDuration); } const matchesAfter = lines[i+1].replace(/^\s*/, '').match(/^[:*FRG-] (-?\d+)/); // Add the duration, end time, pitch syllable.duration = Math.floor((matchesAfter ? (parseInt(matchesAfter[1])- parseInt(matches[0])) : parseInt(matches[1])) * beatDuration); syllable.end = syllable.start + syllable.duration; syllable.pitch = parseInt(matches[2]); // Fix the first syllable start if the offset is negative if (this.meta.gap < 0 && syllable.start < 0) { syllable.start = 0; syllable.duration = syllable.end; } // Change the type for special syllables if (line[0] == '*' || line[0] == 'G') { syllable.type = 'golden'; } else if (line[0] == 'F') { syllable.type = 'freestyle'; } else if (line[0] == 'R') { syllable.type = 'rap'; } // Increment the total beats count with the syllable beats beatsCount += parseInt(matches[1]); // Add the syllable syllables.push(syllable); } // New line mark if (line[0].indexOf('-') > -1 && syllables.length > 0) { // Regex parsing of the line let matches:string[] = line.match(/^- ?(\d+)\s?(\d+)?/); // Ignore the line if it's invalid if (matches == null || matches.length == 0) { continue; } // Split of the regex result matches = matches[0].split(' '); if (matches[0].length > 1) { matches[0] = matches[0].substr(1, matches[0].length); } else { matches = matches.splice(1); } let currentEnd = null; // Add the end time of the sentence, with absolute or relative beats if (!relative) { currentEnd = Math.floor(this.meta.gap + parseInt(matches[0]) * beatDuration); } else { currentEnd = Math.floor(this.meta.gap + (beatsCount + parseInt(matches[0])) * beatDuration); } // Fix the first sentence start if the offset is negative if (this.meta.gap < 0 && currentStart < 0) { currentStart = 0; } else if(null !== currentStart && (0 < syllables.length) && this.first_pass) { // we may need an additional offset - just move to the very first part if we have one currentStart = Math.max(currentStart, syllables[0].start); } // Create a new sentence const sentence = this.makeSentence(sentenceID, syllables, currentStart, currentEnd, previousEnd); if (currentStart != null) { currentStart = null; } // Save the sentence end time for possible use on the next sentence previousEnd = sentence.end; // now, we *MAY* have computed the first offset : we do not need to keep this, ever this.first_pass = false; // Add the sentence to the current track if (trackID == 1) { this.track.push(sentence); } else { this.track_duet.push(sentence); } // Increment the sentence ID, reset the current sentence syllables sentenceID++; syllables = []; } // End of file if (line[0] == 'E') { break; } } return { meta: this.meta, track: this.track, track_duet: (this.track_duet.length > 0) ? this.track_duet : null }; } /** * Make a new sentence * @param {number} id ID of the sentence * @param {any[]} syllables Syllables list of the sentence * @param {number} start Start time of the sentence * @param {number} end End time of the sentence * @param {number} previousEnd End time of the previous sentence */ private makeSentence(id: number, syllables: any[], start: number, end: number, previousEnd: number) { const sentence: any = { id: id, start: syllables[0].start, end: syllables[syllables.length - 1].end }; // Insert sentence syllables as objects or as a string if (this.config.syllable_precision) { sentence.syllables = syllables; } else { sentence.text = ''; for (let j = 0; j < syllables.length; j++) { sentence.text += syllables[j].text; } } // Add the start of the sentence if it was present on the last "sentence end" line if (start != null) { sentence.start = start; } else if (previousEnd != null && this.config.start_as_previous_end) { sentence.start = previousEnd; } // Add the end of the sentence if any if (end != null) { sentence.end = end; } // Set the duration with start and end sentence.duration = sentence.end - sentence.start; return sentence; } }