import SwiftUI import Observation import AVFoundation // MARK: - PlaybackViewModel /// Manages audio playback state, timeline scrubbing, and event filtering /// for session detail/review screens using AVAudioPlayer for real audio output. @Observable final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate { // MARK: - Playback State /// Current playback position in milliseconds. var currentPlaybackTimeMs: Double = 0 /// Whether audio is currently playing. private(set) var isPlaying: Bool = false /// Total session duration in milliseconds. var totalDurationMs: Double = 0 // MARK: - Silence Skip State /// Whether auto-skip silence is enabled var isSilenceSkipEnabled: Bool = false /// Whether background silence analysis is active var isAnalyzingSilence: Bool = false /// Detected silent intervals var silentRanges: [SilenceRange] = [] // MARK: - Audio Player Private State private var audioPlayer: AVAudioPlayer? private var activeAudioURL: URL? // MARK: - Computed Properties /// Formats the current playback position as `MM:SS`. var currentTimeFormatted: String { let totalSeconds = Int(currentPlaybackTimeMs / 1000) let minutes = totalSeconds / 60 let seconds = totalSeconds % 60 return String(format: "%02d:%02d", minutes, seconds) } /// Total duration formatted as `MM:SS`. var totalTimeFormatted: String { let totalSeconds = Int(totalDurationMs / 1000) let minutes = totalSeconds / 60 let seconds = totalSeconds % 60 return String(format: "%02d:%02d", minutes, seconds) } /// Playback progress as a normalized value (0.0 – 1.0). var progress: Double { guard totalDurationMs > 0 else { return 0 } return min(max(currentPlaybackTimeMs / totalDurationMs, 0), 1) } // MARK: - Private /// Tick timer that advances playback position at ~50ms intervals. private var playbackTimer: Timer? /// Tick interval in seconds (≈20 ticks/sec for smooth scrubber movement). private let tickInterval: TimeInterval = 0.05 // MARK: - Init / Deinit override init() { super.init() } deinit { playbackTimer?.invalidate() audioPlayer?.stop() } // MARK: - Player Preparation /// Prepares the audio player for the given file path or generates a sample audio fallback. func preparePlayer(path: String?, durationMs: Double = 0) { stopPlaybackTimer() audioPlayer?.stop() audioPlayer = nil let urlToPlay: URL? if let path = path, !path.isEmpty, FileManager.default.fileExists(atPath: path) { urlToPlay = URL(fileURLWithPath: path) } else { let targetDuration = durationMs > 0 ? durationMs / 1000.0 : 30.0 urlToPlay = generateFallbackAudioFile(durationSeconds: targetDuration) } guard let targetURL = urlToPlay else { return } self.activeAudioURL = targetURL setupAudioSession() do { let player = try AVAudioPlayer(contentsOf: targetURL) player.delegate = self player.prepareToPlay() player.volume = 1.0 self.audioPlayer = player if durationMs <= 0 { self.totalDurationMs = player.duration * 1000.0 } else { self.totalDurationMs = durationMs } } catch { print("[PlaybackViewModel] Failed to initialize AVAudioPlayer: \(error.localizedDescription)") } } private func setupAudioSession() { let session = AVAudioSession.sharedInstance() do { try session.setCategory(.playback, mode: .default, options: [.defaultToSpeaker]) try session.setActive(true) } catch { print("[PlaybackViewModel] Failed to setup AVAudioSession: \(error.localizedDescription)") } } // MARK: - Playback Controls /// Starts or resumes playback from the current position. func play() { guard !isPlaying else { return } setupAudioSession() if let player = audioPlayer { player.currentTime = currentPlaybackTimeMs / 1000.0 player.play() } isPlaying = true startPlaybackTimer() } /// Pauses playback at the current position. func pause() { guard isPlaying else { return } audioPlayer?.pause() isPlaying = false stopPlaybackTimer() } /// Toggles between playing and paused states. func togglePlayback() { if isPlaying { pause() } else { play() } } /// Seeks to a specific position in the timeline. /// - Parameter timeMs: The target position in milliseconds. func seekTo(timeMs: Double) { let target = min(max(timeMs, 0), totalDurationMs) currentPlaybackTimeMs = target if let player = audioPlayer { player.currentTime = target / 1000.0 } } /// Convenience overload accepting Int64. func seekTo(timeMs: Int64) { seekTo(timeMs: Double(timeMs)) } /// Seeks to a normalized progress value (0.0 – 1.0). /// - Parameter progress: Normalized position. func seekToProgress(_ progress: Double) { let clampedProgress = min(max(progress, 0), 1) seekTo(timeMs: clampedProgress * totalDurationMs) } /// Resets playback to the beginning. func reset() { pause() seekTo(timeMs: 0.0) } // MARK: - AVAudioPlayerDelegate func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { isPlaying = false currentPlaybackTimeMs = totalDurationMs stopPlaybackTimer() } // MARK: - Private Timer & Silence Detection private func startPlaybackTimer() { playbackTimer?.invalidate() playbackTimer = Timer.scheduledTimer( withTimeInterval: tickInterval, repeats: true ) { [weak self] _ in guard let self else { return } self.tick() } if let timer = playbackTimer { RunLoop.main.add(timer, forMode: .common) } } private func stopPlaybackTimer() { playbackTimer?.invalidate() playbackTimer = nil } /// Runs silence detection asynchronously. func analyzeSilence(audioURL: URL?) { let url = audioURL ?? activeAudioURL guard let url = url else { self.silentRanges = [ SilenceRange(start: 5.0, end: 12.0), SilenceRange(start: 25.0, end: 32.0) ] return } isAnalyzingSilence = true Task { let ranges = await SilenceDetector.detectSilence(in: url) await MainActor.run { self.silentRanges = ranges self.isAnalyzingSilence = false } } } private func checkForSilenceAndSkip() { guard isSilenceSkipEnabled && !silentRanges.isEmpty else { return } let currentTimeSec = currentPlaybackTimeMs / 1000.0 for range in silentRanges { if currentTimeSec >= range.start && currentTimeSec < range.end { let targetMs = range.end * 1000.0 seekTo(timeMs: targetMs) print("[PlaybackViewModel] Auto-skipped silence from \(range.start)s to \(range.end)s") break } } } /// Advances playback by one tick interval, syncing with audioPlayer if active. private func tick() { if let player = audioPlayer, player.isPlaying { currentPlaybackTimeMs = player.currentTime * 1000.0 } else { currentPlaybackTimeMs += tickInterval * 1000 } checkForSilenceAndSkip() if currentPlaybackTimeMs >= totalDurationMs { currentPlaybackTimeMs = totalDurationMs pause() } } // MARK: - Event Queries func sortedEvents(from events: [CelestiaTimelineEvent]) -> [CelestiaTimelineEvent] { events.sorted { $0.relativeTimeMs < $1.relativeTimeMs } } func sortedEvents(from session: CelestiaSession) -> [CelestiaTimelineEvent] { sortedEvents(from: session.events) } func filteredEvents( from events: [CelestiaTimelineEvent], type: String? ) -> [CelestiaTimelineEvent] { let filtered: [CelestiaTimelineEvent] if let type { filtered = events.filter { $0.eventType == type } } else { filtered = events } return filtered.sorted { $0.relativeTimeMs < $1.relativeTimeMs } } func nearestEvent( from events: [CelestiaTimelineEvent], toleranceMs: Double = 500 ) -> CelestiaTimelineEvent? { events .min(by: { abs(Double($0.relativeTimeMs) - currentPlaybackTimeMs) < abs(Double($1.relativeTimeMs) - currentPlaybackTimeMs) }) .flatMap { event in abs(Double(event.relativeTimeMs) - currentPlaybackTimeMs) <= toleranceMs ? event : nil } } // MARK: - Helper Tone Generator private func generateFallbackAudioFile(durationSeconds: Double) -> URL? { let tempDir = FileManager.default.temporaryDirectory let fileURL = tempDir.appendingPathComponent("sample_playback.wav") if FileManager.default.fileExists(atPath: fileURL.path) { return fileURL } let sampleRate: Double = 22050.0 let numSamples = Int(sampleRate * durationSeconds) let numChannels: UInt16 = 1 let bitsPerSample: UInt16 = 16 let byteRate = UInt32(Double(sampleRate) * Double(numChannels) * Double(bitsPerSample / 8)) let blockAlign = UInt16(numChannels * (bitsPerSample / 8)) let dataSize = UInt32(numSamples * Int(blockAlign)) let chunkSize = 36 + dataSize var data = Data() // RIFF header data.append(contentsOf: Array("RIFF".utf8)) data.append(withUnsafeBytes(of: chunkSize.littleEndian) { Data($0) }) data.append(contentsOf: Array("WAVE".utf8)) // fmt chunk data.append(contentsOf: Array("fmt ".utf8)) data.append(withUnsafeBytes(of: UInt32(16).littleEndian) { Data($0) }) data.append(withUnsafeBytes(of: UInt16(1).littleEndian) { Data($0) }) data.append(withUnsafeBytes(of: numChannels.littleEndian) { Data($0) }) data.append(withUnsafeBytes(of: UInt32(sampleRate).littleEndian) { Data($0) }) data.append(withUnsafeBytes(of: byteRate.littleEndian) { Data($0) }) data.append(withUnsafeBytes(of: blockAlign.littleEndian) { Data($0) }) data.append(withUnsafeBytes(of: bitsPerSample.littleEndian) { Data($0) }) // data chunk data.append(contentsOf: Array("data".utf8)) data.append(withUnsafeBytes(of: dataSize.littleEndian) { Data($0) }) let frequency1: Double = 440.0 let frequency2: Double = 554.37 for i in 0..