kar-to-ass
Version:
Converts midi/kar karaoke files to ass format
165 lines (136 loc) • 5.46 kB
text/typescript
import { asyncExists, asyncReadFile, asyncWriteFile, clone, usToAss } from './utils';
import stringify from 'ass-stringify';
import { v4 as uuid } from 'uuid';
import cli from 'commander';
import { KarData, Options, KarTrack, KarEvent } from './types';
import { parseKar as _parseKar } from './kar';
const ass = require('./assTemplate');
const pjson = require('../package.json');
function generateASSLine(line: KarEvent[], sectionId:string, options: Options) {
const offset: number = options.offset || 0;
const syncType: string = options.progressive ? 'kf' : 'k';
const dialogue = clone(ass.dialogue);
const comment = clone(ass.dialogue);
// keep times as µs
const lineStart = line[0].timeUs + 1e4 * (offset - ass.previewTime);
const lineStartComment = line[0].timeUs + 1e4 * offset;
const lineEnd = line[line.length-1].timeUs + 1e4 * (offset + ass.postviewTime);
const correction = lineStart < 0 ? lineStart : 0;
dialogue.value.Style = sectionId;
dialogue.value.Start = usToAss(lineStart - correction);
dialogue.value.End = usToAss(lineEnd - correction);
dialogue.value.Text = writeLineAsAss(line, syncType);
dialogue.value.Effect = 'fx';
comment.value.Style = sectionId;
comment.value.Start = usToAss(lineStartComment - correction);
comment.value.End = usToAss(lineEnd - correction);
comment.value.Text = writeLineAsAss(line, syncType, true);
comment.value.Effect = 'karaoke';
comment.key = 'Comment';
return {
dialogue,
comment
};
}
export function parseKar(fileContent: Buffer): KarData { // Proxy Method
return _parseKar(fileContent);
}
export function convertKarStringToAss(fileContent: Buffer, options: Options): string {
return convertKarToAss(parseKar(fileContent), options);
}
export function convertKarToAss(kar: KarData, options: Options): string {
const defaultStyle = ass.styles.body.filter(style => style.key == 'Style')[0];
const dialogues = [];
const comments = [];
const styles = clone(ass.styles);
const script = clone(ass.dialogue);
script.value.Effect = ass.scriptFX;
script.value.Text = ass.script;
script.key = 'Comment';
comments.push(script);
for(const track of kar.tracks) {
const sectionId: string = uuid().replace(/-/g, '').substring(0,16);
const sectionStyle = clone(defaultStyle);
sectionStyle.value.Name = sectionId;
styles.body.push(sectionStyle);
for(const line of splitLines(track)) {
const ASSLines = generateASSLine(line, sectionId, options);
comments.push(clone(ASSLines.comment));
dialogues.push(clone(ASSLines.dialogue));
}
}
const scriptInfo = clone(ass.scriptInfo);
scriptInfo.body[0].value = scriptInfo.body[0].value.replace('%version', pjson.version);
scriptInfo.body[1].value = scriptInfo.body[1].value.replace('%options', JSON.stringify(options));
const events = clone(ass.events);
events.body = events.body.concat(comments, dialogues);
return stringify([scriptInfo, styles, events]);
}
function splitLines(track: KarTrack): KarEvent[][] {
const lines: KarEvent[][] = [];
let tempLine: KarEvent[] = [];
for(const event of track) {
if(event.type === 'EndOfLine' && tempLine.length > 0) {
tempLine.push(event);
lines.push(tempLine);
tempLine = [];
} else if(event.type === 'Lyrics') {
tempLine.push(event);
// } else {
// console.debug(`Ignored ${JSON.stringify(event)}`)
}
}
return lines;
}
function writeLineAsAss(line: KarEvent[], syncType: string, comment?:boolean) {
if(line.length == 0) return '';
const realPreview = Math.max(Math.min(ass.previewTime, line[0].timeUs * 1e-4), 0);
const resArray: string[] = [];
if (!comment) resArray.push(`{\\k${realPreview}\\fad(300,200)}`);
for(let i = 0; i < line.length-1; i++) {
// console.error(`At ${usToAss(line[i].timeUs)} for ${Math.round(1e-4 * (line[i+1].timeUs-line[i].timeUs))} cs: '${line[i].text}'`);
resArray.push(`${line[i].text.startsWith(' ') ? ' ' : ''}{\\${syncType}${Math.round(1e-4 * (line[i+1].timeUs-line[i].timeUs))}}${line[i].text.trim()}${(line[i].text.endsWith(' ') ? ' ' : '')}`);
}
return resArray.join('');
}
async function mainCLI() {
const argv = parseArgs();
if(!argv.in)
throw '--in argument is required';
const karFile: string = argv.in;
const correctionString: string = argv.correction;
const outFile: string = argv.out;
const correction = (correctionString && !isNaN(+correctionString))
? +correctionString : 0;
const options: Options = {
offset: correction,
progressive: argv.progressive || false,
};
if(!await asyncExists(karFile))
throw `File ${karFile} does not exist`;
const fileContent = await asyncReadFile(karFile);
const kar: KarData = parseKar(fileContent);
const assString = convertKarToAss(kar, options);
if(!outFile) {
return assString;
} else {
await asyncWriteFile(outFile, assString);
return '';
}
}
function parseArgs() {
const argv = process.argv.filter(e => e !== '--');
return cli
.command('kar-to-ass')
.description('Converts midi/kar files to ass format')
.version(pjson.version)
.option('-i, --in <karFile> (required)', 'the file to read data from')
.option('-o, --out <assFile>', 'if set, saves the converted data there instead of printing it on stdout')
.option('-c, --correction <cs>', 'offsets said amount of cs on each line', 0)
.option('-p, --progressive', 'use kf instead of k ')
.parse(argv);
}
if (require.main === module) mainCLI()
.then(data => console.log(data))
.catch(err => console.error(err));