NavidromeApp/Shared/Audio/AudioPlayer.swift
Dallas Groot c6451b440b Audio Tap FFT:
- MTAudioProcessingTap with lock-free ring buffer + pre-allocated vDSP FFT
- Tap installs on readyToPlay (fixes 'no audio track' on live streams)
- Shared tap serves FFT + Shazam simultaneously (no more tap conflict)
- Ring buffer reset on removeTap (prevents stale FFT frames)
- Simulation runs as placeholder until stream connects
- All 6 paths verified: start, station change, goLive, seekBack, bg/fg, radio→music

Bug fixes:
- playerItem tracking in radioGoLive/radioSeekBack (was orphaned)
- Combine sinks: .receive(on: .main) on all 4 notification handlers
- ShazamRecognizer stopAll: audio session now actually restores after mic fallback
- processTapBuffer/convertAndMatch methods restored (were dropped in refactor)

Lyrics:
- Bottom toolbar on overlay: Search, Timing (±0.1s/±0.5s inline), Edit
- LyricsManager.globalOffset applied to syncToTime + wordProgress
- openLyricsEditor notification wired to NowPlayingView

UI:
- Server selection button hidden when Dynamic Island active
- Debug: Capture Audio Tap (5s WAV) with share sheet in Visualizer Settings"
2026-04-14 19:45:05 -07:00

2121 lines
87 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
// Audio session is configured lazily on first play not here.
// Setting .playback category on init interrupts whatever audio is
// already playing (podcasts, other music apps).
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)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.suspendVisTimers() }
.store(in: &cancellables)
// Restart the correct timer when returning to foreground
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
.receive(on: DispatchQueue.main)
.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)
.receive(on: DispatchQueue.main)
.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)
.receive(on: DispatchQueue.main)
.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()
nowPlayingSyncTimer?.invalidate()
nowPlayingSyncTimer = nil
analysisTask?.cancel()
analysisTask = nil
AudioPreFetcher.shared.cancelAll()
removeTimeObserver()
#if os(iOS)
// Remove audio tap on background no point running FFT when no one can see it
if isRadioStream {
AudioTapProcessor.shared.removeTap(from: playerItem)
}
#endif
// 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() {
// Pick up any widget commands that arrived while the process was suspended.
// Darwin notifications can't wake suspended processes, so commands written
// by widget intents sit in App Group UserDefaults until we check here.
WidgetBridge.shared.processAnyPendingCommand()
// Sync currentTime/duration from the live AVPlayer state.
// While backgrounded, the time observer was removed (suspendVisTimers)
// but AVPlayer kept advancing. Without this sync, the vis timer's first
// frames read the stale position from when the app backgrounded, causing
// the waveform to render from the wrong song position then snap-correct
// once the reinstalled time observer fires (~0.1s later).
if let p = player, p.currentTime().isNumeric {
currentTime = p.currentTime().seconds
}
if let dur = playerItem?.duration.seconds, dur.isFinite, dur > 0 {
duration = dur
}
// 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 + reclaim audio session if actually playing.
// If nothing is playing, don't touch the session let podcasts/other apps keep it.
guard isPlaying else { return }
// 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)")
}
// Restart Now Playing sync timer keeps Lock Screen / Dynamic Island
// seek bar accurate. Created in playWithAVPlayer but never restarted
// after background cycle; without this the system seek bar drifts.
if nowPlayingSyncTimer == nil {
nowPlayingSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
guard let self = self, self.isPlaying else { return }
self.updateNowPlayingInfo()
}
}
if isUsingOfflineVis {
startOfflineVisSync()
} else if isRadioStream {
#if os(iOS)
// Reinstall the audio tap that was removed on background
installAudioTapIfNeeded(on: playerItem)
#endif
} else if !isUsingCrossfade && !VisualizerSettings.shared.realAudioAnalysis {
startLevelSimulation()
}
// When realAudioAnalysis is on but vis data hasn't loaded yet: stay at 0.
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()
AudioTapProcessor.shared.removeTap(from: playerItem)
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
// Claim audio session deferred from init() to first play
configureAudioSession()
activateAudioSession()
// 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 }
// Clear stale vis from outgoing song before loading new
self.offlineVisBuffer = VisFrameBuffer.empty
self.setLevels(Array(repeating: 0, count: self._audioLevels.count))
self.loadOfflineVisualizer(songId: nextSong.id, url: url)
}
// At crossfade midpoint, transition metadata/artwork/colors/widget
// so the UI reflects the incoming song while audio is still fading.
crossfade.songHandoff = { [weak self] incomingSong in
guard let self = self else { return }
// Scrobble the outgoing song before updating currentSong
if let outgoing = self.currentSong {
Task { try? await ServerManager.shared.client.scrobble(id: outgoing.id) }
}
self.currentSong = incomingSong
if let idx = self.queue.firstIndex(where: { $0.id == incomingSong.id }) {
self.queueIndex = idx
}
self.updateNowPlayingInfo()
self.fetchAndSetArtwork(coverArtId: incomingSong.coverArt)
self.pushWidgetState()
LyricsManager.shared.songChanged(
songId: incomingSong.id,
artist: incomingSong.artist ?? "Unknown",
title: incomingSong.title,
path: incomingSong.path,
duration: self.duration
)
}
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)
if !VisualizerSettings.shared.realAudioAnalysis {
startLevelSimulation()
}
// Load offline visualizer for crossfade path too
if VisualizerSettings.shared.realAudioAnalysis {
loadOfflineVisualizer(songId: song.id, url: url)
}
// Prepare next track in standby
prepareNextForCrossfade()
pushWidgetState()
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)
playerItem = item // Keep self.playerItem in sync taps and Shazam depend on this
// 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)
#if os(iOS)
installAudioTapIfNeeded(on: item)
#endif
// 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)
playerItem = item // Keep self.playerItem in sync taps and Shazam depend on this
player?.replaceCurrentItem(with: item)
player?.play()
#if os(iOS)
// Start simulation immediately tap installs when stream reaches readyToPlay
startRadioSimulation()
let liveItem = item
Task { @MainActor [weak self] in
for await status in liveItem.publisher(for: \.status).values {
guard let self, self.playerItem === liveItem else { break }
if status == .readyToPlay {
self.installAudioTapIfNeeded(on: liveItem)
break
}
if status == .failed { break }
}
}
#endif
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!
let isRadio = isRadioStream
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")
// Install audio tap AFTER the stream has connected and the asset
// has parsed its container. Before readyToPlay, loadTracks returns
// empty for live streams tap silently fails.
if isRadio {
self.installAudioTapIfNeeded(on: itemToObserve)
}
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
}
#if os(iOS)
// Sync lyrics line tracking every ~0.5s (every 5th tick of 0.1s interval)
if Int(time.seconds * 10) % 5 == 0 {
LyricsManager.shared.syncToTime(time.seconds)
}
#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: start simulation immediately for visual feedback while
// the stream connects. The readyToPlay status observer (above) will install
// the real FFT audio tap once the asset has loaded its tracks.
#if os(iOS)
if isRadioStream {
startRadioSimulation()
} else if !VisualizerSettings.shared.realAudioAnalysis {
// Simulation mode only real analysis waits for offline vis data
startLevelSimulation()
}
// When realAudioAnalysis is on: levels stay at 0 until
// loadOfflineVisualizer startOfflineVisSync fills them.
#else
startLevelSimulation()
#endif
}
// MARK: - AVAudioEngine Path (local files with real FFT)
private func playLocalWithEngine(_ url: URL) {
#if os(iOS)
alog("Engine path: \(url.lastPathComponent)")
configureAudioSession()
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 if !VisualizerSettings.shared.realAudioAnalysis {
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() }
}
// else: realAudioAnalysis on, vis data loading stay at 0
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 + currentSong + artwork already handled by songHandoff at midpoint.
// Here we only handle post-swap housekeeping.
// Ensure queueIndex is synced (songHandoff may have set it, but verify)
if let nowPlaying = crossfade.pendingNextSong,
let idx = self.queue.firstIndex(where: { $0.id == nowPlaying.id }) {
self.queueIndex = idx
// Only update currentSong if songHandoff somehow didn't fire
if self.currentSong?.id != nowPlaying.id {
self.currentSong = nowPlaying
self.updateNowPlayingInfo()
self.fetchAndSetArtwork(coverArtId: nowPlaying.coverArt)
self.pushWidgetState()
}
} 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.updateNowPlayingInfo()
self.fetchAndSetArtwork(coverArtId: self.currentSong?.coverArt)
self.pushWidgetState()
}
}
// Persist updated queue position
PlaybackStateStore.shared.save(
queue: self.queue, index: self.queueIndex,
currentTime: 0, currentSongId: self.currentSong?.id
)
// Swap AudioPlayer.player to the new active crossfade player
self.player = crossfade.activePlayer
// Re-register the safety-net AVPlayerItemDidPlayToEndTime observer
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
)
}
// 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: don't load the SAME song into the standby player.
// With repeat-all + single-song queue, nextIdx wraps to 0 (same song).
// Loading it into standby creates two AVPlayers with identical content
// if the crossfade boundary fires, both play simultaneously and drift
// out of sync, producing the "duplicate audio" artifact.
if nextSong.id == currentSong?.id {
alog("SmartDJ: next is same song — skipping standby prep (repeat-one will handle)")
return
}
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.
}
// Persist shuffled queue shape time observer only saves position now
#if os(iOS)
PlaybackStateStore.shared.save(
queue: queue, index: queueIndex,
currentTime: currentTime, currentSongId: currentSong?.id
)
#endif
}
}
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)")
// Don't fall back to simulation levels stay at 0
}
} // 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: - Real-time FFT via Audio Tap (radio streams)
#if os(iOS)
/// Install the shared audio tap on a player item for real-time FFT visualization.
/// Async because `loadTracks(withMediaType:)` is the modern non-deprecated API.
/// Falls back to sinusoidal simulation if tap installation fails.
func installAudioTapIfNeeded(on item: AVPlayerItem?) {
guard let item else {
startRadioSimulation()
return
}
Task { @MainActor in
let success = await AudioTapProcessor.shared.installTap(on: item)
if success {
startRadioFFT()
} else {
alog("Audio tap failed — falling back to simulation")
startRadioSimulation()
}
}
}
/// Timer that reads real PCM samples from the audio tap ring buffer,
/// runs vDSP FFT, and feeds 30 frequency bands to the visualizer.
/// Replaces startRadioSimulation() when the tap is active.
private func startRadioFFT() {
stopLevelTimer()
alog("Radio FFT: ▶ real-time analysis active")
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
guard let self else { return }
guard VisualizerSettings.shared.enabled else { return }
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 bands = AudioTapProcessor.shared.computeFFTBands(bandCount: self.internalLevels.count)
// Apply viscosity smoothing (same physics as offline vis path)
let viscosity = Float(VisualizerSettings.shared.viscosity)
for i in 0..<self.internalLevels.count {
self.internalLevels[i] = self.internalLevels[i] * viscosity + bands[i] * (1 - viscosity)
}
self.setLevels(self.internalLevels)
}
}
#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 }
// Notify LyricsManager of the current song (deduplicates internally by song ID)
LyricsManager.shared.songChanged(
songId: song.id,
artist: song.artist ?? "Unknown",
title: song.title,
path: song.path,
duration: duration
)
let upcomingSongs = Array(queue.dropFirst(queueIndex + 1).prefix(3))
let upcoming: [(title: String, artist: String, coverData: Data?)] = upcomingSongs.map { s in
var artData: Data?
if let id = s.coverArt {
if let custom = AlbumCoverStore.shared.loadCover(for: id) {
// Downscale to tiny thumbnail for widget (~40pt @ 2x = 80px)
artData = custom.preparingThumbnail(of: CGSize(width: 80, height: 80))?
.jpegData(compressionQuality: 0.5)
} else if let url = ServerManager.shared.client.coverArtURL(id: id, size: 80) {
artData = ImageCache.shared.cachedImage(for: url)?
.jpegData(compressionQuality: 0.5)
}
}
return (title: s.title, artist: s.artist ?? "Unknown", coverData: artData)
}
// Cover art: custom first, then server (memory + disk).
var coverImage: UIImage?
var artKey = song.coverArt
if let id = song.coverArt {
if let custom = AlbumCoverStore.shared.loadCover(for: id) {
coverImage = custom
artKey = "custom_\(id)"
} else if let url = ServerManager.shared.client.coverArtURL(id: id, size: 600) {
coverImage = ImageCache.shared.cachedImage(for: url)
}
}
// Waveform: downsample offline vis data to 40 peaks, or pseudo-random fallback.
let waveform = sampleWaveform(sampleCount: 40)
// Crossfade trigger time from Smart DJ profile (seconds into track).
let crossfadeTime: TimeInterval? = {
if isUsingCrossfade,
let ss = SmartCrossfadeManager.shared.currentProfile?.silenceStart,
ss > 0 { return ss }
return nil
}()
WidgetBridge.shared.updateNowPlaying(
title: song.title,
artist: song.artist ?? "Unknown",
album: song.album ?? "",
isPlaying: isPlaying,
currentTime: currentTime,
duration: duration,
coverArtId: artKey,
coverArtImage: coverImage,
queue: upcoming,
waveformSamples: waveform,
crossfadeAt: crossfadeTime
)
}
/// Downsample the offline vis buffer to `sampleCount` peak amplitudes (0.01.0).
/// If no vis data is loaded, returns a seeded pseudo-random shape per song ID.
private func sampleWaveform(sampleCount: Int = 40) -> [Float] {
let buf = offlineVisBuffer
if !buf.isEmpty {
var samples = [Float]()
samples.reserveCapacity(sampleCount)
for i in 0..<sampleCount {
let startFrame = i * buf.frameCount / sampleCount
let endFrame = min((i + 1) * buf.frameCount / sampleCount, buf.frameCount)
var peak: Float = 0
for f in startFrame..<endFrame {
let offset = f * buf.pointsPerFrame
let end = min(offset + buf.pointsPerFrame, buf.data.count)
for j in offset..<end {
peak = max(peak, buf.data[j])
}
}
samples.append(peak)
}
return samples
}
// Fallback: seeded PRNG for consistent shape per song
guard let id = currentSong?.id else {
return [Float](repeating: 0.3, count: sampleCount)
}
var seed: UInt64 = 5381
for c in id.utf8 { seed = seed &* 33 &+ UInt64(c) }
var samples = [Float]()
samples.reserveCapacity(sampleCount)
for i in 0..<sampleCount {
seed = (seed &* 16807 &+ 7) % 2147483647
let base = Float(seed % 1000) / 1000.0
let envelope = 0.5 + 0.5 * sin(Float.pi * Float(i) / Float(sampleCount))
samples.append(min(0.2 + base * 0.6 * envelope, 1.0))
}
return samples
}
#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() {
#if os(iOS)
// Clean up audio tap before nilling the playerItem prevents stale
// sourceFormat from confusing Shazam into skipping tap installation
AudioTapProcessor.shared.removeTap(from: playerItem)
#endif
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
}
// Zero the vis buffer so stale frames from the previous song don't render
// while the new song is loading. The new loadOfflineVisualizer() call will
// repopulate it once analysis finishes.
offlineVisBuffer = VisFrameBuffer.empty
#endif
stopEngine()
stopAVPlayer()
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
// Zero audio levels immediately prevents the Canvas from painting stale
// FFT/vis data during track transitions (the "lifted bars" bug).
setLevels(Array(repeating: 0, count: _audioLevels.count))
}
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()
}
}