2026-03-28 13:49:47 -07:00
|
|
|
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?
|
|
|
|
|
|
2026-04-04 07:42:23 -07:00
|
|
|
// 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
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
// 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).
|
2026-04-04 07:42:23 -07:00
|
|
|
/// Skipped if a crossfade is in progress — finalizeCrossfade will call needsNextTrack
|
|
|
|
|
/// which calls prepareNextForCrossfade, picking up any queue changes at that point.
|
2026-03-28 13:49:47 -07:00
|
|
|
func prepareNext(_ song: Song, url: URL) {
|
2026-04-04 07:42:23 -07:00
|
|
|
guard !isCrossfading else {
|
|
|
|
|
log("⏭ prepareNext skipped mid-fade — will pick up \(song.title) after swap")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-28 13:49:47 -07:00
|
|
|
log("⏳ Preparing: \(song.title) in \(slotName(activeSlot == .a ? .b : .a))")
|
2026-04-04 07:42:23 -07:00
|
|
|
pendingNextSong = song
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
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
|
2026-04-03 15:48:37 -07:00
|
|
|
let observation = item.observe(\.status, options: [.new]) { observedItem, _ in
|
2026-03-28 13:49:47 -07:00
|
|
|
guard observedItem.status == .readyToPlay else { return }
|
2026-04-03 15:48:37 -07:00
|
|
|
DispatchQueue.main.async {
|
2026-03-28 13:49:47 -07:00
|
|
|
standby.seek(to: seekTarget)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-03 15:48:37 -07:00
|
|
|
self.statusObservation = observation
|
2026-03-28 13:49:47 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func pause() {
|
|
|
|
|
activePlayer.pause()
|
|
|
|
|
if isCrossfading { standbyPlayer.pause() }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func resume() {
|
|
|
|
|
activePlayer.play()
|
|
|
|
|
if isCrossfading { standbyPlayer.play() }
|
2026-04-10 19:44:55 -07:00
|
|
|
// 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() }
|
2026-03-28 13:49:47 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func seek(to time: TimeInterval) {
|
|
|
|
|
let cmTime = CMTime(seconds: time, preferredTimescale: 1000)
|
2026-04-04 00:14:41 -07:00
|
|
|
|
|
|
|
|
// 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 {
|
2026-04-04 07:05:25 -07:00
|
|
|
self.installCrossfadeTrigger(profile: self.currentProfile)
|
2026-04-04 00:14:41 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-28 13:49:47 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-04-04 00:14:41 -07:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-10 18:23:58 -07:00
|
|
|
|
|
|
|
|
/// 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()
|
|
|
|
|
}
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
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
|
2026-04-04 07:42:23 -07:00
|
|
|
pendingNextSong = currentSong // self-crossfade: "next" == current
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
2026-04-04 07:42:23 -07:00
|
|
|
// needsNextTrack reads pendingNextSong to sync queueIndex, then clears it via
|
|
|
|
|
// prepareNextForCrossfade which sets the new pendingNextSong for the NEXT fade.
|
2026-03-28 13:49:47 -07:00
|
|
|
needsNextTrack?()
|
2026-04-04 07:42:23 -07:00
|
|
|
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
|
2026-03-28 13:49:47 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 }
|
2026-04-04 23:17:47 -07:00
|
|
|
|
|
|
|
|
// 1. Memory/disk cache
|
2026-03-28 13:49:47 -07:00
|
|
|
if let cached = profiles[song.id] { return cached }
|
2026-04-04 23:17:47 -07:00
|
|
|
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)")
|
2026-03-28 13:49:47 -07:00
|
|
|
do {
|
2026-04-04 23:17:47 -07:00
|
|
|
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))")
|
2026-03-28 13:49:47 -07:00
|
|
|
return profile
|
|
|
|
|
} catch {
|
2026-04-04 23:17:47 -07:00
|
|
|
log("On-device analysis failed: \(error.localizedDescription)")
|
2026-03-28 13:49:47 -07:00
|
|
|
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")
|
|
|
|
|
}
|