LyricsParser.swift 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  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. // Skip empty lyric lines (instrumental breaks, etc.) — keep them as blank markers
  34. // Some LRC files use empty lines to indicate pauses
  35. // Create a line for each timestamp
  36. for match in matches {
  37. guard let minRange = Range(match.range(at: 1), in: trimmed),
  38. let secRange = Range(match.range(at: 2), in: trimmed),
  39. let msRange = Range(match.range(at: 3), in: trimmed),
  40. let minutes = Double(trimmed[minRange]),
  41. let seconds = Double(trimmed[secRange]),
  42. let rawMs = Double(trimmed[msRange]) else { continue }
  43. // Handle both 2-digit (centiseconds) and 3-digit (milliseconds) fractional parts
  44. let fractional: Double
  45. let msString = String(trimmed[msRange])
  46. if msString.count <= 2 {
  47. fractional = rawMs / 100.0
  48. } else {
  49. fractional = rawMs / 1000.0
  50. }
  51. let timestamp = minutes * 60.0 + seconds + fractional
  52. lines.append(LyricLine(timestamp: timestamp, text: text))
  53. }
  54. }
  55. return lines.sorted { $0.timestamp < $1.timestamp }
  56. }
  57. /// Parse plain (unsynced) lyrics into lines with no timestamps.
  58. static func parsePlain(_ text: String) -> [LyricLine] {
  59. text.components(separatedBy: .newlines)
  60. .enumerated()
  61. .map { index, line in
  62. LyricLine(timestamp: TimeInterval(index), text: line)
  63. }
  64. }
  65. /// Find the index of the current lyric line for a given playback time.
  66. static func currentLineIndex(in lines: [LyricLine], at time: TimeInterval) -> Int? {
  67. guard !lines.isEmpty else { return nil }
  68. // Binary search for the last line whose timestamp <= current time
  69. var low = 0
  70. var high = lines.count - 1
  71. var result = -1
  72. while low <= high {
  73. let mid = (low + high) / 2
  74. if lines[mid].timestamp <= time {
  75. result = mid
  76. low = mid + 1
  77. } else {
  78. high = mid - 1
  79. }
  80. }
  81. return result >= 0 ? result : nil
  82. }
  83. }