| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101 |
- import Foundation
- /// Parses LRC (synced lyrics) format into timestamped lines.
- /// LRC format: `[mm:ss.xx] Lyric line text`
- struct LyricsParser {
- /// A single timestamped lyric line.
- struct LyricLine: Identifiable, Equatable {
- let id = UUID()
- let timestamp: TimeInterval
- let text: String
- /// Format timestamp as mm:ss.
- var formattedTime: String {
- let minutes = Int(timestamp) / 60
- let seconds = Int(timestamp) % 60
- return String(format: "%d:%02d", minutes, seconds)
- }
- }
- /// Parse synced LRC lyrics into an array of timestamped lines, sorted by time.
- static func parseSynced(_ lrc: String) -> [LyricLine] {
- var lines: [LyricLine] = []
- for rawLine in lrc.components(separatedBy: .newlines) {
- let trimmed = rawLine.trimmingCharacters(in: .whitespaces)
- guard !trimmed.isEmpty else { continue }
- // Match [mm:ss.xx] or [mm:ss:xx] patterns
- // Can have multiple timestamps per line: [00:12.34][00:45.67] text
- let pattern = #"\[(\d{1,3}):(\d{2})[\.:,](\d{2,3})\]"#
- guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
- let matches = regex.matches(in: trimmed, range: NSRange(trimmed.startIndex..., in: trimmed))
- guard !matches.isEmpty else { continue }
- // Extract the text after all timestamps
- let lastMatch = matches.last!
- let textStartIndex = trimmed.index(trimmed.startIndex, offsetBy: lastMatch.range.upperBound)
- let text = String(trimmed[textStartIndex...]).trimmingCharacters(in: .whitespaces)
- // Skip empty lyric lines (instrumental breaks, etc.) — keep them as blank markers
- // Some LRC files use empty lines to indicate pauses
- // Create a line for each timestamp
- for match in matches {
- guard let minRange = Range(match.range(at: 1), in: trimmed),
- let secRange = Range(match.range(at: 2), in: trimmed),
- let msRange = Range(match.range(at: 3), in: trimmed),
- let minutes = Double(trimmed[minRange]),
- let seconds = Double(trimmed[secRange]),
- let rawMs = Double(trimmed[msRange]) else { continue }
- // Handle both 2-digit (centiseconds) and 3-digit (milliseconds) fractional parts
- let fractional: Double
- let msString = String(trimmed[msRange])
- if msString.count <= 2 {
- fractional = rawMs / 100.0
- } else {
- fractional = rawMs / 1000.0
- }
- let timestamp = minutes * 60.0 + seconds + fractional
- lines.append(LyricLine(timestamp: timestamp, text: text))
- }
- }
- return lines.sorted { $0.timestamp < $1.timestamp }
- }
- /// Parse plain (unsynced) lyrics into lines with no timestamps.
- static func parsePlain(_ text: String) -> [LyricLine] {
- text.components(separatedBy: .newlines)
- .enumerated()
- .map { index, line in
- LyricLine(timestamp: TimeInterval(index), text: line)
- }
- }
- /// Find the index of the current lyric line for a given playback time.
- static func currentLineIndex(in lines: [LyricLine], at time: TimeInterval) -> Int? {
- guard !lines.isEmpty else { return nil }
- // Binary search for the last line whose timestamp <= current time
- var low = 0
- var high = lines.count - 1
- var result = -1
- while low <= high {
- let mid = (low + high) / 2
- if lines[mid].timestamp <= time {
- result = mid
- low = mid + 1
- } else {
- high = mid - 1
- }
- }
- return result >= 0 ? result : nil
- }
- }
|