NavidromeApp/iOS/Views/Companion/SmartCrossfadeManager.swift

488 lines
19 KiB
Swift
Raw Normal View History

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: - 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).
func prepareNext(_ song: Song, url: URL) {
log("⏳ Preparing: \(song.title) in \(slotName(activeSlot == .a ? .b : .a))")
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
self.statusObservation = item.observe(\.status) { [weak self] observedItem, _ in
guard observedItem.status == .readyToPlay else { return }
DispatchQueue.main.async { [weak self] in
self?.statusObservation?.invalidate()
self?.statusObservation = nil
standby.seek(to: seekTarget)
}
}
}
}
}
func pause() {
activePlayer.pause()
if isCrossfading { standbyPlayer.pause() }
}
func resume() {
activePlayer.play()
if isCrossfading { standbyPlayer.play() }
}
func seek(to time: TimeInterval) {
let cmTime = CMTime(seconds: time, preferredTimescale: 1000)
activePlayer.seek(to: cmTime)
}
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)
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
}
}
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
// 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?()
}
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 }
if let cached = profiles[song.id] { return cached }
do {
let profile = try await api.fetchProfile(relativePath: path)
profiles[song.id] = profile
log("Profile: trailing=\(profile.silenceStart ?? 0)s, " +
"leading=\(profile.silenceEnd ?? 0)s, " +
"LUFS=\(profile.loudnessLUFS ?? 0)")
return profile
} catch {
log("Profile 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")
}