lrc-kit
Version:
lrc parser, maker, runner
201 lines (179 loc) • 5.13 kB
text/typescript
import type { ParseOptions } from './lrc';
// match `[12:30.1][12:30.2]`
export const SQUARE_TAGS_REGEXP = /^(?:\s*\[[^\]]+\])+/;
// match `ti: The Title`
export const INFO_REGEXP = /^\s*(\w+)\s*:(.*)$/;
// match `512:34.1`
export const TIME_REGEXP = /^\s*(\d+)\s*:\s*(\d+(\s*[.:]\s*\d+)?)\s*$/;
// match `<12:30.1> word` (A2 extension) | `[12:30.1] word` (Foobar2000)
export const ENHANCED_TAG_WORD_REGEXP = /[<[](\d+:\d+(?:\.\d+)?)[>\]]([^[<]*)/;
export enum LineType {
INVALID = 'INVALID',
INFO = 'INFO',
TIME = 'TIME',
}
export interface InvalidLine {
type: LineType.INVALID;
}
interface TimeWordTimestamp {
timestamp: number;
content: string;
}
export interface TimeLine {
type: LineType.TIME;
timestamps: number[];
wordTimestamps?: TimeWordTimestamp[];
rawContent: string;
content: string;
}
export interface InfoLine {
type: LineType.INFO;
key: string;
value: string;
}
export function parseSquareTags(
line: string,
): null | { tags: string[]; rawContent: string } {
line = line.trim();
const matches = SQUARE_TAGS_REGEXP.exec(line);
if (matches === null) return null;
const tag = matches[0];
const content = line.slice(tag.length);
return {
tags: tag.slice(1, -1).split(/\]\s*\[/),
rawContent: content,
};
}
function parseTimestamp(str: string): number | null {
const matches = TIME_REGEXP.exec(str);
if (!matches) return null;
const minuteStr = matches[1] ?? '0';
const secondStr = matches[2] ?? '0';
const minutes = parseFloat(minuteStr);
const seconds = parseFloat(secondStr.replace(/\s+/g, '').replace(':', '.'));
return minutes * 60 + seconds;
}
export function parseEnhancedWords(
timestamps: number[],
rawContent: string,
): TimeLine | null {
const wordTimestamps: TimeWordTimestamp[] = [];
let stripContent = '';
let stripIndex = 0;
const pushContent = (timestamp: number, wordContent: string) => {
if (!wordContent.trim()) return;
if (stripContent.endsWith(' ') && wordContent.startsWith(' ')) {
wordContent = wordContent.trimStart();
}
stripContent += wordContent;
wordTimestamps.push({
timestamp,
content: wordContent,
});
};
const firstTimestamp = timestamps[timestamps.length - 1];
if (!firstTimestamp) return null;
const firstMatches = ENHANCED_TAG_WORD_REGEXP.exec(rawContent);
const firstContent = firstMatches
? rawContent.slice(0, firstMatches.index)
: rawContent;
pushContent(firstTimestamp, firstContent);
if (firstMatches)
while (stripIndex < rawContent.length) {
const wordMatches = ENHANCED_TAG_WORD_REGEXP.exec(
rawContent.slice(stripIndex),
);
if (!wordMatches) break;
stripIndex += wordMatches.index + wordMatches[0].length;
const timestamp = parseTimestamp(wordMatches[1]!);
if (timestamp === null) continue;
const wordContent = wordMatches[2]!;
pushContent(timestamp, wordContent);
}
return {
type: LineType.TIME,
timestamps,
content: stripContent.trim(),
rawContent,
wordTimestamps,
};
}
export function parseTime(
tags: string[],
rawContent: string,
{ enhanced = true }: ParseOptions = {},
): TimeLine {
const timestamps = tags
.map((tag) => parseTimestamp(tag))
.filter((it) => it !== null);
rawContent = rawContent.trim();
if (enhanced) {
const parsedWords = parseEnhancedWords(timestamps, rawContent);
if (parsedWords) return parsedWords;
}
return {
type: LineType.TIME,
timestamps,
rawContent,
content: rawContent,
};
}
export function parseInfo(tag: string): InfoLine | null {
const matches = INFO_REGEXP.exec(tag);
if (!matches) return null;
const key = matches[1] ?? '';
const value = matches[2] ?? '';
return {
type: LineType.INFO,
key: key.trim(),
value: value.trim(),
};
}
const parseLineInner = (
line: string,
options?: ParseOptions,
): InfoLine | TimeLine | null => {
const parsedTags = parseSquareTags(line);
if (!parsedTags) return null;
const { tags, rawContent } = parsedTags;
const firstTag = tags[0];
if (!firstTag) return null;
if (TIME_REGEXP.test(firstTag)) {
return parseTime(tags, rawContent, options);
} else {
return parseInfo(firstTag);
}
};
/**
* line parse lrc of timestamp
* @example
* const lp = parseLine('[ti: Song title]')
* lp.type === LineParser.TYPE.INFO
* lp.key === 'ti'
* lp.value === 'Song title'
*
* const lp = parseLine('[10:10.10]hello')
* lp.type === LineParser.TYPE.TIME
* lp.timestamps === [10*60+10.10]
* lp.content === 'hello'
*
* const lp = parseLine('[10:10.10] <10:10.12> hello <10:11.02> world')
* lp.type === LineParser.TYPE.TIME
* lp.timestamps === [10*60+10.10]
* lp.content === 'hello world'
* lp.wordTimestamps === [
* { timestamp: 10*60+10.12, content: 'hello' },
* { timestamp: 10*60+11.02, content: 'world' }
* ]
*/
export function parseLine(
line: string,
options?: ParseOptions,
): InfoLine | TimeLine | InvalidLine {
const result = parseLineInner(line, options);
return result
? result
: {
type: LineType.INVALID,
};
}