kar-to-ass
Version:
Converts midi/kar karaoke files to ass format
125 lines (118 loc) • 3.94 kB
text/typescript
import { KarData, KarTimebase, KarTrack, KarTimebaseSection } from './types';
import { parseMidi } from 'midi-file';
import { clone } from './utils';
import debug from 'debug';
const debugTimebase = debug('timebase');
const debugTracks = debug('tracks');
const debugLyrics = debug('lyrics');
const isEventLyrics = event => (['text', 'lyrics'].includes(event.type) && !event.text.startsWith('@'));
const DEFAULT_EOL = 200;
export function parseKar(fileContent: Buffer): KarData {
const midi = parseMidi(fileContent);
const timebase: KarTimebase = getTimebase(midi);
const rawTracks = getTracks(midi);
rawTracks.forEach(track => applyTimebase(track, timebase));
return {
timebase: timebase,
tracks: rawTracks,
};
}
function applyTimebase(track: KarTrack, timebase: KarTimebase): void {
const tmpTimebase: KarTimebase = clone(timebase);
let time: KarTimebaseSection = tmpTimebase.shift();
let baseUs: number = 0;
for(const event of track) {
while(event.ticks >= time.tickEnd) {
baseUs += time.microSecondsPerTick * (time.tickEnd - time.tickStart);
time = tmpTimebase.shift();
}
event.timeUs = baseUs + time.microSecondsPerTick * (event.ticks - time.tickStart);
// console.error(`Mapped ${JSON.stringify({timeMillis: Math.round(event.timeUs/1000), type: event.type, text: event.text})}`)
}
debugTracks('Applied timebase to ', track);
}
function getTimebase(midi: any): KarTimebase {
const tempoTrack: any = midi.tracks.filter(track => track.some(event => event.type == 'setTempo'))[0];
debugTimebase('Tempo track is ', tempoTrack);
const timebase: KarTimebase = [];
let curr: number = 0;
const tempoEvents: any = tempoTrack.filter(e => e.type === 'setTempo');
for(let i = 0; i < tempoEvents.length; i++) {
const tempoEvent: any = tempoEvents[i];
const duration = (i+1<tempoEvents.length)
? tempoEvents[i+1].deltaTime
: Number.MAX_SAFE_INTEGER;
timebase.push({
tickStart: curr,
tickEnd: curr + duration,
microSecondsPerTick: tempoEvent.microsecondsPerBeat / midi.header.ticksPerBeat,
});
curr += duration;
debugTimebase('Added timebase event', timebase[timebase.length - 1]);
}
return timebase;
}
function getTracks(midi: any, defaultEOL: number = DEFAULT_EOL): KarTrack[] {
const response: KarTrack[] = [];
debugTracks('All tracks from midi', midi.tracks);
const tracksWithLyrics: any[] = midi.tracks
.filter(events => events.some(isEventLyrics));
debugTracks('Filtered tracks with lyric events', tracksWithLyrics);
for(const rawTrack of tracksWithLyrics) {
let timeCursor: number = 0;
let track: KarTrack = [];
for(const rawEvent of rawTrack) {
if(isEventLyrics(rawEvent)) {
const cleanText: string = rawEvent.text.replace(/[\/\\]/g, '');
debugLyrics('Lyrics line: ', cleanText, rawEvent);
if(['/', '\r', '\n', '\r\n'].includes(rawEvent.text)) {
timeCursor += rawEvent.deltaTime;
track.push({
ticks: timeCursor,
type: 'EndOfLine',
});
} else if(rawEvent.text.match(/^[\/\\]/)) {
track.push({
ticks: timeCursor + defaultEOL,
type: 'EndOfLine',
});
timeCursor += rawEvent.deltaTime;
track.push({
ticks: timeCursor,
type: 'Lyrics',
text: cleanText,
});
} else if(rawEvent.text.match(/[\/\\]$/)) {
timeCursor += rawEvent.deltaTime;
track.push({
ticks: timeCursor,
type: 'Lyrics',
text: cleanText,
});
track.push({
ticks: timeCursor + defaultEOL,
type: 'EndOfLine',
});
} else {
timeCursor += rawEvent.deltaTime;
track.push({
ticks: timeCursor,
type: 'Lyrics',
text: cleanText,
});
}
} else {
timeCursor += rawEvent.deltaTime;
}
}
if(track[track.length-1].type === 'Lyrics') {
track.push({
ticks: timeCursor + defaultEOL,
type: 'EndOfLine',
});
}
debugTracks('Finalized track: ', track);
response.push(track);
}
return response;
}