iOS 1.0.4: per-song saved mixes, karaoke mode with synced lyrics
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e0184f540b
commit
bea3f8f5a2
5 files changed
+234
-6
No files matched your search
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
|
||||
struct LyricLine: Hashable {
|
||||
let ms: Int64
|
||||
let text: String
|
||||
}
|
||||
|
||||
struct Lyrics {
|
||||
var lines: [LyricLine] = []
|
||||
/// False for plain lyrics, which have no timings (every [ms] is 0).
|
||||
var synced = false
|
||||
var instrumental = false
|
||||
}
|
||||
|
||||
/// Lyrics from LRCLIB (lrclib.net), synced when it has them. Hits are cached on disk by track,
|
||||
/// misses only until the app restarts.
|
||||
actor LyricsSource {
|
||||
static let shared = LyricsSource()
|
||||
|
||||
private struct Record: Codable {
|
||||
var syncedLyrics: String?
|
||||
var plainLyrics: String?
|
||||
var instrumental: Bool?
|
||||
var duration: Double?
|
||||
}
|
||||
|
||||
private let dir: URL
|
||||
private var misses: Set<String> = []
|
||||
|
||||
private init() {
|
||||
dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appendingPathComponent("lyrics", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
/// Nil when LRCLIB has nothing for this song or couldn't be reached.
|
||||
func lyrics(_ t: Track) async -> Lyrics? {
|
||||
let file = dir.appendingPathComponent(t.ratingKey.replacingOccurrences(of: "/", with: "_") + ".json")
|
||||
if let data = try? Data(contentsOf: file), let r = try? JSONDecoder().decode(Record.self, from: data) { return Self.parse(r) }
|
||||
if misses.contains(t.ratingKey) { return nil }
|
||||
let secs = Double(t.durationMs) / 1000
|
||||
var rec = await get("get", ["track_name": t.title, "artist_name": t.artist, "album_name": t.album, "duration": String(Int(secs.rounded()))])
|
||||
.flatMap { try? JSONDecoder().decode(Record.self, from: $0) }
|
||||
if rec == nil, let data = await get("search", ["track_name": t.title, "artist_name": t.artist]),
|
||||
let found = try? JSONDecoder().decode([Record].self, from: data) {
|
||||
// Closest length first, so a live or extended version doesn't win.
|
||||
let close = found.filter { abs(($0.duration ?? 0) - secs) < 8 || secs <= 0 }
|
||||
rec = close.first { $0.syncedLyrics?.isEmpty == false } ?? close.first { $0.plainLyrics?.isEmpty == false } ?? close.first
|
||||
}
|
||||
guard let rec else {
|
||||
misses.insert(t.ratingKey)
|
||||
return nil
|
||||
}
|
||||
if let data = try? JSONEncoder().encode(rec) { try? data.write(to: file, options: .atomic) }
|
||||
return Self.parse(rec)
|
||||
}
|
||||
|
||||
private func get(_ path: String, _ query: [String: String]) async -> Data? {
|
||||
var c = URLComponents(string: "https://lrclib.net/api/\(path)")!
|
||||
c.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
|
||||
var req = URLRequest(url: c.url!, timeoutInterval: 10)
|
||||
req.setValue("Stemify iOS (https://git.iamchrisama.com/cdnutter/stemify)", forHTTPHeaderField: "User-Agent")
|
||||
guard let (data, resp) = try? await URLSession.shared.data(for: req), (resp as? HTTPURLResponse)?.statusCode == 200 else { return nil }
|
||||
return data
|
||||
}
|
||||
|
||||
private static let stamp = #/\[(\d+):(\d+(?:\.\d+)?)\]/#
|
||||
|
||||
/// LRC: each line starts with one or more [mm:ss.xx] stamps.
|
||||
private static func parse(_ r: Record) -> Lyrics {
|
||||
if let lrc = r.syncedLyrics, !lrc.isEmpty {
|
||||
var lines: [LyricLine] = []
|
||||
for raw in lrc.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||
let stamps = raw.matches(of: stamp)
|
||||
guard let last = stamps.last else { continue }
|
||||
let text = raw[last.range.upperBound...].trimmingCharacters(in: .whitespaces)
|
||||
for m in stamps {
|
||||
let ms = (Int64(m.output.1) ?? 0) * 60_000 + Int64((Double(m.output.2) ?? 0) * 1000)
|
||||
lines.append(LyricLine(ms: ms, text: text))
|
||||
}
|
||||
}
|
||||
return Lyrics(lines: lines.sorted { $0.ms < $1.ms }, synced: true)
|
||||
}
|
||||
let plain = (r.plainLyrics ?? "").split(separator: "\n", omittingEmptySubsequences: false).map { LyricLine(ms: 0, text: String($0)) }
|
||||
return Lyrics(lines: plain.count > 1 || plain.first?.text.isEmpty == false ? plain : [], instrumental: r.instrumental == true)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user