NavidromeApp/iOS/Views/Companion/SmartCrossfadeManager.swift
2026-04-10 19:44:55 -07:00

597 lines
24 KiB
Swift

import Foundation
import AVFoundation
import Combine
import QuartzCore
// MARK: - Smart Crossfade Manager
// Dual-AVPlayer (A/B) crossfade engine.
//
// Profile fields (from Companion API):
// silence_start = where TRAILING silence begins (crossfade trigger point)
// silence_end = where LEADING silence ends (seek-to point for skip)
// loudness_lufs = integrated loudness for volume normalization
//
// Flow:
// 1. playSong() loads item, starts playback IMMEDIATELY, then fetches profile async
// 2. Profile arrives applies LUFS volume, installs boundary observer at silence_start - fade
// 3. Boundary fires beginCrossfade() fades A B via CADisplayLink
// 4. Fade completes swap slots, install new boundary, request next track
class SmartCrossfadeManager: ObservableObject {
static let shared = SmartCrossfadeManager()
// MARK: - Settings (persisted)
@Published var isEnabled = false {
didSet { UserDefaults.standard.set(isEnabled, forKey: "smart_crossfade_enabled") }
}
@Published var crossfadeDuration: TimeInterval = 5.0 {
didSet { UserDefaults.standard.set(crossfadeDuration, forKey: "smart_crossfade_duration") }
}
@Published var targetLUFS: Double = -14.0 {
didSet { UserDefaults.standard.set(targetLUFS, forKey: "smart_target_lufs") }
}
@Published var skipSilence: Bool = true {
didSet { UserDefaults.standard.set(skipSilence, forKey: "smart_skip_silence") }
}
// MARK: - State
@Published var currentProfile: SmartDJProfile?
@Published var nextProfile: SmartDJProfile?
@Published var isCrossfading = false
// MARK: - Dual Players
private(set) var playerA: AVPlayer = AVPlayer()
private(set) var playerB: AVPlayer = AVPlayer()
private var activeSlot: Slot = .a
var activePlayer: AVPlayer { activeSlot == .a ? playerA : playerB }
var standbyPlayer: AVPlayer { activeSlot == .a ? playerB : playerA }
enum Slot { case a, b }
// MARK: - Volume Targets (LUFS-normalized)
private var activeMaxVolume: Float = 1.0
private var standbyMaxVolume: Float = 1.0
// MARK: - Observers
private var boundaryObserver: Any?
private var timeObserver: Any?
private var statusObservation: NSKeyValueObservation?
// MARK: - Crossfade Animation
private var displayLink: CADisplayLink?
private var fadeStartTime: CFTimeInterval = 0
private var fadeOutStartVolume: Float = 1.0
private var fadeInTargetVolume: Float = 1.0
private var didHandoffVisualizer = false
// MARK: - Track Info (for self-crossfade)
private var currentSongURL: URL?
private var currentSong: Song?
// MARK: - Queue Sync
/// The song that was loaded into standby and is now in the active player post-fade.
/// AudioPlayer reads this in needsNextTrack to set queueIndex correctly.
private(set) var pendingNextSong: Song?
/// Set by AudioPlayer.notifyQueueChanged() when a queue mutation arrives mid-fade.
/// finalizeCrossfade checks this and re-prepares standby after the swap.
var queueChangedDuringFade: Bool = false
// MARK: - Profile Cache
private var profiles: [String: SmartDJProfile] = [:]
private let api = CompanionAPIService()
// MARK: - Callbacks
var needsNextTrack: (() -> Void)?
var timeUpdate: ((TimeInterval, TimeInterval) -> Void)?
var visualizerHandoff: ((AVPlayer) -> Void)?
private init() {
isEnabled = UserDefaults.standard.bool(forKey: "smart_crossfade_enabled")
let dur = UserDefaults.standard.double(forKey: "smart_crossfade_duration")
crossfadeDuration = dur > 0 ? dur : 5.0
let lufs = UserDefaults.standard.double(forKey: "smart_target_lufs")
targetLUFS = lufs < 0 ? lufs : -14.0
skipSilence = UserDefaults.standard.object(forKey: "smart_skip_silence") as? Bool ?? true
}
//
// MARK: - PUBLIC API
//
/// Load and play a song on the active player.
/// Starts playback IMMEDIATELY profile adjustments arrive async.
func playSong(_ song: Song, url: URL) {
log("▶ Playing: \(song.title) on \(slotName(activeSlot))")
cancelCrossfade()
removeAllObservers()
currentSong = song
currentSongURL = url
// Load and play immediately don't wait for profile
let asset = AVURLAsset(url: url)
let item = AVPlayerItem(asset: asset)
activePlayer.replaceCurrentItem(with: item)
activePlayer.volume = 1.0
activeMaxVolume = 1.0
activePlayer.play()
// Install time observer right away for seek bar
installTimeObserver()
// Fetch profile async apply adjustments when it arrives
Task { @MainActor [weak self] in
guard let self else { return }
let profile = await self.fetchProfile(for: song)
self.currentProfile = profile
// Apply LUFS normalization
self.activeMaxVolume = self.lufsToVolume(profile?.loudnessLUFS)
// Only adjust volume if not already crossfading
if !self.isCrossfading {
self.activePlayer.volume = self.activeMaxVolume
}
// Skip leading silence (silenceEnd = where leading silence ends)
if self.skipSilence, let silEnd = profile?.silenceEnd, silEnd > 0.3, silEnd < 10.0 {
let current = self.activePlayer.currentTime().seconds
// Only seek if we're still near the start
if current < silEnd + 1.0 {
await self.activePlayer.seek(to: CMTime(seconds: silEnd, preferredTimescale: 1000))
self.log("Skipped \(String(format: "%.1f", silEnd))s leading silence")
}
}
// Install boundary observer for crossfade trigger
self.installCrossfadeTrigger(profile: profile)
}
}
/// Pre-load the next song into standby (paused, vol 0, seeked past leading silence).
/// Skipped if a crossfade is in progress finalizeCrossfade will call needsNextTrack
/// which calls prepareNextForCrossfade, picking up any queue changes at that point.
func prepareNext(_ song: Song, url: URL) {
guard !isCrossfading else {
log("⏭ prepareNext skipped mid-fade — will pick up \(song.title) after swap")
return
}
log("⏳ Preparing: \(song.title) in \(slotName(activeSlot == .a ? .b : .a))")
pendingNextSong = song
let asset = AVURLAsset(url: url)
let item = AVPlayerItem(asset: asset)
standbyPlayer.replaceCurrentItem(with: item)
standbyPlayer.volume = 0.0
standbyPlayer.pause()
Task { @MainActor [weak self] in
guard let self else { return }
let profile = await self.fetchProfile(for: song)
self.nextProfile = profile
self.standbyMaxVolume = self.lufsToVolume(profile?.loudnessLUFS)
// Wait for item to be ready before seeking
if let silEnd = profile?.silenceEnd, silEnd > 0.3, silEnd < 10.0 {
let seekTarget = CMTime(seconds: silEnd, preferredTimescale: 1000)
let standby = self.standbyPlayer
let observation = item.observe(\.status, options: [.new]) { observedItem, _ in
guard observedItem.status == .readyToPlay else { return }
DispatchQueue.main.async {
standby.seek(to: seekTarget)
}
}
self.statusObservation = observation
}
}
}
func pause() {
activePlayer.pause()
if isCrossfading { standbyPlayer.pause() }
}
func resume() {
activePlayer.play()
if isCrossfading { standbyPlayer.play() }
// Reinstall the time observer if it was removed by suspendForBackground()
// (happens when app is backgrounded while paused then brought back to foreground)
if timeObserver == nil { installTimeObserver() }
}
func seek(to time: TimeInterval) {
let cmTime = CMTime(seconds: time, preferredTimescale: 1000)
// Remove existing boundary observer before seeking
// to prevent it firing during the seek operation
removeBoundaryObserver()
activePlayer.seek(to: cmTime) { [weak self] finished in
guard finished, let self else { return }
// Reinstall boundary observer AFTER seek completes
// Only if we haven't seeked past the trigger point
DispatchQueue.main.async {
if !self.isCrossfading {
self.installCrossfadeTrigger(profile: self.currentProfile)
}
}
}
}
func stop() {
cancelCrossfade()
removeAllObservers()
playerA.pause()
playerA.replaceCurrentItem(with: nil)
playerB.pause()
playerB.replaceCurrentItem(with: nil)
activeSlot = .a
currentProfile = nil
nextProfile = nil
currentSong = nil
currentSongURL = nil
activeMaxVolume = 1.0
standbyMaxVolume = 1.0
}
//
// MARK: - LUFS Volume
//
private func lufsToVolume(_ lufs: Double?) -> Float {
guard let lufs = lufs, lufs < 0 else { return 1.0 }
let gainDB = targetLUFS - lufs
let linear = pow(10.0, gainDB / 20.0)
let clamped = Float(min(max(linear, 0.05), 1.0))
log("LUFS \(String(format: "%.1f", lufs)) → vol \(String(format: "%.3f", clamped))")
return clamped
}
//
// MARK: - CROSSFADE TRIGGER
//
/// Install a boundary time observer to trigger crossfade.
/// Uses silenceStart (trailing silence begin) as the end-of-content marker.
/// Falls back to duration - crossfadeDuration if no profile.
private func installCrossfadeTrigger(profile: SmartDJProfile?) {
removeBoundaryObserver()
guard let item = activePlayer.currentItem else { return }
let duration = item.duration.seconds
// If duration isn't ready yet, poll until it is
guard duration.isFinite, duration > 0 else {
let pollObs = activePlayer.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
queue: .main
) { [weak self] _ in
guard let self else { return }
let dur = self.activePlayer.currentItem?.duration.seconds ?? 0
if dur.isFinite, dur > 0 {
// Duration ready remove poll, install real trigger
if let obs = self.boundaryObserver {
self.activePlayer.removeTimeObserver(obs)
self.boundaryObserver = nil
}
self.installCrossfadeTriggerWithDuration(dur, profile: profile)
}
}
boundaryObserver = pollObs
return
}
installCrossfadeTriggerWithDuration(duration, profile: profile)
}
private func installCrossfadeTriggerWithDuration(_ duration: TimeInterval, profile: SmartDJProfile?) {
// silenceStart = where trailing silence begins
let silenceStart = profile?.silenceStart ?? 0
let contentEnd: TimeInterval
// Valid if it's in the second half of the track
if silenceStart > duration * 0.5 && silenceStart <= duration {
contentEnd = silenceStart
} else {
// No valid trailing silence use full duration
contentEnd = duration
}
// Trigger = content end minus crossfade duration (min 1s from start)
let triggerTime = max(1.0, contentEnd - crossfadeDuration)
// Don't install if we've already seeked past the trigger
let currentPos = activePlayer.currentTime().seconds
if currentPos.isFinite && currentPos >= triggerTime {
log("⏭ Skipping trigger — already past \(String(format: "%.1f", triggerTime))s")
return
}
log("🎯 Trigger at \(String(format: "%.1f", triggerTime))s " +
"(content \(String(format: "%.1f", contentEnd))s, " +
"dur \(String(format: "%.1f", duration))s)")
let boundary = CMTime(seconds: triggerTime, preferredTimescale: 1000)
boundaryObserver = activePlayer.addBoundaryTimeObserver(
forTimes: [NSValue(time: boundary)],
queue: .main
) { [weak self] in
self?.beginCrossfade()
}
}
private func removeBoundaryObserver() {
if let obs = boundaryObserver {
activePlayer.removeTimeObserver(obs)
boundaryObserver = nil
}
}
//
// MARK: - TIME OBSERVER
//
private func installTimeObserver() {
removeTimeObserver()
let interval = CMTime(seconds: 0.1, preferredTimescale: 600)
timeObserver = activePlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
guard let self else { return }
// During crossfade past midpoint, report from the INCOMING player
// so the seek bar shows the new track's position, not -0:00
let reportPlayer: AVPlayer
if self.isCrossfading && self.didHandoffVisualizer {
reportPlayer = self.standbyPlayer
} else {
reportPlayer = self.activePlayer
}
let current = reportPlayer.currentTime().seconds
guard current.isFinite else { return }
let dur = reportPlayer.currentItem?.duration.seconds ?? 0
if dur.isFinite, dur > 0 {
self.timeUpdate?(current, dur)
}
}
}
private func removeTimeObserver() {
if let obs = timeObserver {
activePlayer.removeTimeObserver(obs)
timeObserver = nil
}
}
/// Called when app enters background removes the 10Hz time observer that
/// drives @Published currentTime/duration updates and burns main-thread CPU.
func suspendForBackground() {
removeTimeObserver()
displayLink?.invalidate()
displayLink = nil
}
/// Called when app returns to foreground reinstalls the time observer.
func resumeFromBackground() {
guard timeObserver == nil else { return }
installTimeObserver()
}
private func removeAllObservers() {
removeBoundaryObserver()
removeTimeObserver()
statusObservation?.invalidate()
statusObservation = nil
}
//
// MARK: - CROSSFADE EXECUTION
//
private func beginCrossfade() {
guard !isCrossfading else { return }
// If standby has nothing, load self-crossfade
if standbyPlayer.currentItem == nil {
guard let url = currentSongURL else {
log("⚠️ No standby and no current URL")
return
}
log("🔁 No next track — self-crossfade")
let asset = AVURLAsset(url: url)
let item = AVPlayerItem(asset: asset)
standbyPlayer.replaceCurrentItem(with: item)
standbyMaxVolume = activeMaxVolume
nextProfile = currentProfile
pendingNextSong = currentSong // self-crossfade: "next" == current
// Seek past leading silence
if skipSilence, let silEnd = currentProfile?.silenceEnd, silEnd > 0.3, silEnd < 10.0 {
standbyPlayer.seek(to: CMTime(seconds: silEnd, preferredTimescale: 1000))
}
}
log("🔀 BEGIN (\(String(format: "%.1f", crossfadeDuration))s)")
isCrossfading = true
didHandoffVisualizer = false
fadeOutStartVolume = activePlayer.volume
fadeInTargetVolume = standbyMaxVolume
standbyPlayer.volume = 0.0
standbyPlayer.play()
fadeStartTime = CACurrentMediaTime()
displayLink = CADisplayLink(target: self, selector: #selector(crossfadeTick))
displayLink?.add(to: .main, forMode: .common)
}
@objc private func crossfadeTick() {
let elapsed = CACurrentMediaTime() - fadeStartTime
let progress = Float(min(elapsed / crossfadeDuration, 1.0))
// Equal-power crossfade
let fadeOut = cosf(progress * .pi / 2.0)
let fadeIn = sinf(progress * .pi / 2.0)
activePlayer.volume = fadeOutStartVolume * fadeOut
standbyPlayer.volume = fadeInTargetVolume * fadeIn
if progress >= 0.5 && !didHandoffVisualizer {
didHandoffVisualizer = true
visualizerHandoff?(standbyPlayer)
}
if progress >= 1.0 {
finalizeCrossfade()
}
}
private func finalizeCrossfade() {
displayLink?.invalidate()
displayLink = nil
let outgoing = activePlayer
outgoing.pause()
outgoing.replaceCurrentItem(with: nil)
removeAllObservers()
activeSlot = (activeSlot == .a) ? .b : .a
currentProfile = nextProfile
nextProfile = nil
activeMaxVolume = standbyMaxVolume
standbyMaxVolume = 1.0
activePlayer.volume = activeMaxVolume
isCrossfading = false
log("🔀 DONE → \(slotName(activeSlot))")
installTimeObserver()
installCrossfadeTrigger(profile: currentProfile)
// needsNextTrack reads pendingNextSong to sync queueIndex, then clears it via
// prepareNextForCrossfade which sets the new pendingNextSong for the NEXT fade.
needsNextTrack?()
pendingNextSong = nil
// If queue changed while we were fading, needsNextTrack prepareNextForCrossfade
// already ran and picked up the current queue state. Just clear the flag.
queueChangedDuringFade = false
}
private func cancelCrossfade() {
displayLink?.invalidate()
displayLink = nil
isCrossfading = false
didHandoffVisualizer = false
}
//
// MARK: - PROFILE FETCHING
//
private func fetchProfile(for song: Song) async -> SmartDJProfile? {
guard CompanionSettings.shared.smartDJEnabled else { return nil }
guard let path = song.path else { return nil }
// 1. Memory/disk cache
if let cached = profiles[song.id] { return cached }
if let cached = SmartDJCache.shared.get(path) {
profiles[song.id] = cached
return cached
}
// 2. Companion API (server-side analysis)
if CompanionSettings.shared.isEnabled {
do {
let profile = try await api.fetchProfile(relativePath: path)
profiles[song.id] = profile
SmartDJCache.shared.store(profile, for: path)
log("Profile (API): trailing=\(profile.silenceStart ?? 0)s, " +
"leading=\(profile.silenceEnd ?? 0)s, " +
"LUFS=\(profile.loudnessLUFS ?? 0)")
return profile
} catch {
log("Profile API failed, trying on-device: \(error.localizedDescription)")
}
}
// 3. On-device analysis fallback for local/downloaded files only.
// Runs OfflineAudioAnalyzer's combined pass to extract silence boundaries
// and an approximate integrated loudness in one file read.
if let localURL = OfflineManager.shared.localURL(for: song.id) {
if let profile = await analyzeOnDevice(song: song, url: localURL) {
profiles[song.id] = profile
SmartDJCache.shared.store(profile, for: path)
return profile
}
}
return nil
}
/// On-device SmartDJ profile extraction using the combined OfflineAudioAnalyzer pass.
/// Returns silence boundaries and approximate LUFS without requiring Companion API.
private func analyzeOnDevice(song: Song, url: URL) async -> SmartDJProfile? {
log("On-device analysis: \(song.title)")
do {
let result = try await OfflineAudioAnalyzer.shared.analyzeWithSmartDJ(
url: url,
pointsCount: 20, // minimal for speed we only need the SmartDJ data
fps: 15.0, // low fps = fewer FFT frames = faster
cutoff: 80,
extractSmartDJ: true,
progress: nil
)
let profile = SmartDJProfile(
bpm: nil,
silenceStart: result.silenceStart,
silenceEnd: result.silenceEnd,
loudnessLUFS: result.loudnessLUFS
)
log("On-device profile: trailing=\(String(format: "%.2f", result.silenceStart ?? 0))s, " +
"leading=\(String(format: "%.2f", result.silenceEnd ?? 0))s, " +
"LUFS≈\(String(format: "%.1f", result.loudnessLUFS ?? 0))")
return profile
} catch {
log("On-device analysis failed: \(error.localizedDescription)")
return nil
}
}
//
// MARK: - HELPERS
//
func resolveURL(for song: Song) -> URL? {
if let localURL = OfflineManager.shared.localURL(for: song.id) {
return localURL
}
let quality = StreamingQuality.shared
return ServerManager.shared.client.streamURL(
songId: song.id,
format: quality.format,
maxBitRate: quality.maxBitRate
)
}
private func slotName(_ slot: Slot) -> String {
slot == .a ? "A" : "B"
}
private func log(_ msg: String) {
DebugLogger.shared.log(msg, category: "SmartDJ")
}
}
// MARK: - Notification
extension Notification.Name {
static let smartDJNeedsNextTrack = Notification.Name("smartDJNeedsNextTrack")
}