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:
cdnutterandClaude Opus 5.5 committed 2026-10-20 09:18:31 -07:00
1 parent e0184f540b
commit bea3f8f5a2
5 files changed
+234 -6

No files matched your search

+86
View File
@@ -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)
}
}
+46 -2
View File
@@ -106,6 +106,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
@ObservationIgnored private let eq: EqController
@ObservationIgnored private(set) var engine: StemEngine!
@ObservationIgnored private let queueFile: URL
@ObservationIgnored private let songMixFile: URL
private(set) var queue: [QueueItem] = []
private(set) var index = -1
@@ -121,6 +122,11 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
private(set) var waveform: Waveform?
private(set) var durationMs: Int64 = 0
private(set) var message: AppMessage?
/// Mixes saved for single songs (by rating key), put back whenever that song starts.
private(set) var songMixes: [String: [String: StemSetting]] = [:]
/// Karaoke mode: vocals out on every song, lyrics on Now Playing.
private(set) var karaoke = false
@ObservationIgnored private var preKaraokeMix: [String: StemSetting]?
@ObservationIgnored private var listeners: [UUID: () -> Void] = [:]
@ObservationIgnored private var messageListeners: [UUID: (String) -> Void] = [:]
@@ -148,6 +154,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
self.cache = cache
self.eq = eq
queueFile = dir.appendingPathComponent("queue.json")
songMixFile = dir.appendingPathComponent("song-mixes.json")
songMixes = (try? JSONDecoder().decode([String: [String: StemSetting]].self, from: Data(contentsOf: songMixFile))) ?? [:]
master = settings.masterVolume
engine = StemEngine(listener: self)
eq.onParams = { [weak self] p in self?.engine.eqParams = p }
@@ -405,6 +413,42 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
pushMix()
}
/// The mix a song starts with: karaoke if that's on, else the song's saved mix, else cleared (or kept, per settings).
private func startMix(_ t: Track) -> [String: StemSetting] {
if karaoke { return Self.karaokeMix }
return songMixes[t.ratingKey] ?? (settings.keepStemMix ? mix : [:])
}
private static let karaokeMix = ["vocals": StemSetting(muted: true)]
/// Turning it off puts back the song's saved mix, or the one from before, unless the mix was changed meanwhile.
func setKaraoke(_ on: Bool) {
guard on != karaoke else { return }
karaoke = on
if on {
preKaraokeMix = mix
applyMix(Self.karaokeMix)
} else {
if MixPresets.normalized(mix) == Self.karaokeMix { applyMix(songMix ?? preKaraokeMix ?? [:]) }
preKaraokeMix = nil
changed()
}
}
var songMix: [String: StemSetting]? { current.flatMap { songMixes[$0.track.ratingKey] } }
func saveSongMix() { setSongMix(MixPresets.normalized(mix)) }
func forgetSongMix() { setSongMix(nil) }
private func setSongMix(_ m: [String: StemSetting]?) {
guard let key = current?.track.ratingKey else { return }
songMixes[key] = m
let all = songMixes, file = songMixFile
DispatchQueue.global(qos: .utility).async {
if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) }
}
}
private func editMix(_ name: String, _ f: (inout StemSetting) -> Void) {
var s = mix[name] ?? StemSetting()
f(&s)
@@ -462,7 +506,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
if play && !activateSession() { return }
playWhenReady = play
buffering = true
if !settings.keepStemMix { mix = [:] }
mix = startMix(item.track)
changed()
save()
loadTask = Task { @MainActor [weak self] in
@@ -635,7 +679,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
guard let item = current else { return }
durationMs = item.track.durationMs
waveform = nil
if !settings.keepStemMix { mix = [:] }
mix = startMix(item.track)
source = currentEntry != nil ? .Stems : .Original
stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"])
pushMix()