LyricsParser.swift 3.7 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. import Foundation
  2. /// Parses LRC (synced lyrics) format into timestamped lines.
  3. /// LRC format: `[mm:ss.xx] Lyric line text`
  4. struct LyricsParser {
  5. /// A single timestamped lyric line.
  6. struct LyricLine: Identifiable, Equatable {
  7. let id = UUID()
  8. let timestamp: TimeInterval
  9. let text: String
  10. /// Format timestamp as mm:ss.
  11. var formattedTime: String {
  12. let minutes = Int(timestamp) / 60
  13. let seconds = Int(timestamp) % 60
  14. return String(format: "%d:%02d", minutes, seconds)
  15. }
  16. }
  17. /// Parse synced LRC lyrics into an array of timestamped lines, sorted by time.
  18. static func parseSynced(_ lrc: String) -> [LyricLine] {
  19. var lines: [LyricLine] = []
  20. for rawLine in lrc.components(separatedBy: .newlines) {
  21. let trimmed = rawLine.trimmingCharacters(in: .whitespaces)
  22. guard !trimmed.isEmpty else { continue }
  23. // Match [mm:ss.xx] or [mm:ss:xx] patterns
  24. // Can have multiple timestamps per line: [00:12.34][00:45.67] text
  25. let pattern = #"\[(\d{1,3}):(\d{2})[\.:,](\d{2,3})\]"#
  26. guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
  27. let matches = regex.matches(in: trimmed, range: NSRange(trimmed.startIndex..., in: trimmed))
  28. guard !matches.isEmpty else { continue }
  29. // Extract the text after all timestamps
  30. let lastMatch = matches.last!
  31. let textStartIndex = trimmed.index(trimmed.startIndex, offsetBy: lastMatch.range.upperBound)
  32. let text = String(trimmed[textStartIndex...]).trimmingCharacters(in: .whitespaces)
  33. // Create a line for each timestamp
  34. for match in matches {
  35. guard let minRange = Range(match.range(at: 1), in: trimmed),
  36. let secRange = Range(match.range(at: 2), in: trimmed),
  37. let msRange = Range(match.range(at: 3), in: trimmed),
  38. let minutes = Double(trimmed[minRange]),
  39. let seconds = Double(trimmed[secRange]),
  40. let rawMs = Double(trimmed[msRange]) else { continue }
  41. // Handle both 2-digit (centiseconds) and 3-digit (milliseconds) fractional parts
  42. let fractional: Double
  43. let msString = String(trimmed[msRange])
  44. if msString.count <= 2 {
  45. fractional = rawMs / 100.0
  46. } else {
  47. fractional = rawMs / 1000.0
  48. }
  49. let timestamp = minutes * 60.0 + seconds + fractional
  50. lines.append(LyricLine(timestamp: timestamp, text: text))
  51. }
  52. }
  53. return lines.sorted { $0.timestamp < $1.timestamp }
  54. }
  55. /// Parse plain (unsynced) lyrics into lines with no timestamps.
  56. static func parsePlain(_ text: String) -> [LyricLine] {
  57. text.components(separatedBy: .newlines)
  58. .enumerated()
  59. .map { index, line in
  60. LyricLine(timestamp: TimeInterval(index), text: line)
  61. }
  62. }
  63. /// Find the index of the current lyric line for a given playback time.
  64. static func currentLineIndex(in lines: [LyricLine], at time: TimeInterval) -> Int? {
  65. guard !lines.isEmpty else { return nil }
  66. // Binary search for the last line whose timestamp <= current time
  67. var low = 0
  68. var high = lines.count - 1
  69. var result = -1
  70. while low <= high {
  71. let mid = (low + high) / 2
  72. if lines[mid].timestamp <= time {
  73. result = mid
  74. low = mid + 1
  75. } else {
  76. high = mid - 1
  77. }
  78. }
  79. return result >= 0 ? result : nil
  80. }
  81. }