| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494 |
- import SwiftUI
- import Observation
- import AVFoundation
- private final class WeakPlaybackViewModelReference: @unchecked Sendable {
- weak var value: PlaybackViewModel?
- init(_ value: PlaybackViewModel) {
- self.value = value
- }
- }
- // MARK: - PlaybackViewModel
- /// Manages audio playback state, timeline scrubbing, and event filtering
- /// for session detail/review screens using asynchronously prepared AVPlayer media.
- @Observable
- final class PlaybackViewModel: NSObject {
- // MARK: - Playback State
- /// Current playback position in milliseconds.
- var currentPlaybackTimeMs: Double = 0
- /// Whether audio is currently playing.
- private(set) var isPlaying: Bool = false
- /// A user-facing reason why the selected recording cannot be played.
- private(set) var playbackError: String?
- /// Whether the local media asset is still being prepared.
- private(set) var isPreparingPlayback: Bool = false
- /// Whether the player is ready to accept playback and seek commands.
- private(set) var isPlaybackReady: Bool = false
- /// Whether a slider or timeline playhead drag is active.
- private(set) var isScrubbing: Bool = false
- /// Total session duration in milliseconds.
- var totalDurationMs: Double = 0
-
- // MARK: - Silence Skip State
-
- /// Whether auto-skip silence is enabled
- var isSilenceSkipEnabled: Bool = true
-
- /// Whether background silence analysis is active
- var isAnalyzingSilence: Bool = false
-
- /// Detected silent intervals
- var silentRanges: [SilenceRange] = []
- /// Amplitude levels extracted from the playback file on the live-recording scale.
- var waveformSamples: [Float] = []
- /// Cheap invalidation token for the otherwise static waveform drawing layer.
- private(set) var waveformRevision: Int = 0
- /// Whether playback waveform and silence analysis is active.
- var isAnalyzingAudio: Bool = false
- // MARK: - Audio Player Private State
- private var player: AVPlayer?
- private var playerItem: AVPlayerItem?
- private var activeAudioURL: URL?
- private var playerPreparationTask: Task<Void, Never>?
- private var audioAnalysisTask: Task<Void, Never>?
- private var audioAnalysisSession: AudioAnalysisSession?
- private var playbackTimeObserver: Any?
- private var playbackEndObserver: NSObjectProtocol?
- private var shouldPlayWhenReady = false
- private var shouldResumeAfterScrubbing = false
- private var isSilenceSkipSeekInFlight = false
- // 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
- // MARK: - Init / Deinit
- override init() {
- super.init()
- }
- deinit {
- playerPreparationTask?.cancel()
- audioAnalysisTask?.cancel()
- audioAnalysisSession?.cancel()
- removePlayerObservers()
- player?.pause()
- }
- // MARK: - Player Preparation
- /// Prepares the audio player for the given local recording file.
- func preparePlayer(path: String?, durationMs: Double = 0) {
- playerPreparationTask?.cancel()
- removePlayerObservers()
- player?.pause()
- player = nil
- playerItem = nil
- activeAudioURL = nil
- playbackError = nil
- isPlaying = false
- isPlaybackReady = false
- isPreparingPlayback = false
- shouldPlayWhenReady = false
- currentPlaybackTimeMs = 0
- if durationMs > 0 {
- totalDurationMs = durationMs
- }
- guard let targetURL = AudioPathHelper.resolveURL(for: path) else {
- print("[PlaybackViewModel] No valid audio file found for path: \(path ?? "nil")")
- playbackError = "未找到本地录音文件,请先从云端同步或重新录制"
- return
- }
- self.activeAudioURL = targetURL
- isPreparingPlayback = true
- // AVURLAsset creation is lightweight. Properties that may touch a long
- // AAC file are loaded asynchronously so navigation never waits for them.
- playerPreparationTask = Task { @MainActor [weak self] in
- let asset = AVURLAsset(url: targetURL)
- do {
- let duration = try await asset.load(.duration)
- let isPlayable = try await asset.load(.isPlayable)
- try Task.checkCancellation()
- guard let self else { return }
- guard self.activeAudioURL == targetURL else { return }
- guard isPlayable else {
- self.isPreparingPlayback = false
- self.playbackError = "录音文件无法播放"
- return
- }
- let item = AVPlayerItem(asset: asset)
- let player = AVPlayer(playerItem: item)
- player.automaticallyWaitsToMinimizeStalling = true
- self.playerItem = item
- self.player = player
- self.installPlayerObservers(player: player, item: item)
- let seconds = CMTimeGetSeconds(duration)
- if seconds.isFinite && seconds > 0 {
- self.totalDurationMs = seconds * 1_000
- }
- self.isPreparingPlayback = false
- self.isPlaybackReady = true
- self.performSeek(to: self.currentPlaybackTimeMs) { [weak self] _ in
- guard let self, self.shouldPlayWhenReady else { return }
- self.startPreparedPlayer()
- }
- } catch is CancellationError {
- return
- } catch {
- guard let self, self.activeAudioURL == targetURL else { return }
- print("[PlaybackViewModel] Failed to prepare AVPlayer: \(error.localizedDescription)")
- self.isPreparingPlayback = false
- self.playbackError = "录音文件无法播放:\(error.localizedDescription)"
- }
- }
- }
- private func setupAudioSession() {
- let session = AVAudioSession.sharedInstance()
- do {
- // `.playback` already routes through the speaker by default.
- // `.defaultToSpeaker` is valid only with `.playAndRecord`.
- try session.setCategory(.playback, mode: .default)
- 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 }
- shouldPlayWhenReady = true
- guard isPlaybackReady, player != nil else {
- if isPreparingPlayback {
- return
- }
- playbackError = "当前记录没有可播放的本地录音文件"
- return
- }
- setupAudioSession()
- startPreparedPlayer()
- }
- /// Pauses playback at the current position.
- func pause() {
- shouldPlayWhenReady = false
- player?.pause()
- isPlaying = false
- }
- /// 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
- audioAnalysisSession?.prioritize(time: target / 1_000)
- performSeek(to: target)
- }
- /// Begins an interactive seek. Playback pauses once and no decoder seeks
- /// occur until the gesture ends.
- func beginScrubbing() {
- guard !isScrubbing else { return }
- isScrubbing = true
- shouldResumeAfterScrubbing = isPlaying || shouldPlayWhenReady
- pause()
- }
- /// Updates only the visual position while an interactive seek is active.
- func previewScrub(to timeMs: Double) {
- let target = min(max(timeMs, 0), totalDurationMs)
- currentPlaybackTimeMs = target
- audioAnalysisSession?.prioritize(time: target / 1_000)
- }
- /// Commits one accurate player seek, then restores playback only when it
- /// was active before the drag began.
- func endScrubbing(at timeMs: Double) {
- let target = min(max(timeMs, 0), totalDurationMs)
- currentPlaybackTimeMs = target
- isScrubbing = false
- let shouldResume = shouldResumeAfterScrubbing
- shouldResumeAfterScrubbing = false
- performSeek(to: target) { [weak self] finished in
- guard let self, shouldResume else { return }
- if finished || self.isPreparingPlayback {
- self.play()
- }
- }
- }
- /// 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: - Private Player Coordination & Audio Analysis
- private func startPreparedPlayer() {
- guard let player else { return }
- shouldPlayWhenReady = true
- player.play()
- isPlaying = true
- }
- private func performSeek(
- to timeMs: Double,
- completion: ((Bool) -> Void)? = nil
- ) {
- guard let player, isPlaybackReady else {
- completion?(false)
- return
- }
- let time = CMTime(seconds: timeMs / 1_000, preferredTimescale: 600)
- player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { finished in
- DispatchQueue.main.async {
- completion?(finished)
- }
- }
- }
- private func installPlayerObservers(player: AVPlayer, item: AVPlayerItem) {
- playbackTimeObserver = player.addPeriodicTimeObserver(
- forInterval: CMTime(seconds: 0.1, preferredTimescale: 600),
- queue: .main
- ) { [weak self] time in
- guard let self, !self.isScrubbing else { return }
- let seconds = CMTimeGetSeconds(time)
- guard seconds.isFinite else { return }
- self.currentPlaybackTimeMs = min(max(seconds * 1_000, 0), self.totalDurationMs)
- self.checkForSilenceAndSkip()
- }
- playbackEndObserver = NotificationCenter.default.addObserver(
- forName: .AVPlayerItemDidPlayToEndTime,
- object: item,
- queue: .main
- ) { [weak self] _ in
- guard let self else { return }
- self.isPlaying = false
- self.shouldPlayWhenReady = false
- self.currentPlaybackTimeMs = self.totalDurationMs
- }
- }
- private func removePlayerObservers() {
- if let playbackTimeObserver, let player {
- player.removeTimeObserver(playbackTimeObserver)
- }
- playbackTimeObserver = nil
- if let playbackEndObserver {
- NotificationCenter.default.removeObserver(playbackEndObserver)
- }
- playbackEndObserver = nil
- }
- /// Runs silence detection asynchronously.
- func analyzeSilence(audioURL: URL?) {
- analyzeAudio(audioURL: audioURL)
- }
- /// Extracts the real waveform and silence ranges in one background pass.
- func analyzeAudio(audioURL: URL?) {
- audioAnalysisSession?.cancel()
- audioAnalysisTask?.cancel()
- let targetPath = audioURL?.path ?? activeAudioURL?.path
- guard let url = AudioPathHelper.resolveURL(for: targetPath) else {
- waveformSamples = []
- silentRanges = []
- waveformRevision &+= 1
- isAnalyzingSilence = false
- isAnalyzingAudio = false
- return
- }
-
- waveformSamples = []
- silentRanges = []
- waveformRevision &+= 1
- isAnalyzingSilence = true
- isAnalyzingAudio = true
- let session = AudioAnalysisSession(audioURL: url)
- audioAnalysisSession = session
- session.prioritize(time: currentPlaybackTimeMs / 1_000)
- let reference = WeakPlaybackViewModelReference(self)
- audioAnalysisTask = Task {
- await session.run { update in
- await MainActor.run {
- guard let owner = reference.value,
- owner.audioAnalysisSession === session else {
- return
- }
- owner.applyAnalysisUpdate(update)
- }
- }
- await MainActor.run {
- guard let owner = reference.value,
- owner.audioAnalysisSession === session else {
- return
- }
- if !Task.isCancelled {
- owner.isAnalyzingSilence = false
- owner.isAnalyzingAudio = false
- }
- }
- }
- }
- func cancelAudioAnalysis() {
- audioAnalysisSession?.cancel()
- audioAnalysisSession = nil
- audioAnalysisTask?.cancel()
- audioAnalysisTask = nil
- isAnalyzingSilence = false
- isAnalyzingAudio = false
- }
- private func applyAnalysisUpdate(_ update: AudioAnalysisProgress) {
- if waveformSamples.count != update.totalSampleCount {
- waveformSamples = Array(repeating: 0, count: update.totalSampleCount)
- }
- let lowerBound = min(max(update.sampleOffset, 0), waveformSamples.count)
- let upperBound = min(lowerBound + update.waveformSamples.count, waveformSamples.count)
- if lowerBound < upperBound {
- waveformSamples.replaceSubrange(
- lowerBound..<upperBound,
- with: update.waveformSamples.prefix(upperBound - lowerBound)
- )
- }
- silentRanges = update.silentRanges
- waveformRevision &+= 1
- if update.isComplete {
- isAnalyzingSilence = false
- isAnalyzingAudio = false
- }
- }
-
- private func checkForSilenceAndSkip() {
- guard isSilenceSkipEnabled,
- !silentRanges.isEmpty,
- !isSilenceSkipSeekInFlight else {
- return
- }
-
- let currentTimeSec = currentPlaybackTimeMs / 1000.0
- for range in silentRanges {
- if currentTimeSec >= range.start && currentTimeSec < range.end {
- let targetMs = range.end * 1000.0
- isSilenceSkipSeekInFlight = true
- currentPlaybackTimeMs = targetMs
- performSeek(to: targetMs) { [weak self] _ in
- self?.isSilenceSkipSeekInFlight = false
- }
- break
- }
- }
- }
- // 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
- }
- }
- }
|