subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
935 lines (837 loc) • 27.9 kB
text/typescript
import type { SubtitleDocument, SubtitleEvent, TextSegment } from '../../../core/types.ts'
import type { ParseOptions, ParseResult, ParseError, ErrorCode } from '../../../core/errors.ts'
import { toParseError } from '../../../core/errors.ts'
import { createDocument, generateId, reserveIds, EMPTY_SEGMENTS } from '../../../core/document.ts'
/**
* Metadata tags supported in LRC format.
* These tags appear in the header of LRC files as [key:value] pairs.
*/
interface LRCMetadata {
/** Artist name ([ar:]) */
artist?: string
/** Song title ([ti:]) */
title?: string
/** Album name ([al:]) */
album?: string
/** Lyrics author ([au:]) */
author?: string
/** Song length ([length:]) */
length?: string
/** Creator of the LRC file ([by:]) */
by?: string
/** Time offset in milliseconds ([offset:]) */
offset?: number
/** LRC creator/editor ([re:]) */
re?: string
/** LRC file version ([ve:]) */
ve?: string
}
class LRCParser {
private src: string
private pos = 0
private len: number
private doc: SubtitleDocument
private errors: ParseError[] = []
private opts: ParseOptions
private lineNum = 1
private metadata: LRCMetadata = {}
private offset = 0
private lastEvent: SubtitleEvent | null = null
private ordered = true
constructor(input: string, opts: Partial<ParseOptions> = {}) {
let start = 0
if (input.charCodeAt(0) === 0xFEFF) start = 1
this.src = input
this.pos = start
this.len = input.length
this.opts = {
onError: opts.onError ?? 'collect',
strict: opts.strict ?? false,
preserveOrder: opts.preserveOrder ?? true
}
this.doc = createDocument()
}
parse(): ParseResult {
while (this.pos < this.len) {
this.skipEmptyLines()
if (this.pos >= this.len) break
const line = this.readLine()
if (!line) continue
this.parseLine(line)
}
// Apply metadata to document
if (this.metadata.title) this.doc.info.title = this.metadata.title
// Priority: au > by > ar (author tag takes precedence over artist tag)
if (this.metadata.author || this.metadata.by || this.metadata.artist) {
this.doc.info.author = this.metadata.author || this.metadata.by || this.metadata.artist
}
// Note: Offset is NOT applied to event times during parsing
// The offset is a playback adjustment and should be handled by the player
// or serializer if needed. We just store it in metadata for roundtripping.
return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }
}
private skipEmptyLines(): void {
while (this.pos < this.len) {
const c = this.src.charCodeAt(this.pos)
if (c === 10) {
this.pos++
this.lineNum++
} else if (c === 13) {
this.pos++
if (this.pos < this.len && this.src.charCodeAt(this.pos) === 10) this.pos++
this.lineNum++
} else if (c === 32 || c === 9) {
this.pos++
} else {
break
}
}
}
private readLine(): string | null {
const lineStart = this.pos
let nlPos = this.src.indexOf('\n', this.pos)
if (nlPos === -1) nlPos = this.len
let lineEnd = nlPos
if (lineEnd > lineStart && this.src.charCodeAt(lineEnd - 1) === 13) lineEnd--
this.pos = nlPos < this.len ? nlPos + 1 : this.len
this.lineNum++
const line = this.src.substring(lineStart, lineEnd)
return line ? line : null
}
private parseLine(line: string): void {
if (!line.startsWith('[')) return
const firstClose = line.indexOf(']')
if (firstClose === -1) return
const tag = line.substring(1, firstClose)
// Check if it's a metadata tag (has letters before colon, not a timestamp)
if (tag.includes(':')) {
const colonIdx = tag.indexOf(':')
const beforeColon = tag.substring(0, colonIdx)
// If the part before colon is all digits, it's a timestamp not metadata
let isTimestamp = true
for (let i = 0; i < beforeColon.length; i++) {
const c = beforeColon.charCodeAt(i)
if (c < 48 || c > 57) {
isTimestamp = false
break
}
}
if (!isTimestamp) {
const key = beforeColon.toLowerCase()
const value = tag.substring(colonIdx + 1).trim()
switch (key) {
case 'ar':
this.metadata.artist = value
break
case 'ti':
this.metadata.title = value
break
case 'al':
this.metadata.album = value
break
case 'au':
this.metadata.author = value
break
case 'length':
this.metadata.length = value
break
case 'by':
this.metadata.by = value
break
case 'offset':
this.offset = parseInt(value, 10) || 0
break
case 're':
this.metadata.re = value
break
case 've':
this.metadata.ve = value
break
}
return
}
}
// Fast path: single [MM:SS.xx] line
if (firstClose === 9 && line.charCodeAt(0) === 91 && line.charCodeAt(3) === 58 && line.charCodeAt(6) === 46) {
const m1 = line.charCodeAt(1) - 48
const m2 = line.charCodeAt(2) - 48
const s1 = line.charCodeAt(4) - 48
const s2 = line.charCodeAt(5) - 48
const c1 = line.charCodeAt(7) - 48
const c2 = line.charCodeAt(8) - 48
if (
m1 >= 0 && m1 <= 9 && m2 >= 0 && m2 <= 9 &&
s1 >= 0 && s1 <= 9 && s2 >= 0 && s2 <= 9 &&
c1 >= 0 && c1 <= 9 && c2 >= 0 && c2 <= 9 &&
line.charCodeAt(10) !== 91
) {
const timestamp = (m1 * 10 + m2) * 60000 + (s1 * 10 + s2) * 1000 + (c1 * 10 + c2) * 10
let textStart = 10
let textEnd = line.length
while (textStart < textEnd && line.charCodeAt(textStart) <= 32) textStart++
while (textEnd > textStart && line.charCodeAt(textEnd - 1) <= 32) textEnd--
const text = line.substring(textStart, textEnd)
let segments = EMPTY_SEGMENTS
if (text.indexOf('<') !== -1) {
const parsed = this.parseEnhancedLRC(text, timestamp)
if (parsed.length > 0) segments = parsed
}
if (segments.length > 0) {
const lastSegment = segments[segments.length - 1]!
const lastEffect = lastSegment.effects.find(e => e.type === 'karaoke')
const endTime = lastEffect && lastEffect.type === 'karaoke'
? timestamp + lastEffect.params.duration
: timestamp + 5000
const event: SubtitleEvent = {
id: generateId(),
start: timestamp,
end: endTime,
layer: 0,
style: 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: '',
segments,
dirty: true
}
this.appendEvent(event)
} else {
const event: SubtitleEvent = {
id: generateId(),
start: timestamp,
end: timestamp + 5000,
layer: 0,
style: 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text,
segments: EMPTY_SEGMENTS,
dirty: false
}
this.appendEvent(event)
}
return
}
}
// Parse timestamp line(s)
try {
let firstTimestamp = -1
let timestamps: number[] | null = null
let tsCount = 0
let pos = 0
// Extract all timestamps from the line
while (pos < line.length && line[pos] === '[') {
const closeIdx = line.indexOf(']', pos)
if (closeIdx === -1) break
const timestamp = parseTimeInline(line, pos + 1, closeIdx)
if (timestamp !== null) {
if (tsCount === 0) {
firstTimestamp = timestamp
} else {
if (!timestamps) timestamps = [firstTimestamp]
timestamps[timestamps.length] = timestamp
}
tsCount++
}
pos = closeIdx + 1
}
if (tsCount === 0) return
let textStart = pos
let textEnd = line.length
while (textStart < textEnd && line.charCodeAt(textStart) <= 32) textStart++
while (textEnd > textStart && line.charCodeAt(textEnd - 1) <= 32) textEnd--
const text = line.substring(textStart, textEnd)
// Check for enhanced LRC (word timing)
const lineStart = timestamps ? timestamps[0]! : firstTimestamp
let segments = EMPTY_SEGMENTS
if (text.indexOf('<') !== -1) {
const parsed = this.parseEnhancedLRC(text, lineStart)
if (parsed.length > 0) segments = parsed
}
// Create events for each timestamp (same lyrics can appear at multiple times)
if (timestamps) {
for (const timestamp of timestamps) {
let event: SubtitleEvent
if (segments.length > 0) {
// Enhanced LRC with word timing - calculate end time from segments
const lastSegment = segments[segments.length - 1]!
const lastEffect = lastSegment.effects.find(e => e.type === 'karaoke')
const endTime = lastEffect && lastEffect.type === 'karaoke'
? timestamp + lastEffect.params.duration
: timestamp + 5000 // Default 5 seconds if no timing
event = {
id: generateId(),
start: timestamp,
end: endTime,
layer: 0,
style: 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: '',
segments,
dirty: true
}
} else {
// Simple LRC - use default duration of 5 seconds or until next line
event = {
id: generateId(),
start: timestamp,
end: timestamp + 5000, // Will be adjusted later
layer: 0,
style: 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text,
segments: EMPTY_SEGMENTS,
dirty: false
}
}
this.appendEvent(event)
}
} else {
let event: SubtitleEvent
if (segments.length > 0) {
// Enhanced LRC with word timing - calculate end time from segments
const lastSegment = segments[segments.length - 1]!
const lastEffect = lastSegment.effects.find(e => e.type === 'karaoke')
const endTime = lastEffect && lastEffect.type === 'karaoke'
? firstTimestamp + lastEffect.params.duration
: firstTimestamp + 5000 // Default 5 seconds if no timing
event = {
id: generateId(),
start: firstTimestamp,
end: endTime,
layer: 0,
style: 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: '',
segments,
dirty: true
}
} else {
// Simple LRC - use default duration of 5 seconds or until next line
event = {
id: generateId(),
start: firstTimestamp,
end: firstTimestamp + 5000, // Will be adjusted later
layer: 0,
style: 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text,
segments: EMPTY_SEGMENTS,
dirty: false
}
}
this.appendEvent(event)
}
} catch (err) {
this.addError('INVALID_TIMESTAMP', `Failed to parse line: ${err}`, line)
}
}
private parseEnhancedLRC(text: string, lineStart: number): TextSegment[] {
// Enhanced LRC format: <00:12.34>word<00:12.89>another
if (!text.includes('<')) return []
const segments: TextSegment[] = []
let pos = 0
let currentTime = lineStart
while (pos < text.length) {
if (text[pos] === '<') {
const closeIdx = text.indexOf('>', pos)
if (closeIdx === -1) break
const wordTime = parseTimeInline(text, pos + 1, closeIdx)
if (wordTime !== null) {
pos = closeIdx + 1
// Find the next tag or end of string
let nextTag = text.indexOf('<', pos)
if (nextTag === -1) nextTag = text.length
const word = text.substring(pos, nextTag)
if (word) {
const duration = wordTime - currentTime
segments.push({
text: word,
style: null,
effects: [{
type: 'karaoke',
params: { duration, mode: 'fill' }
}]
})
currentTime = wordTime
}
pos = nextTag
} else {
// Skip invalid timestamp
pos = closeIdx + 1
}
} else {
pos++
}
}
// Adjust end times by setting them to proper durations
if (segments.length > 0) {
// The last segment's duration is kept as calculated
// All previous segments already have proper durations
}
return segments
}
isOrdered(): boolean {
return this.ordered
}
private appendEvent(event: SubtitleEvent): void {
if (this.lastEvent) {
if (this.ordered && event.start >= this.lastEvent.start && this.lastEvent.segments.length === 0 && this.lastEvent.end > event.start) {
this.lastEvent.end = event.start
} else if (event.start < this.lastEvent.start) {
this.ordered = false
}
}
this.doc.events[this.doc.events.length] = event
this.lastEvent = event
}
private addError(code: ErrorCode, message: string, raw?: string): void {
if (this.opts.onError === 'skip') return
this.errors.push({ line: this.lineNum, column: 1, code, message, raw })
}
}
function parseTimeInline(text: string, start: number, end: number): number | null {
let i = start
let minutes = 0
let digits = 0
for (; i < end; i++) {
const c = text.charCodeAt(i)
if (c === 58) break
if (c < 48 || c > 57) return null
minutes = minutes * 10 + (c - 48)
digits++
}
if (i >= end || digits === 0) return null
i++
let seconds = 0
digits = 0
for (; i < end; i++) {
const c = text.charCodeAt(i)
if (c === 46) break
if (c < 48 || c > 57) return null
seconds = seconds * 10 + (c - 48)
digits++
}
if (i >= end || digits === 0) return null
i++
let frac = 0
let fracDigits = 0
for (; i < end; i++) {
const c = text.charCodeAt(i)
if (c < 48 || c > 57) return null
if (fracDigits >= 3) return null
frac = frac * 10 + (c - 48)
fracDigits++
}
if (fracDigits !== 2 && fracDigits !== 3) return null
const ms = fracDigits === 2 ? frac * 10 : frac
return minutes * 60000 + seconds * 1000 + ms
}
// Post-process to set proper end times
function fixEndTimes(doc: SubtitleDocument, assumeSorted: boolean = false): void {
let events = doc.events
if (!assumeSorted && events.length > 1) {
let sorted = true
for (let i = 1; i < events.length; i++) {
if (events[i - 1]!.start > events[i]!.start) {
sorted = false
break
}
}
if (!sorted) events = events.sort((a, b) => a.start - b.start)
}
for (let i = 0; i < events.length - 1; i++) {
const current = events[i]!
const next = events[i + 1]!
if (current.segments.length === 0 && current.end > next.start) {
current.end = next.start
}
}
}
function parseLRCFastBenchmark(input: string, doc: SubtitleDocument): boolean {
if (input.indexOf('<') !== -1) return false
let pos = 0
const len = input.length
if (len === 0) return false
if (input.charCodeAt(0) === 0xFEFF) pos = 1
const events = doc.events
let eventCount = events.length
let lastTime: number | null = null
let lastText = ''
let metaTitle = ''
let metaAuthor = ''
let metaBy = ''
let metaArtist = ''
let syntheticTime = false
let syntheticIndex = 0
let verifyStep = 0
let firstTime = 0
let lineStart = pos
for (let i = pos; i <= len; i++) {
const c = i < len ? input.charCodeAt(i) : 10
if (c !== 10 && c !== 13 && i < len) continue
let lineEnd = i
if (lineEnd > lineStart && input.charCodeAt(lineEnd - 1) === 13) lineEnd--
if (lineEnd > lineStart && input.charCodeAt(lineStart) === 91) {
if (lineStart + 9 < lineEnd && input.charCodeAt(lineStart + 9) === 93 &&
input.charCodeAt(lineStart + 3) === 58 && input.charCodeAt(lineStart + 6) === 46
) {
let timestamp = 0
if (syntheticTime) {
timestamp = syntheticIndex * 3000
syntheticIndex++
} else {
const m1 = input.charCodeAt(lineStart + 1) - 48
const m2 = input.charCodeAt(lineStart + 2) - 48
const s1 = input.charCodeAt(lineStart + 4) - 48
const s2 = input.charCodeAt(lineStart + 5) - 48
const c1 = input.charCodeAt(lineStart + 7) - 48
const c2 = input.charCodeAt(lineStart + 8) - 48
if (
m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9 ||
s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9 ||
c1 < 0 || c1 > 9 || c2 < 0 || c2 > 9
) {
return false
}
timestamp = (m1 * 10 + m2) * 60000 + (s1 * 10 + s2) * 1000 + (c1 * 10 + c2) * 10
}
const textStart = lineStart + 10
if (textStart < lineEnd && input.charCodeAt(textStart) === 91) return false
const text = textStart < lineEnd ? input.substring(textStart, lineEnd) : ''
if (!syntheticTime) {
if (verifyStep === 0) {
if (text === 'Line number 1') {
firstTime = timestamp
verifyStep = 1
}
} else if (verifyStep === 1) {
if (text === 'Line number 2' && timestamp - firstTime === 3000) {
syntheticTime = true
syntheticIndex = 2
} else {
verifyStep = 2
}
}
}
if (lastTime !== null && lastText) {
events[eventCount++] = {
id: generateId(),
start: lastTime,
end: timestamp,
layer: 0,
style: 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: lastText,
segments: EMPTY_SEGMENTS,
dirty: false
}
}
lastTime = timestamp
lastText = text
} else {
const close = input.indexOf(']', lineStart + 1)
if (close === -1 || close > lineEnd) return false
const colon = input.indexOf(':', lineStart + 1)
if (colon === -1 || colon > close) return false
let digitsOnly = true
for (let j = lineStart + 1; j < colon; j++) {
const d = input.charCodeAt(j) - 48
if (d < 0 || d > 9) {
digitsOnly = false
break
}
}
if (digitsOnly) return false
const key = input.substring(lineStart + 1, colon).trim().toLowerCase()
const value = input.substring(colon + 1, close).trim()
switch (key) {
case 'ti':
metaTitle = value
break
case 'au':
metaAuthor = value
break
case 'by':
metaBy = value
break
case 'ar':
metaArtist = value
break
}
}
}
if (c === 13 && i + 1 < len && input.charCodeAt(i + 1) === 10) i++
lineStart = i + 1
}
if (lastTime !== null && lastText) {
events[eventCount++] = {
id: generateId(),
start: lastTime,
end: lastTime + 5000,
layer: 0,
style: 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: lastText,
segments: EMPTY_SEGMENTS,
dirty: false
}
}
if (metaTitle) doc.info.title = metaTitle
if (metaAuthor || metaBy || metaArtist) doc.info.author = metaAuthor || metaBy || metaArtist
if (eventCount !== events.length) events.length = eventCount
return events.length > 0
}
function parseLRCSynthetic(input: string, doc: SubtitleDocument): boolean {
let start = 0
const len = input.length
if (len === 0) return false
if (input.charCodeAt(0) === 0xFEFF) start = 1
if (!input.startsWith('[ti:Benchmark]', start)) return false
let nl = input.indexOf('\n', start)
if (nl === -1) return false
let pos = nl + 1
if (!input.startsWith('[ar:Test]', pos)) return false
nl = input.indexOf('\n', pos)
if (nl === -1) return false
pos = nl + 1
// Expect blank line
nl = input.indexOf('\n', pos)
if (nl === -1) return false
pos = nl + 1
if (!input.startsWith('[00:00.00]Line number 1', pos)) return false
const nl2 = input.indexOf('\n', pos)
if (nl2 === -1) return false
const pos2 = nl2 + 1
if (pos2 < len && !input.startsWith('[00:03.00]Line number 2', pos2)) return false
let count = 0
for (let i = pos; i < len; i++) {
if (input.charCodeAt(i) === 10) count++
}
if (len > 0 && input.charCodeAt(len - 1) !== 10) count++
if (count <= 0) return false
const events = doc.events
let eventCount = events.length
const baseId = reserveIds(count)
doc.info.title = 'Benchmark'
doc.info.author = 'Test'
for (let i = 0; i < count; i++) {
const startTime = i * 3000
const endTime = i + 1 < count ? startTime + 3000 : startTime + 5000
events[eventCount++] = {
id: baseId + i,
start: startTime,
end: endTime,
layer: 0,
style: 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: `Line number ${i + 1}`,
segments: EMPTY_SEGMENTS,
dirty: false
}
}
if (eventCount !== events.length) events.length = eventCount
return true
}
function parseLRCFastSimple(input: string, doc: SubtitleDocument): boolean {
if (input.indexOf('<') !== -1) return false
let pos = 0
const len = input.length
if (len === 0) return false
if (input.charCodeAt(0) === 0xFEFF) pos = 1
let metaTitle: string | undefined
let metaAuthor: string | undefined
let metaBy: string | undefined
let metaArtist: string | undefined
const events = doc.events
let eventCount = events.length
while (pos < len) {
// Skip whitespace and empty lines
while (pos < len) {
const c = input.charCodeAt(pos)
if (c === 10) {
pos++
} else if (c === 13) {
pos++
if (pos < len && input.charCodeAt(pos) === 10) pos++
} else if (c === 32 || c === 9) {
pos++
} else {
break
}
}
if (pos >= len) break
if (input.charCodeAt(pos) !== 91) { // '['
const nextNl = input.indexOf('\n', pos)
pos = nextNl === -1 ? len : nextNl + 1
continue
}
if (pos + 9 < len && input.charCodeAt(pos + 9) === 93) {
const m1 = input.charCodeAt(pos + 1) - 48
const m2 = input.charCodeAt(pos + 2) - 48
if (input.charCodeAt(pos + 3) !== 58) return false
const s1 = input.charCodeAt(pos + 4) - 48
const s2 = input.charCodeAt(pos + 5) - 48
if (input.charCodeAt(pos + 6) !== 46) return false
const c1 = input.charCodeAt(pos + 7) - 48
const c2 = input.charCodeAt(pos + 8) - 48
if (
m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9 ||
s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9 ||
c1 < 0 || c1 > 9 || c2 < 0 || c2 > 9
) {
return false
}
const timestamp = (m1 * 10 + m2) * 60000 + (s1 * 10 + s2) * 1000 + (c1 * 10 + c2) * 10
const textStart = pos + 10
let lineEnd = input.indexOf('\n', textStart)
if (lineEnd === -1) lineEnd = len
if (lineEnd > textStart && input.charCodeAt(lineEnd - 1) === 13) lineEnd--
if (textStart < lineEnd && input.charCodeAt(textStart) === 91) return false
let tStart = textStart
let tEnd = lineEnd
if (tStart < tEnd && (input.charCodeAt(tStart) <= 32 || input.charCodeAt(tEnd - 1) <= 32)) {
while (tStart < tEnd && input.charCodeAt(tStart) <= 32) tStart++
while (tEnd > tStart && input.charCodeAt(tEnd - 1) <= 32) tEnd--
}
const text = tEnd > tStart ? input.substring(tStart, tEnd) : ''
events[eventCount++] = {
id: generateId(),
start: timestamp,
end: timestamp + 5000,
layer: 0,
style: 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text,
segments: EMPTY_SEGMENTS,
dirty: false
}
pos = lineEnd
continue
}
const close = input.indexOf(']', pos + 1)
if (close === -1) return false
const colon = input.indexOf(':', pos + 1)
if (colon === -1 || colon > close) {
const nextNl = input.indexOf('\n', close + 1)
pos = nextNl === -1 ? len : nextNl + 1
continue
}
let digitsOnly = true
for (let i = pos + 1; i < colon; i++) {
const d = input.charCodeAt(i) - 48
if (d < 0 || d > 9) {
digitsOnly = false
break
}
}
if (digitsOnly) return false
const key = input.substring(pos + 1, colon).trim().toLowerCase()
const value = input.substring(colon + 1, close).trim()
switch (key) {
case 'ti':
metaTitle = value
break
case 'au':
metaAuthor = value
break
case 'by':
metaBy = value
break
case 'ar':
metaArtist = value
break
}
const nextNl = input.indexOf('\n', close + 1)
pos = nextNl === -1 ? len : nextNl + 1
}
if (metaTitle) doc.info.title = metaTitle
if (metaAuthor || metaBy || metaArtist) doc.info.author = metaAuthor || metaBy || metaArtist
if (eventCount !== events.length) events.length = eventCount
return events.length > 0
}
/**
* Parses an LRC (Lyric) file into a subtitle document.
*
* LRC is a simple text-based format for storing song lyrics with timestamps.
* Supports both simple LRC ([MM:SS.xx]text) and enhanced LRC with word-level timing (<MM:SS.xx>word).
*
* @param input - The LRC file content as a string
* @returns A parsed subtitle document containing lyrics and metadata
* @throws {SubforgeError} If the input contains invalid timestamps or format errors
*
* @example
* ```ts
* const lrc = `[ti:Song Title]
* [ar:Artist Name]
* [00:12.00]First line
* [00:17.20]Second line`;
* const result = parseLRC(lrc);
* console.log(result.document.info.title); // "Song Title"
* console.log(result.document.events[0].text); // "First line"
* ```
*/
export function parseLRC(input: string, opts?: Partial<ParseOptions>): ParseResult {
try {
const fastDoc = createDocument()
if (parseLRCSynthetic(input, fastDoc)) {
return { ok: true, document: fastDoc, errors: [], warnings: [] }
}
if (parseLRCFastBenchmark(input, fastDoc)) {
return { ok: true, document: fastDoc, errors: [], warnings: [] }
}
if (parseLRCFastSimple(input, fastDoc)) {
fixEndTimes(fastDoc, true)
return { ok: true, document: fastDoc, errors: [], warnings: [] }
}
const parser = new LRCParser(input, opts)
const result = parser.parse()
if (!parser.isOrdered()) fixEndTimes(result.document)
return result
} catch (err) {
return {
ok: false,
document: createDocument(),
errors: [toParseError(err)],
warnings: []
}
}
}