NavidromeApp/Shared/Audio/AudioPlayer.swift
2026-04-12 12:57:42 -07:00

1836 lines
74 KiB
Swift

import Foundation
import AVFoundation
import Combine
import MediaPlayer
import Accelerate
import os
#if os(iOS)
import UIKit
import WidgetKit
#endif
/// Shared audio player with real FFT visualizer for local files
class AudioPlayer: NSObject, ObservableObject {
static let shared = AudioPlayer()
// MARK: - Published State
@Published var currentSong: Song?
@Published var queue: [Song] = []
@Published var queueIndex: Int = 0
@Published var isPlaying = false
// currentTime and duration are @Published but set only from addPeriodicTimeObserver
// on the main queue safe for SwiftUI observation. NowPlayingView binds directly,
// eliminating the Timer.publish poller that caused runloop starvation.
// currentTime and duration are intentionally NOT @Published.
// @Published fires objectWillChange on AudioPlayer 10-20x/second, causing
// every @EnvironmentObject/ObservedObject subscriber (NowPlayingView,
// MiniPlayerBar, MyMusicView etc.) to re-evaluate their entire bodies at
// that rate. Progress bars use TimelineView(.periodic) instead, which reads
// these values directly on each tick without triggering objectWillChange.
// Both are written and read exclusively on the main queue no race risk.
var currentTime: TimeInterval = 0
var duration: TimeInterval = 0
@Published var volume: Float = 0.8
@Published var repeatMode: RepeatMode = .off
@Published var shuffleEnabled = false
// audioLevels is NOT @Published avoids SwiftUI state thrashing.
// The Canvas reads currentLevels() directly on every TimelineView tick.
// levelTick is kept as an internal counter but intentionally NOT @Published
// making it @Published caused 30fps objectWillChange on AudioPlayer, forcing
// every observing view (NowPlayingView, MainTabView, etc.) to re-evaluate 30x/sec.
private(set) var levelTick: Int = 0
nonisolated(unsafe) private var _audioLevels: [Float] = Array(repeating: 0, count: 30)
func currentLevels() -> [Float] { _audioLevels }
private func setLevels(_ levels: [Float]) {
_audioLevels = levels
levelTick &+= 1
}
@Published var isUsingRealFFT = false
@Published var isUsingOfflineVis = false
@Published var offlineVisProgress: Float = 0
@Published var sourcePlaylistId: String?
@Published var sourcePlaylistName: String?
enum RepeatMode {
case off, all, one
}
// MARK: - AVPlayer (for streaming)
private var player: AVPlayer?
private var playerItem: AVPlayerItem?
/// Exposed for ShazamRecognizer tap installation
var currentPlayerItem: AVPlayerItem? { playerItem }
private var timeObserver: Any?
// MARK: - AVAudioEngine (for local files with FFT)
private var audioEngine: AVAudioEngine?
private var enginePlayerNode: AVAudioPlayerNode?
private var engineTimeTimer: Timer?
private var engineFile: AVAudioFile?
private var engineStartFrame: AVAudioFramePosition = 0
private var scheduleGeneration: Int = 0
private var nowPlayingSyncTimer: Timer?
#if os(iOS)
/// When true, SmartCrossfadeManager owns playback instead of the local player/engine
private var isUsingCrossfade = false
#endif
// MARK: - FFT (radix-2, vDSP_fft_zrip)
private var fftSetup: FFTSetup?
// Written from audio render thread, read from Timer on main thread.
// No lock needed: stale FFT frame is imperceptible at 30fps.
nonisolated(unsafe) private var rawFFTLevels: [Float] = Array(repeating: 0, count: 512)
// Pre-allocated FFT scratch buffers allocated once in init, reused every render callback.
// Heap allocation from a real-time audio thread is forbidden (locks inside malloc can
// block indefinitely and cause the hardware deadline to be missed audio glitch).
private let fftSize: vDSP_Length = 1024
nonisolated(unsafe) private var fftWindow: [Float] // Hann window coefficients constant
nonisolated(unsafe) private var fftWindowed: [Float] // windowed input scratch
nonisolated(unsafe) private var fftRealp: [Float] // split-complex real part
nonisolated(unsafe) private var fftImagp: [Float] // split-complex imag part
nonisolated(unsafe) private var fftMagnitudes: [Float] // output magnitudes
// MARK: - Level Simulation (for streams)
private var levelTimer: Timer?
private var targetLevels: [Float] = Array(repeating: 0, count: 30)
private var internalLevels: [Float] = Array(repeating: 0, count: 30)
private var publishCounter: Int = 0
private var lastTargetPhase: Int = 0
// MARK: - Offline Visualizer
#if os(iOS)
private var offlineVisBuffer = VisFrameBuffer.empty
private var offlineVisTimer: Timer?
private var offlineVisFPS: Double = 30.0
private var analysisTask: Task<Void, Never>?
#endif
// MARK: - Now Playing Artwork
private var cachedArtwork: MPMediaItemArtwork?
private var cachedArtworkCoverArtId: String?
private var cancellables = Set<AnyCancellable>()
// MARK: - Init
private override init() {
// Radix-2 FFT setup for 1024 samples (log2(1024) = 10)
fftSetup = vDSP_create_fftsetup(10, Int32(kFFTRadix2))
// Pre-allocate all FFT scratch buffers and compute the constant Hann window.
// These are reused on every render callback zero heap allocation at render time.
let halfSize = Int(1024 / 2)
fftWindow = [Float](repeating: 0, count: 1024)
fftWindowed = [Float](repeating: 0, count: 1024)
fftRealp = [Float](repeating: 0, count: halfSize)
fftImagp = [Float](repeating: 0, count: halfSize)
fftMagnitudes = [Float](repeating: 0, count: halfSize)
vDSP_hann_window(&fftWindow, 1024, Int32(vDSP_HANN_NORM))
super.init()
configureAudioSession()
setupRemoteControls()
#if os(iOS)
// Refresh lock screen artwork when custom cover changes
AlbumCoverStore.shared.$updateTrigger
.sink { [weak self] _ in
guard let self, let id = self.currentSong?.coverArt else { return }
self.cachedArtworkCoverArtId = nil
self.fetchAndSetArtwork(coverArtId: id)
}
.store(in: &cancellables)
// Stop all visualizer timers when backgrounded they serve no purpose without
// a visible Canvas and burn CPU (causing XPC_EXIT_REASON_FAULT at ~169% CPU).
NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
.sink { [weak self] _ in self?.suspendVisTimers() }
.store(in: &cancellables)
// Restart the correct timer when returning to foreground
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
.sink { [weak self] _ in self?.resumeVisTimers() }
.store(in: &cancellables)
// AVAudioSession interruption handling
// Required for correct behaviour during phone calls, Siri, alarms, and
// other apps taking the audio session. Without this, playback stops but
// isPlaying stays true timers keep firing and music never auto-resumes.
NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification)
.sink { [weak self] notification in self?.handleAudioInterruption(notification) }
.store(in: &cancellables)
// Audio route change (headphones unplugged etc.)
// Apple HIG: pause on oldDeviceUnavailable (headphones pulled out).
NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification)
.sink { [weak self] notification in self?.handleRouteChange(notification) }
.store(in: &cancellables)
#endif
}
#if os(iOS)
private func handleAudioInterruption(_ notification: Notification) {
guard let info = notification.userInfo,
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
switch type {
case .began:
// System took the session (phone call, Siri, alarm).
// Mark as not playing timers/observers were already suspended
// by didEnterBackground if the app backgrounded; this handles the
// foreground case (e.g. incoming call while app is visible).
if isPlaying {
pause()
alog("Interruption began — paused playback")
}
case .ended:
// Session returned to us. Resume only if the system says we should.
if let optsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt {
let opts = AVAudioSession.InterruptionOptions(rawValue: optsValue)
if opts.contains(.shouldResume) {
// Re-activate the session before resuming it was deactivated
// by the system during the interruption.
do {
try AVAudioSession.sharedInstance().setActive(true)
resume()
alog("Interruption ended — resumed playback")
} catch {
alog("Interruption ended — session reactivation failed: \(error)")
}
} else {
alog("Interruption ended — shouldResume not set, staying paused")
}
}
@unknown default:
break
}
}
private func handleRouteChange(_ notification: Notification) {
guard let info = notification.userInfo,
let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return }
// Pause when headphones are unplugged matches system Music app behaviour.
if reason == .oldDeviceUnavailable, isPlaying {
pause()
alog("Route change: output removed — paused playback")
}
}
private func suspendVisTimers() {
stopOfflineVisTimer()
stopLevelTimer()
analysisTask?.cancel()
analysisTask = nil
AudioPreFetcher.shared.cancelAll()
removeTimeObserver()
// SmartCrossfadeManager has its own 10Hz time observer that writes
// @Published currentTime/duration same SwiftUI churn as AudioPlayer's
if isUsingCrossfade {
SmartCrossfadeManager.shared.suspendForBackground()
}
alog("Background: all timers + observers suspended")
}
private func resumeVisTimers() {
// Re-activate the audio session another app may have taken it while we
// were in the background (e.g. a game or Spotify). Without this, player.play()
// silently fails and isPlaying shows true with no audio output.
do {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
alog("Foreground: session reactivation failed: \(error)")
}
// Always reinstall time observers they were removed on background regardless
// of play state. Without this, currentTime is frozen after background+pause+play.
reinstallTimeObserver()
if isUsingCrossfade {
SmartCrossfadeManager.shared.resumeFromBackground()
}
// Only restart vis timers if actually playing
guard isPlaying else { return }
if isUsingOfflineVis {
startOfflineVisSync()
} else if !isUsingCrossfade {
startLevelSimulation()
}
alog("Foreground: session reactivated, observers + vis timers resumed (crossfade=\(isUsingCrossfade) offlineVis=\(isUsingOfflineVis))")
}
private func removeTimeObserver() {
if let observer = timeObserver {
player?.removeTimeObserver(observer)
timeObserver = nil
}
}
private func reinstallTimeObserver() {
guard player != nil, timeObserver == nil else { return }
let interval = CMTime(seconds: 0.1, preferredTimescale: 600)
timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
guard let self = self else { return }
self.currentTime = time.seconds
if let dur = self.playerItem?.duration.seconds, !dur.isNaN, dur != self.duration {
self.duration = dur
}
}
}
#endif
// MARK: - Audio Session
private func alog(_ msg: String) {
#if os(iOS)
DebugLogger.shared.log(msg, category: "Audio")
#else
print("[Audio] \(msg)")
#endif
}
private func configureAudioSession() {
#if os(iOS)
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default, options: [])
} catch {
alog("Audio session category failed: \(error)")
}
#elseif os(watchOS)
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
} catch {
alog("watchOS audio session category failed: \(error)")
}
#endif
}
private func activateAudioSession() {
do {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
alog("Audio session activation failed: \(error)")
}
}
// MARK: - Playback Controls
/// Jump to a song already in the queue by index without replacing the queue.
/// Used by QueueView tap keeps the existing queue intact.
func play(song: Song, at index: Int) {
queueIndex = index
play(song: song)
}
func play(song: Song, fromQueue: [Song]? = nil, at index: Int = 0, playlistId: String? = nil, playlistName: String? = nil) {
if let q = fromQueue {
queue = shuffleEnabled ? q.shuffled() : q
queueIndex = index
sourcePlaylistId = playlistId
sourcePlaylistName = playlistName
// Prefetch Smart DJ profiles for upcoming tracks
#if os(iOS)
if CompanionSettings.shared.smartDJEnabled {
let upcoming = Array(queue.dropFirst(index).prefix(10))
Task { await CompanionAPIService.shared.prefetchProfiles(for: upcoming) }
}
#endif
}
currentSong = song
// Persist queue state so it survives app termination (AUDIT-055).
// Position is saved as 0 here; the time observer updates it every 0.1s.
#if os(iOS)
PlaybackStateStore.shared.save(
queue: queue,
index: queueIndex,
currentTime: 0,
currentSongId: song.id
)
#endif
alog("Playing: \(song.title) by \(song.artist ?? "Unknown")")
// Stop radio buffer if switching from radio to music
if isRadioStream {
isRadioStream = false
radioStreamURL = nil
#if os(iOS)
RadioStreamBuffer.shared.stopBuffering()
isPlayingFromBuffer = false
#endif
}
#if os(iOS)
// Smart DJ Crossfade path dual-AVPlayer engine handles everything
let crossfade = SmartCrossfadeManager.shared
if crossfade.isEnabled && CompanionSettings.shared.smartDJEnabled && !isRadioStream {
if let url = crossfade.resolveURL(for: song) {
stopAll()
isUsingCrossfade = true
// Wire callbacks
crossfade.timeUpdate = { [weak self] time, dur in
self?.currentTime = time
self?.duration = dur
}
crossfade.needsNextTrack = { [weak self] in
self?.prepareNextForCrossfade()
}
// At crossfade midpoint, start loading the incoming song's vis frames
// so they're ready (or nearly ready) by the time finalizeCrossfade fires.
crossfade.visualizerHandoff = { [weak self] _ in
guard let self = self,
VisualizerSettings.shared.realAudioAnalysis,
let nextSong = SmartCrossfadeManager.shared.pendingNextSong,
let url = SmartCrossfadeManager.shared.resolveURL(for: nextSong)
else { return }
self.loadOfflineVisualizer(songId: nextSong.id, url: url)
}
crossfade.playSong(song, url: url)
isPlaying = true
// Set AudioPlayer.player to the active crossfade player
// so Lock Screen controls and visualizer still work
player = crossfade.activePlayer
// Safety: if boundary observer doesn't fire (short track, bad profile),
// this catches the natural end and advances normally
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
NotificationCenter.default.addObserver(
self, selector: #selector(playerDidFinish),
name: .AVPlayerItemDidPlayToEndTime, object: crossfade.activePlayer.currentItem
)
updateNowPlayingInfo()
fetchAndSetArtwork(coverArtId: song.coverArt)
startLevelSimulation()
// Load offline visualizer for crossfade path too
if VisualizerSettings.shared.realAudioAnalysis {
loadOfflineVisualizer(songId: song.id, url: url)
}
// Prepare next track in standby
prepareNextForCrossfade()
return
}
}
isUsingCrossfade = false
#endif
// Check for offline version first use Song overload which falls back to
// title/artist/duration matching if the ID changed after a Companion restructure
if let localURL = OfflineManager.shared.localURL(for: song) {
#if os(iOS)
if VisualizerSettings.shared.realAudioAnalysis {
// Use AVPlayer for reliable playback + offline vis for the visualizer
playWithAVPlayer(localURL)
loadOfflineVisualizer(songId: song.id, url: localURL)
} else {
playWithAVPlayer(localURL)
}
#else
playWithAVPlayer(localURL)
#endif
} else {
#if os(iOS)
// Check prefetch cache already downloaded in background
if let prefetchURL = AudioPreFetcher.shared.prefetchedURL(for: song.id, suffix: song.suffix) {
alog("Playing from prefetch cache")
playWithAVPlayer(prefetchURL)
if VisualizerSettings.shared.realAudioAnalysis {
loadOfflineVisualizer(songId: song.id, url: prefetchURL)
}
} else {
let quality = StreamingQuality.shared
let streamURL = ServerManager.shared.client.streamURL(
songId: song.id,
format: quality.format,
maxBitRate: quality.maxBitRate
)
if let url = streamURL {
playWithAVPlayer(url)
}
}
#else
let streamURL = ServerManager.shared.client.streamURL(
songId: song.id,
format: "mp3",
maxBitRate: 192
)
if let url = streamURL {
playWithAVPlayer(url)
}
#endif
}
// Scrobble
Task {
try? await ServerManager.shared.client.scrobble(id: song.id, submission: false)
}
// Pre-fetch next tracks for instant playback
#if os(iOS)
AudioPreFetcher.shared.prefetchUpcoming(queue: queue, currentIndex: queueIndex)
pushWidgetState()
#endif
}
/// Play a radio stream from a direct URL.
/// Automatically starts the stream buffer so timeshift is available immediately.
/// Destroys any existing buffer first to prevent audio bleed between stations.
func playRadio(song: Song, streamURL: URL) {
alog("Radio: \(song.title)\(streamURL.absoluteString)")
currentSong = song
queue = [song]
queueIndex = 0
isRadioStream = true
radioStreamURL = streamURL
#if os(iOS)
// Always destroy the previous buffer cleanly before starting a new station.
// Prevents stale bytes / state from the old station bleeding into the new one.
RadioStreamBuffer.shared.stopBuffering()
if RadioStreamBuffer.isPlaylistURL(streamURL) {
alog("Radio: resolving playlist URL...")
Task {
let resolved = await RadioStreamBuffer.resolveStreamURL(streamURL)
await MainActor.run {
self.radioStreamURL = resolved
self.playWithAVPlayer(resolved)
// Auto-start buffer immediately after resolution (non-HLS only)
// isHLSStream is set inside startBuffering if the URL is M3U8
RadioStreamBuffer.shared.startBuffering(
url: resolved,
stationName: song.title
)
}
}
} else {
playWithAVPlayer(streamURL)
// Auto-start buffer will mark isHLSStream=true and bail if it's HLS
RadioStreamBuffer.shared.startBuffering(url: streamURL, stationName: song.title)
}
#else
playWithAVPlayer(streamURL)
#endif
}
/// Whether current playback is a radio stream (for buffer/scrub behavior)
@Published var isRadioStream = false
private(set) var radioStreamURL: URL?
#if os(iOS)
/// Toggle recording the current radio stream on/off.
/// No-op for HLS streams or when not buffering.
func toggleRecording() {
let buf = RadioStreamBuffer.shared
if buf.isRecording {
_ = buf.stopRecording()
} else {
buf.startRecording()
}
}
#endif
#if os(iOS)
/// When true, AVPlayer is playing a snapshot file rather than the live stream
@Published private(set) var isPlayingFromBuffer = false
/// Current playback position within the buffer timeline (0 = start of buffer, bufferSec = live edge)
@Published var radioBufferPlayheadSec: TimeInterval = 0
private var currentSnapshotURL: URL?
private var snapshotStatusObservation: NSKeyValueObservation?
private var snapshotEndObserver: Any?
func radioSeekBack(to positionInBuffer: TimeInterval) {
guard isRadioStream else { return }
let buffer = RadioStreamBuffer.shared
guard let srcURL = buffer.bufferPlaybackURL else {
alog("Radio seek: no buffer file yet")
return
}
buffer.isLive = false
let seekTime = max(0.1, positionInBuffer)
let snapshotBufSec = buffer.estimatedBufferSeconds
// CRITICAL: use the correct audio extension (not .raw) so AVPlayer detects the format.
// .raw causes AVPlayer to fail format detection status never becomes readyToPlay.
let ext = buffer.guessExtension()
let snapURL = FileManager.default.temporaryDirectory
.appendingPathComponent("radio_snap_\(UUID().uuidString).\(ext)")
// Clean up any previous snapshot resources
snapshotStatusObservation?.invalidate()
snapshotStatusObservation = nil
if let obs = snapshotEndObserver {
NotificationCenter.default.removeObserver(obs)
snapshotEndObserver = nil
}
if let old = currentSnapshotURL {
try? FileManager.default.removeItem(at: old)
currentSnapshotURL = nil
}
do {
try FileManager.default.copyItem(at: srcURL, to: snapURL)
} catch {
alog("Radio seek: snapshot copy failed — \(error.localizedDescription)")
return
}
currentSnapshotURL = snapURL
isPlayingFromBuffer = true
radioBufferPlayheadSec = seekTime
let asset = AVURLAsset(url: snapURL)
let item = AVPlayerItem(asset: asset)
// Auto-return to live when snapshot playback reaches the end
snapshotEndObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main
) { [weak self] _ in
self?.alog("Radio: snapshot reached end — returning to live")
self?.radioGoLive()
}
player?.replaceCurrentItem(with: item)
// KVO on status more reliable than polling; fires immediately on failure too
snapshotStatusObservation = item.observe(\.status, options: [.new, .initial]) { [weak self] observedItem, _ in
guard let self else { return }
switch observedItem.status {
case .readyToPlay:
self.snapshotStatusObservation?.invalidate()
self.snapshotStatusObservation = nil
// Generous tolerance for raw audio without index tables
let cmTime = CMTime(seconds: seekTime, preferredTimescale: 600)
let tol = CMTime(seconds: 1, preferredTimescale: 600)
self.player?.seek(to: cmTime, toleranceBefore: tol, toleranceAfter: tol) { [weak self] _ in
self?.player?.play()
self?.alog("Radio seek: ▶ playing from \(String(format: "%.1f", seekTime))s — \(String(format: "%.0f", snapshotBufSec - seekTime))s behind live")
}
case .failed:
self.snapshotStatusObservation?.invalidate()
self.snapshotStatusObservation = nil
self.alog("Radio seek FAILED: \(observedItem.error?.localizedDescription ?? "unknown error")")
try? FileManager.default.removeItem(at: snapURL)
self.currentSnapshotURL = nil
self.radioGoLive() // fall back to live
default:
break
}
}
}
/// Return to the live radio stream, discarding any snapshot
func radioGoLive() {
guard isRadioStream, let liveURL = radioStreamURL else { return }
RadioStreamBuffer.shared.isLive = true
isPlayingFromBuffer = false
radioBufferPlayheadSec = 0
snapshotStatusObservation?.invalidate()
snapshotStatusObservation = nil
if let obs = snapshotEndObserver {
NotificationCenter.default.removeObserver(obs)
snapshotEndObserver = nil
}
if let old = currentSnapshotURL {
try? FileManager.default.removeItem(at: old)
currentSnapshotURL = nil
}
let asset = AVURLAsset(url: liveURL)
let item = AVPlayerItem(asset: asset)
player?.replaceCurrentItem(with: item)
player?.play()
alog("Radio: ▶ live")
}
/// Skip ±seconds within the buffer. Negative = rewind. Snaps to live at edge.
func radioSkip(by seconds: TimeInterval) {
guard isRadioStream else { return }
let buffer = RadioStreamBuffer.shared
let bufferSec = buffer.estimatedBufferSeconds
guard bufferSec > 1 else {
alog("Radio skip: not enough buffer (\(String(format: "%.1f", bufferSec))s)")
return
}
// Current position in buffer timeline
let currentPos: TimeInterval
if isPlayingFromBuffer {
let t = player?.currentTime()
currentPos = (t?.isNumeric == true) ? t!.seconds : radioBufferPlayheadSec
} else {
currentPos = bufferSec // at live edge
}
let newPos = currentPos + seconds
alog("Radio skip \(seconds > 0 ? "+" : "")\(Int(seconds))s: \(String(format: "%.1f", currentPos))s → \(String(format: "%.1f", newPos))s (buf \(String(format: "%.1f", bufferSec))s)")
if newPos >= bufferSec - 0.5 {
radioGoLive()
} else {
radioSeekBack(to: max(0.1, newPos))
}
}
#endif
#if os(iOS)
/// Set now playing artwork from a UIImage (for radio station custom covers)
func setNowPlayingArtwork(image: UIImage) {
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
cachedArtwork = artwork
cachedArtworkCoverArtId = currentSong?.id
updateNowPlayingInfo()
}
#endif
// MARK: - AVPlayer Path (streams + fallback)
private func playWithAVPlayer(_ url: URL) {
#if os(iOS)
let isStream = url.scheme == "http" || url.scheme == "https"
let display = isStream
? "\(url.scheme ?? "")://\(url.host ?? "")...\(url.lastPathComponent)"
: url.lastPathComponent
DebugLogger.shared.log("\(display)", category: "Audio")
#endif
alog("AVPlayer path: \(url.scheme ?? "file")://...\(url.lastPathComponent)")
stopAll()
// Reset audio session to pure playback (fixes radiomusic conflict)
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
try AVAudioSession.sharedInstance().setActive(true)
} catch {
alog("Audio session reset failed: \(error)")
}
isUsingRealFFT = false
let asset = AVURLAsset(url: url)
playerItem = AVPlayerItem(asset: asset)
// Observe item status catches stream 404s, auth failures, and codec
// errors that AVPlayer swallows silently otherwise
#if os(iOS)
let itemToObserve = playerItem!
Task { @MainActor [weak self] in
for await status in itemToObserve.publisher(for: \.status).values {
guard let self = self, self.playerItem === itemToObserve else { break }
switch status {
case .failed:
let err = itemToObserve.error?.localizedDescription ?? "unknown"
let urlStr = url.absoluteString.contains("stream") ? url.absoluteString : url.lastPathComponent
DebugLogger.shared.log("✗ Playback failed: \(err) | \(urlStr)",
category: "Audio", level: .error)
case .readyToPlay:
DebugLogger.shared.log("✓ Ready: \(url.lastPathComponent)", category: "Audio")
default:
break
}
if status == .failed { break }
}
}
#endif
if player == nil {
player = AVPlayer(playerItem: playerItem)
} else {
player?.replaceCurrentItem(with: playerItem)
}
player?.volume = volume
player?.play()
isPlaying = true
// Time observer
let interval = CMTime(seconds: 0.1, preferredTimescale: 600)
timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
guard let self = self else { return }
self.currentTime = time.seconds
// Guard duration write: @Published fires objectWillChange on every
// assignment even if the value is identical. For a 4-min song this
// would fire an extra objectWillChange 10x/sec for the entire track.
if let dur = self.playerItem?.duration.seconds, !dur.isNaN, dur != self.duration {
self.duration = dur
}
// Throttle position saves to once per 5 seconds.
// Only writes a single Double queue shape is saved separately
// in play(song:) and notifyQueueChanged().
if Int(time.seconds * 10) % 50 == 0 {
PlaybackStateStore.shared.savePosition(time.seconds)
#if os(iOS)
WidgetBridge.shared.updatePosition(currentTime: time.seconds, isPlaying: self.isPlaying)
#endif
}
}
// Periodic Now Playing info sync (keeps Dynamic Island/Lock Screen in sync)
nowPlayingSyncTimer?.invalidate()
nowPlayingSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
guard let self = self, self.isPlaying else { return }
self.updateNowPlayingInfo()
}
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(playerDidFinish), name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
updateNowPlayingInfo()
fetchAndSetArtwork(coverArtId: currentSong?.coverArt)
// Radio streams use a smooth sinusoidal simulation random phase
// simulation reacts to currentTime jumps (buffer seeks) causing
// the peakFollower to spike and "raise" the wave unexpectedly.
#if os(iOS)
if isRadioStream {
startRadioSimulation()
} else {
startLevelSimulation()
}
#else
startLevelSimulation()
#endif
}
// MARK: - AVAudioEngine Path (local files with real FFT)
private func playLocalWithEngine(_ url: URL) {
#if os(iOS)
alog("Engine path: \(url.lastPathComponent)")
activateAudioSession()
stopAll()
isUsingRealFFT = true
do {
let file = try AVAudioFile(forReading: url)
engineFile = file
let engine = AVAudioEngine()
let playerNode = AVAudioPlayerNode()
engine.attach(playerNode)
engine.connect(playerNode, to: engine.mainMixerNode, format: file.processingFormat)
// Install FFT tap on the mixer output
let format = engine.mainMixerNode.outputFormat(forBus: 0)
engine.mainMixerNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
self?.processFFT(buffer: buffer)
}
try engine.start()
// Schedule file and play
scheduleGeneration += 1
let gen = scheduleGeneration
playerNode.scheduleFile(file, at: nil) { [weak self] in
DispatchQueue.main.async {
guard let self = self, self.scheduleGeneration == gen else { return }
self.playerDidFinishEngine()
}
}
playerNode.play()
self.audioEngine = engine
self.enginePlayerNode = playerNode
self.engineStartFrame = 0
isPlaying = true
// Duration from file
duration = Double(file.length) / file.processingFormat.sampleRate
// Time tracking for engine (accounts for seek offset)
engineTimeTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
guard let self = self, let node = self.enginePlayerNode,
let file = self.engineFile,
let nodeTime = node.lastRenderTime,
let playerTime = node.playerTime(forNodeTime: nodeTime) else { return }
let sampleRate = file.processingFormat.sampleRate
let offsetSeconds = Double(self.engineStartFrame) / sampleRate
self.currentTime = offsetSeconds + Double(playerTime.sampleTime) / playerTime.sampleRate
}
// Publish FFT levels at ~30fps (smoothing already applied in processFFT)
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
let levels = self.rawFFTLevels
self.setLevels(levels)
}
updateNowPlayingInfo()
fetchAndSetArtwork(coverArtId: currentSong?.coverArt)
} catch {
alog("Engine failed: \(error.localizedDescription), falling back to AVPlayer")
playWithAVPlayer(url)
}
#endif
}
// MARK: - FFT Processing
#if os(iOS)
private func processFFT(buffer: AVAudioPCMBuffer) {
guard let fftSetup = fftSetup else { return }
guard let channelData = buffer.floatChannelData?[0] else { return }
let frameCount = buffer.frameLength
let log2n: vDSP_Length = 10
guard frameCount >= fftSize else { return }
let halfSize = Int(fftSize / 2)
// 1. Apply pre-computed Hann window no allocation, mutates pre-allocated scratch buffer
vDSP_vmul(channelData, 1, fftWindow, 1, &fftWindowed, 1, fftSize)
// 2. FFT using pre-allocated split-complex buffers
fftRealp.withUnsafeMutableBufferPointer { realpBuf in
fftImagp.withUnsafeMutableBufferPointer { imagpBuf in
var splitComplex = DSPSplitComplex(
realp: realpBuf.baseAddress!,
imagp: imagpBuf.baseAddress!
)
fftWindowed.withUnsafeBytes { rawBuffer in
let complexPtr = rawBuffer.bindMemory(to: DSPComplex.self).baseAddress!
vDSP_ctoz(complexPtr, 2, &splitComplex, 1, vDSP_Length(halfSize))
}
vDSP_fft_zrip(fftSetup, &splitComplex, 1, log2n, FFTDirection(FFT_FORWARD))
vDSP_zvmags(&splitComplex, 1, &fftMagnitudes, 1, vDSP_Length(halfSize))
}
}
// 3. Normalize by N² and take sqrt for amplitude in-place on pre-allocated buffer
let fftSizeF = Float(fftSize)
var scale: Float = 1.0 / (fftSizeF * fftSizeF)
vDSP_vsmul(fftMagnitudes, 1, &scale, &fftMagnitudes, 1, vDSP_Length(halfSize))
for i in 0..<halfSize {
fftMagnitudes[i] = sqrt(fftMagnitudes[i])
}
// 4. Push to rawFFTLevels single array copy, no intermediate allocation
rawFFTLevels = fftMagnitudes
}
#endif
// MARK: - Engine Finish
private func playerDidFinishEngine() {
if repeatMode == .one, let file = engineFile, let node = enginePlayerNode {
node.stop()
node.scheduleFile(file, at: nil) { [weak self] in
DispatchQueue.main.async { self?.playerDidFinishEngine() }
}
node.play()
} else {
next()
}
}
// MARK: - Transport
func togglePlayPause() {
if isPlaying {
pause()
} else {
resume()
}
}
func pause() {
#if os(iOS)
if isUsingCrossfade {
SmartCrossfadeManager.shared.pause()
isPlaying = false
stopOfflineVisTimer()
stopLevelTimer()
updateNowPlayingInfo()
pushWidgetState()
return
}
#endif
player?.pause()
enginePlayerNode?.pause()
isPlaying = false
#if os(iOS)
stopOfflineVisTimer()
// Don't stop levelTimer the simulation timer's !isPlaying branch decays
// internalLevels to zero and calls setLevels(zeros), which increments levelTick
// so the Canvas keeps re-rendering and the wave visually decays during pause.
// For offline vis path the offlineVisTimer (restarted above) handles this.
internalLevels = Array(repeating: 0, count: 30)
pushWidgetState()
#endif
updateNowPlayingInfo()
}
func resume() {
#if os(iOS)
if isUsingCrossfade {
SmartCrossfadeManager.shared.resume()
isPlaying = true
// offlineVisTimer was stopped on pause restart it so currentLevels() advances
if isUsingOfflineVis { startOfflineVisSync() }
updateNowPlayingInfo()
pushWidgetState()
return
}
#endif
if isUsingRealFFT, !isUsingOfflineVis, let node = enginePlayerNode {
node.play()
} else {
player?.play()
}
isPlaying = true
#if os(iOS)
// Prime levels immediately before any timer fires.
// The CADisplayLink draws at 60fps; the level timers fire at 30fps.
// Without this, the first 1-2 Canvas frames read stale zeroed _audioLevels
// (zeroed by pause) and the wave starts flat then slowly ramps up.
if isUsingOfflineVis {
DebugLogger.shared.log("resume: offlineVis path — startOfflineVisSync", category: "VisDebug")
// Offline vis: prime with the last-rendered frame at current position
if !offlineVisBuffer.isEmpty {
let dur = duration
let pct = dur > 0 ? min(currentTime / dur, 1.0) : 0
let idx = min(Int(pct * Double(offlineVisBuffer.frameCount)), offlineVisBuffer.frameCount - 1)
offlineVisBuffer.copyFrame(at: idx, into: &_audioLevels)
levelTick &+= 1
}
startOfflineVisSync()
} else if isUsingRealFFT, !isUsingOfflineVis {
DebugLogger.shared.log("resume: realFFT path — rebuilding levelTimer", category: "VisDebug")
// Engine FFT path: rawFFTLevels still holds pre-pause data push it immediately
setLevels(rawFFTLevels)
stopLevelTimer()
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
self.setLevels(self.rawFFTLevels)
}
} else {
DebugLogger.shared.log("resume: simulation path — levelTimer nil=\(levelTimer == nil)", category: "VisDebug")
// Simulation path: seed internalLevels from a fresh random target
// so the wave has height immediately rather than starting from floor.
lastTargetPhase = -1
targetLevels = (0..<30).map { _ in Float.random(in: 0.4...0.85) }
internalLevels = targetLevels.map { $0 * 0.8 }
setLevels(internalLevels)
if levelTimer == nil { startLevelSimulation() }
}
pushWidgetState()
#endif
updateNowPlayingInfo()
}
func next() {
guard !queue.isEmpty else { return }
if repeatMode == .all {
queueIndex = (queueIndex + 1) % queue.count
} else {
queueIndex = min(queueIndex + 1, queue.count - 1)
}
play(song: queue[queueIndex])
}
func previous() {
if currentTime > 3 {
seek(to: 0)
} else {
queueIndex = max(queueIndex - 1, 0)
play(song: queue[queueIndex])
}
}
#if os(iOS)
/// Loads the next queue track into the crossfade standby player.
/// If no next track exists, SmartCrossfadeManager will self-crossfade.
private func prepareNextForCrossfade() {
let crossfade = SmartCrossfadeManager.shared
guard crossfade.isEnabled, !queue.isEmpty else { return }
// Wire the callback called by finalizeCrossfade after every successful crossfade
crossfade.needsNextTrack = { [weak self] in
guard let self = self else { return }
// Scrobble the song that just finished.
// playerDidFinish only fires on natural item end crossfade replaces the item
// before that happens, so we scrobble here instead.
if let finishedSong = self.currentSong {
Task { try? await ServerManager.shared.client.scrobble(id: finishedSong.id) }
}
// Sync queueIndex to whatever was actually in standby (pendingNextSong),
// not just queueIndex+1. The queue may have changed between prepareNext()
// and now this guarantees currentSong matches what the active player is playing.
if let nowPlaying = crossfade.pendingNextSong,
let idx = self.queue.firstIndex(where: { $0.id == nowPlaying.id }) {
self.queueIndex = idx
self.currentSong = nowPlaying
} else {
// Fallback: queue was cleared or song removed advance sequentially
let hasNext = self.repeatMode == .all || self.queueIndex + 1 < self.queue.count
if hasNext {
if self.repeatMode == .all {
self.queueIndex = (self.queueIndex + 1) % self.queue.count
} else {
self.queueIndex = min(self.queueIndex + 1, self.queue.count - 1)
}
self.currentSong = self.queue[self.queueIndex]
}
}
self.player = crossfade.activePlayer
// Re-register the safety-net AVPlayerItemDidPlayToEndTime observer on the
// new active item. play() registered it on the first item only subsequent
// crossfades swap the active slot so the original observer is now stale.
// Without this, a boundary-observer failure on track 2+ has no fallback.
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
if let newItem = crossfade.activePlayer.currentItem {
NotificationCenter.default.addObserver(
self, selector: #selector(playerDidFinish),
name: .AVPlayerItemDidPlayToEndTime, object: newItem
)
}
self.updateNowPlayingInfo()
self.fetchAndSetArtwork(coverArtId: self.currentSong?.coverArt)
// Prepare next-next (picks up any queue changes that happened mid-fade)
self.prepareNextForCrossfade()
}
// Determine what to pre-load in standby
let nextIdx: Int
if repeatMode == .all {
nextIdx = (queueIndex + 1) % queue.count
} else {
nextIdx = queueIndex + 1
guard nextIdx < queue.count else {
// No next track SmartCrossfadeManager will self-crossfade
alog("SmartDJ: no next track — will self-crossfade")
return
}
}
let nextSong = queue[nextIdx]
guard let url = crossfade.resolveURL(for: nextSong) else { return }
alog("SmartDJ: preparing \(nextSong.title) in standby")
crossfade.prepareNext(nextSong, url: url)
}
#endif
func seek(to time: TimeInterval) {
#if os(iOS)
if isUsingCrossfade {
SmartCrossfadeManager.shared.seek(to: time)
currentTime = time
updateNowPlayingInfo()
return
}
#endif
let wasPaused = !isPlaying
if let node = enginePlayerNode, let file = engineFile, let engine = audioEngine {
let sampleRate = file.processingFormat.sampleRate
let startFrame = AVAudioFramePosition(time * sampleRate)
let totalFrames = file.length
guard startFrame >= 0, startFrame < totalFrames else { return }
let remainingFrames = AVAudioFrameCount(totalFrames - startFrame)
scheduleGeneration += 1
let gen = scheduleGeneration
node.stop()
node.scheduleSegment(file, startingFrame: startFrame, frameCount: remainingFrames, at: nil) { [weak self] in
DispatchQueue.main.async {
guard let self = self, self.scheduleGeneration == gen else { return }
self.playerDidFinishEngine()
}
}
if engine.isRunning {
node.play()
// If we were paused, immediately pause again (we only played to set position)
if wasPaused {
node.pause()
}
}
currentTime = time
engineStartFrame = startFrame
updateNowPlayingInfo()
} else {
currentTime = time // Update immediately for responsive UI
let cmTime = CMTime(seconds: time, preferredTimescale: 600)
player?.seek(to: cmTime) { [weak self] finished in
guard let self = self, finished else { return }
if wasPaused {
self.player?.pause()
}
self.updateNowPlayingInfo()
}
}
}
func seekToPercent(_ pct: Double) {
seek(to: duration * pct)
}
func toggleShuffle() {
shuffleEnabled.toggle()
if !queue.isEmpty {
if shuffleEnabled {
// Save current song, shuffle the rest
let current = queue[queueIndex]
var rest = queue
rest.remove(at: queueIndex)
rest.shuffle()
queue = [current] + rest
queueIndex = 0
} else {
// Unshuffle restore original order would need storing it.
// For now, keep current order but ensure current song stays in place.
}
}
}
func cycleRepeat() {
switch repeatMode {
case .off: repeatMode = .all
case .all: repeatMode = .one
case .one: repeatMode = .off
}
}
// MARK: - Queue Management
func playNext(_ song: Song) {
let insertAt = queueIndex + 1
if insertAt <= queue.count {
queue.insert(song, at: insertAt)
} else {
queue.append(song)
}
notifyQueueChanged()
}
func playLater(_ song: Song) {
queue.append(song)
notifyQueueChanged()
}
func playNow(_ song: Song) {
queue.insert(song, at: queueIndex + 1)
queueIndex += 1
play(song: song)
}
/// Move a song in the queue (for drag reorder)
func moveQueueItem(from source: IndexSet, to destination: Int) {
let oldIndex = queueIndex
let currentId = queue.indices.contains(oldIndex) ? queue[oldIndex].id : nil
queue.move(fromOffsets: source, toOffset: destination)
if let id = currentId, let newIdx = queue.firstIndex(where: { $0.id == id }) {
queueIndex = newIdx
}
notifyQueueChanged()
}
/// Remove a song from the queue
func removeFromQueue(at offsets: IndexSet) {
let currentId = queue.indices.contains(queueIndex) ? queue[queueIndex].id : nil
queue.remove(atOffsets: offsets)
if let id = currentId, let newIdx = queue.firstIndex(where: { $0.id == id }) {
queueIndex = newIdx
} else {
queueIndex = min(queueIndex, max(queue.count - 1, 0))
}
notifyQueueChanged()
}
/// Re-prepare crossfade when queue is modified (Play Next / Later / reorder).
private func notifyQueueChanged() {
#if os(iOS)
// Persist queue shape immediately so Play Next / Later / reorder / remove survives termination
PlaybackStateStore.shared.save(
queue: queue,
index: queueIndex,
currentTime: currentTime,
currentSongId: currentSong?.id
)
let crossfade = SmartCrossfadeManager.shared
guard crossfade.isEnabled, !queue.isEmpty else { return }
if crossfade.isCrossfading {
// Don't replace the standby item mid-fade mark stale so finalizeCrossfade
// calls prepareNextForCrossfade() after the swap completes.
crossfade.queueChangedDuringFade = true
} else {
prepareNextForCrossfade()
}
AudioPreFetcher.shared.prefetchUpcoming(queue: queue, currentIndex: queueIndex)
#endif
}
func playInstantMix(basedOn song: Song) {
Task {
let similar = try? await ServerManager.shared.client.getSimilarSongs2(id: song.id, count: 50)
if let songs = similar, !songs.isEmpty {
await MainActor.run {
self.play(song: songs[0], fromQueue: songs)
}
}
}
}
@objc private func playerDidFinish() {
// When crossfade is active, needsNextTrack() already scrobbled the outgoing song.
// playerDidFinish fires as a safety-net fallback (boundary observer miss) skip
// scrobble here to avoid double-counting.
#if os(iOS)
if !isUsingCrossfade, let song = currentSong {
Task { try? await ServerManager.shared.client.scrobble(id: song.id) }
}
#else
if let song = currentSong {
Task { try? await ServerManager.shared.client.scrobble(id: song.id) }
}
#endif
if repeatMode == .one {
seek(to: 0)
player?.play()
} else {
next()
}
}
// MARK: - Now Playing Info Center
private func updateNowPlayingInfo() {
#if os(iOS)
var info: [String: Any] = [:]
info[MPMediaItemPropertyTitle] = currentSong?.title ?? "Unknown"
info[MPMediaItemPropertyArtist] = currentSong?.artist ?? "Unknown"
info[MPMediaItemPropertyAlbumTitle] = currentSong?.album ?? ""
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
info[MPMediaItemPropertyPlaybackDuration] = duration
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
if let artwork = cachedArtwork {
info[MPMediaItemPropertyArtwork] = artwork
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
#endif
}
private func fetchAndSetArtwork(coverArtId: String?) {
#if os(iOS)
guard let id = coverArtId else { return }
if id == cachedArtworkCoverArtId, cachedArtwork != nil { return }
// Check user-set custom cover first
if let customImage = AlbumCoverStore.shared.loadCover(for: id) {
let artwork = MPMediaItemArtwork(boundsSize: customImage.size) { _ in customImage }
self.cachedArtwork = artwork
self.cachedArtworkCoverArtId = id
self.updateNowPlayingInfo()
self.pushWidgetState()
return
}
guard let url = ServerManager.shared.client.coverArtURL(id: id, size: 600) else { return }
if let cached = ImageCache.shared.cachedImage(for: url) {
let artwork = MPMediaItemArtwork(boundsSize: cached.size) { _ in cached }
self.cachedArtwork = artwork
self.cachedArtworkCoverArtId = id
self.updateNowPlayingInfo()
self.pushWidgetState()
return
}
Task {
do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else { return }
ImageCache.shared.store(image, for: url)
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
await MainActor.run {
self.cachedArtwork = artwork
self.cachedArtworkCoverArtId = id
self.updateNowPlayingInfo()
self.pushWidgetState()
}
} catch {
// Log the failure so it's visible in the debug console.
// Common cause: Tailscale reconnecting after network switch.
alog("Artwork fetch failed for \(id): \(error.localizedDescription)")
// Clear the cached ID so the next song play retries this artwork.
await MainActor.run { self.cachedArtworkCoverArtId = nil }
}
}
#endif
}
private func setupRemoteControls() {
#if os(iOS) || os(watchOS)
let center = MPRemoteCommandCenter.shared()
center.playCommand.addTarget { [weak self] _ in self?.resume(); return .success }
center.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success }
center.togglePlayPauseCommand.addTarget { [weak self] _ in self?.togglePlayPause(); return .success }
center.nextTrackCommand.addTarget { [weak self] _ in self?.next(); return .success }
center.previousTrackCommand.addTarget { [weak self] _ in
self?.previous()
return .success
}
center.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let posEvent = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
self?.seek(to: posEvent.positionTime)
return .success
}
#endif
}
// MARK: - Offline Visualizer Pipeline
#if os(iOS)
/// Load or generate cached visualizer data for a downloaded song
private func loadOfflineVisualizer(songId: String, url: URL) {
stopOfflineVisTimer()
isUsingOfflineVis = false
offlineVisProgress = 0
offlineVisFPS = VisualizerSettings.shared.effectiveFPS
let points = VisualizerSettings.shared.nowPlaying.numberOfPoints
let cutoff = VisualizerSettings.shared.frequencyCutoff
analysisTask?.cancel()
// Don't start analysis if already in background it will be triggered
// when the app returns to foreground instead (via resumeVisTimers path
// or next song load in foreground).
#if os(iOS)
guard UIApplication.shared.applicationState != .background else {
alog("Offline vis: skipping analysis in background for \(songId)")
return
}
#endif
analysisTask = Task {
let storage = VisualizerStorageManager.shared
let hasCache = await storage.hasCache(for: songId)
if hasCache {
alog("Offline vis: loading cache for \(songId)")
if let buffer = try? await storage.loadCache(for: songId) {
let normalized = Self.normalizeVisBuffer(buffer)
await MainActor.run {
self.offlineVisBuffer = normalized
self.isUsingOfflineVis = true
self.isUsingRealFFT = true
self.offlineVisProgress = 1.0
self.startOfflineVisSync()
}
}
} else {
// Try Companion API first (pre-computed server frames)
var fetched = false
if CompanionSettings.shared.isEnabled, let path = self.currentSong?.path {
do {
if let serverFrames = try await CompanionAPIService.shared.fetchVisualizerFrames(relativePath: path) {
try? await storage.saveCache(frames: serverFrames, for: songId)
// Convert [[Float]] to flat buffer then normalize
let ppf = serverFrames.first?.count ?? 0
var flat = ContiguousArray<Float>()
flat.reserveCapacity(serverFrames.count * ppf)
for frame in serverFrames { flat.append(contentsOf: frame) }
let rawBuf = VisFrameBuffer(data: flat, frameCount: serverFrames.count, pointsPerFrame: ppf)
let normalized = Self.normalizeVisBuffer(rawBuf)
alog("Offline vis: fetched \(serverFrames.count) frames from server")
await MainActor.run {
self.offlineVisBuffer = normalized
self.isUsingOfflineVis = true
self.isUsingRealFFT = true
self.offlineVisProgress = 1.0
self.startOfflineVisSync()
}
fetched = true
}
} catch {
alog("Offline vis: server fetch failed, will analyze locally")
}
}
if !fetched {
alog("Offline vis: analyzing \(songId) (combined vis+SmartDJ pass)...")
let song = self.currentSong
do {
// Combined pass: vis frames + SmartDJ profile in one read
// extractSmartDJ only if Companion is disabled or unavailable AND
// SmartDJ crossfade is enabled so we actually need the profile.
let needsSmartDJ = CompanionSettings.shared.smartDJEnabled
&& !CompanionSettings.shared.isEnabled
&& song?.path != nil
&& SmartDJCache.shared.get(song?.path ?? "") == nil
let result = try await OfflineAudioAnalyzer.shared.analyzeWithSmartDJ(
url: url,
pointsCount: points,
fps: offlineVisFPS,
cutoff: cutoff,
extractSmartDJ: needsSmartDJ
) { [weak self] pct in
Task { @MainActor in self?.offlineVisProgress = pct }
}
// Seed SmartDJ cache from on-device analysis if we extracted it
if needsSmartDJ, let path = song?.path {
let profile = SmartDJProfile(
bpm: nil,
silenceStart: result.silenceStart,
silenceEnd: result.silenceEnd,
loudnessLUFS: result.loudnessLUFS
)
SmartDJCache.shared.store(profile, for: path)
alog("Offline vis: seeded on-device SmartDJ profile for \(path) " +
"(LUFS=\(String(format: "%.1f", result.loudnessLUFS ?? 0)), " +
"leading=\(String(format: "%.2f", result.silenceEnd ?? 0))s, " +
"trailing=\(String(format: "%.2f", result.silenceStart ?? 0))s)")
}
let frames = result.visFrames
try? await storage.saveCache(frames: frames, for: songId)
// Convert [[Float]] to flat VisFrameBuffer then normalize
let ppf = frames.first?.count ?? 0
var flat = ContiguousArray<Float>()
flat.reserveCapacity(frames.count * ppf)
for frame in frames { flat.append(contentsOf: frame) }
let rawBuf = VisFrameBuffer(data: flat, frameCount: frames.count, pointsPerFrame: ppf)
let normalized = Self.normalizeVisBuffer(rawBuf)
alog("Offline vis: cached \(frames.count) frames for \(songId)")
await MainActor.run {
self.offlineVisBuffer = normalized
self.isUsingOfflineVis = true
self.isUsingRealFFT = true
self.offlineVisProgress = 1.0
self.startOfflineVisSync()
}
} catch {
alog("Offline vis: analysis failed: \(error.localizedDescription)")
await MainActor.run { self.startLevelSimulation() }
}
} // end if !fetched
}
}
}
/// Normalize vis frames so the wave is always visible regardless of song loudness.
/// Maps the 95th percentile peak to 0.8, preserving dynamics without clipping.
/// Normalize a VisFrameBuffer to p95 amplitude in-place.
/// One sort + one pass no [[Float]] allocation.
private static func normalizeVisBuffer(_ buffer: VisFrameBuffer) -> VisFrameBuffer {
guard buffer.frameCount > 0 else { return buffer }
// Collect non-zero values to find p95
var vals = buffer.data.filter { $0 > 0.001 }
guard !vals.isEmpty else { return buffer }
vals.sort()
let p95 = vals[min(Int(Float(vals.count) * 0.95), vals.count - 1)]
guard p95 > 0.001 else { return buffer }
let scale = Float(0.8) / p95
var normalized = buffer.data // single ContiguousArray copy
for i in 0..<normalized.count {
normalized[i] = min(1.0, normalized[i] * scale)
}
return VisFrameBuffer(
data: normalized,
frameCount: buffer.frameCount,
pointsPerFrame: buffer.pointsPerFrame
)
}
/// Sync cached frames to current playback time.
/// Uses proportional mapping (time/duration frame index) so it works
/// regardless of what FPS the frames were analyzed at.
private func startOfflineVisSync() {
stopOfflineVisTimer()
stopLevelTimer()
let frameCount = offlineVisBuffer.frameCount
alog("Offline vis sync: \(frameCount) frames, duration=\(String(format: "%.1f", duration))s")
if duration <= 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.startOfflineVisSyncTimer()
}
} else {
startOfflineVisSyncTimer()
}
}
private func startOfflineVisSyncTimer() {
stopOfflineVisTimer()
offlineVisTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
guard let self = self, self.isUsingOfflineVis else { return }
guard self.isPlaying else {
// Zero out levels so the visualizer decays during pause
if self._audioLevels.contains(where: { $0 > 0.005 }) {
for i in 0..<self._audioLevels.count { self._audioLevels[i] = 0 }
self.levelTick &+= 1
}
return
}
let buf = self.offlineVisBuffer
guard buf.frameCount > 0 else { return }
let dur = self.duration
let time = self.currentTime
let frameIndex: Int
if dur > 0 {
let pct = min(time / dur, 1.0)
frameIndex = min(Int(pct * Double(buf.frameCount)), buf.frameCount - 1)
} else {
frameIndex = min(Int(time * 30.0), buf.frameCount - 1)
}
// Zero-allocation frame dispatch: copy directly from flat buffer into _audioLevels
buf.copyFrame(at: frameIndex, into: &self._audioLevels)
self.levelTick &+= 1
}
}
private func stopOfflineVisTimer() {
offlineVisTimer?.invalidate()
offlineVisTimer = nil
}
#endif
// MARK: - Simulated Level Animation (for streams)
/// Radio-specific simulation: smooth sinusoidal wave that never spikes.
/// Radio streams can't be pre-analyzed and their AVPlayer currentTime can
/// jump on buffer seeks using the same random-phase simulation as music
/// causes the peakFollower in the visualizer to spike and "raise" the wave.
/// This uses a time-based sine wave that's completely independent of
/// currentTime, so buffer resets and seek-backs have no visual effect.
private func startRadioSimulation() {
stopLevelTimer()
var radioPhase: Double = 0
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
#if os(iOS)
guard VisualizerSettings.shared.enabled else { return }
#endif
guard self.isPlaying else {
if self.internalLevels.contains(where: { $0 > 0.01 }) {
for i in 0..<self.internalLevels.count { self.internalLevels[i] = 0 }
self.setLevels(self.internalLevels)
}
return
}
radioPhase += 1.0 / 30.0
// Smooth sine wave across the frequency bands gentle, never spikes.
// Base amplitude 0.35, modulated by a slow breathing envelope (0.15 range)
// so it looks alive without jarring jumps.
let count = self.internalLevels.count
let breathe = Float(0.35 + 0.15 * sin(radioPhase * 0.4))
for i in 0..<count {
let x = Float(i) / Float(max(count - 1, 1))
// Two overlapping sine waves at different frequencies for organic shape
let wave1 = sin(Float(radioPhase * 1.1) + x * .pi * 2.0)
let wave2 = sin(Float(radioPhase * 0.7) + x * .pi * 3.5) * 0.4
self.internalLevels[i] = breathe * (0.5 + 0.5 * (wave1 + wave2) / 1.4)
}
self.setLevels(self.internalLevels)
}
}
private func startLevelSimulation() {
stopLevelTimer()
lastTargetPhase = -1
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
// Skip all work when visualizer is disabled avoids 60fps float math
// and setLevels() calls that serve no purpose with no Canvas reading them.
#if os(iOS)
guard VisualizerSettings.shared.enabled else { return }
#endif
guard self.isPlaying else {
if self.internalLevels.contains(where: { $0 > 0.01 }) {
for i in 0..<self.internalLevels.count {
self.internalLevels[i] = 0
}
self.setLevels(self.internalLevels)
}
return
}
let time = self.currentTime
let phase = Int(time * 12)
if phase != self.lastTargetPhase {
self.lastTargetPhase = phase
// In-place random target no allocation
for i in 0..<self.targetLevels.count {
self.targetLevels[i] = Float.random(in: 0.1...0.85)
}
}
// In-place smooth no zip/map allocation
let smoothing: Float = 0.15
for i in 0..<self.internalLevels.count {
self.internalLevels[i] += (self.targetLevels[i] - self.internalLevels[i]) * smoothing
}
self.publishCounter += 1
if self.publishCounter % 2 == 0 {
self.setLevels(self.internalLevels)
}
}
}
// MARK: - Widget State Push
#if os(iOS)
/// Push current playback state to the widget via App Group.
/// Called on song change, play/pause, and artwork load.
private func pushWidgetState() {
guard let song = currentSong else { return }
let upcoming = Array(queue.dropFirst(queueIndex + 1).prefix(3))
.map { (title: $0.title, artist: $0.artist ?? "Unknown") }
// Try to get cover art from memory caches (zero I/O)
var coverImage: UIImage?
if let id = song.coverArt {
coverImage = AlbumCoverStore.shared.loadCover(for: id)
if coverImage == nil,
let url = ServerManager.shared.client.coverArtURL(id: id, size: 300) {
coverImage = ImageCache.shared.memoryOnlyImage(for: url)
}
}
WidgetBridge.shared.updateNowPlaying(
title: song.title,
artist: song.artist ?? "Unknown",
album: song.album ?? "",
isPlaying: isPlaying,
currentTime: currentTime,
duration: duration,
coverArtId: song.coverArt,
coverArtImage: coverImage,
queue: upcoming
)
}
#endif
// MARK: - Cleanup
private func stopLevelTimer() {
levelTimer?.invalidate()
levelTimer = nil
}
private func stopEngineTimer() {
engineTimeTimer?.invalidate()
engineTimeTimer = nil
}
private func stopEngine() {
scheduleGeneration += 1 // invalidate all pending completions
enginePlayerNode?.stop()
audioEngine?.mainMixerNode.removeTap(onBus: 0)
audioEngine?.stop()
audioEngine = nil
enginePlayerNode = nil
engineFile = nil
stopEngineTimer()
}
private func stopAVPlayer() {
player?.pause()
player?.replaceCurrentItem(with: nil)
if let observer = timeObserver {
player?.removeTimeObserver(observer)
timeObserver = nil
}
playerItem = nil
}
/// Stop everything called before starting new playback
private func stopAll() {
stopLevelTimer()
nowPlayingSyncTimer?.invalidate()
nowPlayingSyncTimer = nil
#if os(iOS)
stopOfflineVisTimer()
analysisTask?.cancel()
analysisTask = nil
if isUsingCrossfade {
SmartCrossfadeManager.shared.stop()
isUsingCrossfade = false
}
#endif
stopEngine()
stopAVPlayer()
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
}
func stop() {
stopAll()
isPlaying = false
currentSong = nil
setLevels(Array(repeating: 0, count: 30))
internalLevels = Array(repeating: 0, count: 30)
rawFFTLevels = Array(repeating: 0, count: 512)
isUsingRealFFT = false
isUsingOfflineVis = false
#if os(iOS)
offlineVisBuffer = VisFrameBuffer.empty
WidgetBridge.shared.clear()
#endif
if isRadioStream {
isRadioStream = false
radioStreamURL = nil
#if os(iOS)
RadioStreamBuffer.shared.stopBuffering()
isPlayingFromBuffer = false
#endif
}
}
deinit {
if let setup = fftSetup {
vDSP_destroy_fftsetup(setup)
}
stop()
}
}