| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369 |
- 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..<numSamples {
- let t = Double(i) / sampleRate
- let sampleVal1 = sin(2.0 * .pi * frequency1 * t)
- let sampleVal2 = sin(2.0 * .pi * frequency2 * t)
- let envelope = min(1.0, min(t * 2.0, (durationSeconds - t) * 2.0))
- let combined = (sampleVal1 * 0.4 + sampleVal2 * 0.3) * envelope
- let pcmValue = Int16(clamping: Int(combined * 16384.0))
- data.append(withUnsafeBytes(of: pcmValue.littleEndian) { Data($0) })
- }
- do {
- try data.write(to: fileURL)
- return fileURL
- } catch {
- print("[PlaybackViewModel] Failed to write fallback audio WAV: \(error.localizedDescription)")
- return nil
- }
- }
- }
|