|
@@ -2,12 +2,20 @@ import SwiftUI
|
|
|
import Observation
|
|
import Observation
|
|
|
import AVFoundation
|
|
import AVFoundation
|
|
|
|
|
|
|
|
|
|
+private final class WeakPlaybackViewModelReference: @unchecked Sendable {
|
|
|
|
|
+ weak var value: PlaybackViewModel?
|
|
|
|
|
+
|
|
|
|
|
+ init(_ value: PlaybackViewModel) {
|
|
|
|
|
+ self.value = value
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// MARK: - PlaybackViewModel
|
|
// MARK: - PlaybackViewModel
|
|
|
|
|
|
|
|
/// Manages audio playback state, timeline scrubbing, and event filtering
|
|
/// Manages audio playback state, timeline scrubbing, and event filtering
|
|
|
-/// for session detail/review screens using AVAudioPlayer for real audio output.
|
|
|
|
|
|
|
+/// for session detail/review screens using asynchronously prepared AVPlayer media.
|
|
|
@Observable
|
|
@Observable
|
|
|
-final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
|
|
|
|
|
|
|
+final class PlaybackViewModel: NSObject {
|
|
|
|
|
|
|
|
// MARK: - Playback State
|
|
// MARK: - Playback State
|
|
|
|
|
|
|
@@ -20,6 +28,15 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
|
|
|
/// A user-facing reason why the selected recording cannot be played.
|
|
/// A user-facing reason why the selected recording cannot be played.
|
|
|
private(set) var playbackError: String?
|
|
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.
|
|
/// Total session duration in milliseconds.
|
|
|
var totalDurationMs: Double = 0
|
|
var totalDurationMs: Double = 0
|
|
|
|
|
|
|
@@ -37,13 +54,24 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
|
|
|
/// Amplitude levels extracted from the playback file on the live-recording scale.
|
|
/// Amplitude levels extracted from the playback file on the live-recording scale.
|
|
|
var waveformSamples: [Float] = []
|
|
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.
|
|
/// Whether playback waveform and silence analysis is active.
|
|
|
var isAnalyzingAudio: Bool = false
|
|
var isAnalyzingAudio: Bool = false
|
|
|
|
|
|
|
|
// MARK: - Audio Player Private State
|
|
// MARK: - Audio Player Private State
|
|
|
- private var audioPlayer: AVAudioPlayer?
|
|
|
|
|
|
|
+ private var player: AVPlayer?
|
|
|
|
|
+ private var playerItem: AVPlayerItem?
|
|
|
private var activeAudioURL: URL?
|
|
private var activeAudioURL: URL?
|
|
|
|
|
+ private var playerPreparationTask: Task<Void, Never>?
|
|
|
private var audioAnalysisTask: 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
|
|
// MARK: - Computed Properties
|
|
|
|
|
|
|
@@ -71,12 +99,6 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
|
|
|
|
|
|
|
|
// MARK: - Private
|
|
// 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
|
|
// MARK: - Init / Deinit
|
|
|
|
|
|
|
|
override init() {
|
|
override init() {
|
|
@@ -84,46 +106,82 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
deinit {
|
|
deinit {
|
|
|
- playbackTimer?.invalidate()
|
|
|
|
|
|
|
+ playerPreparationTask?.cancel()
|
|
|
audioAnalysisTask?.cancel()
|
|
audioAnalysisTask?.cancel()
|
|
|
- audioPlayer?.stop()
|
|
|
|
|
|
|
+ audioAnalysisSession?.cancel()
|
|
|
|
|
+ removePlayerObservers()
|
|
|
|
|
+ player?.pause()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Player Preparation
|
|
// MARK: - Player Preparation
|
|
|
|
|
|
|
|
/// Prepares the audio player for the given local recording file.
|
|
/// Prepares the audio player for the given local recording file.
|
|
|
func preparePlayer(path: String?, durationMs: Double = 0) {
|
|
func preparePlayer(path: String?, durationMs: Double = 0) {
|
|
|
- stopPlaybackTimer()
|
|
|
|
|
- audioPlayer?.stop()
|
|
|
|
|
- audioPlayer = nil
|
|
|
|
|
|
|
+ playerPreparationTask?.cancel()
|
|
|
|
|
+ removePlayerObservers()
|
|
|
|
|
+ player?.pause()
|
|
|
|
|
+ player = nil
|
|
|
|
|
+ playerItem = nil
|
|
|
activeAudioURL = nil
|
|
activeAudioURL = nil
|
|
|
playbackError = 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 {
|
|
guard let targetURL = AudioPathHelper.resolveURL(for: path) else {
|
|
|
print("[PlaybackViewModel] No valid audio file found for path: \(path ?? "nil")")
|
|
print("[PlaybackViewModel] No valid audio file found for path: \(path ?? "nil")")
|
|
|
playbackError = "未找到本地录音文件,请先从云端同步或重新录制"
|
|
playbackError = "未找到本地录音文件,请先从云端同步或重新录制"
|
|
|
- if durationMs > 0 {
|
|
|
|
|
- self.totalDurationMs = durationMs
|
|
|
|
|
- }
|
|
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
self.activeAudioURL = targetURL
|
|
self.activeAudioURL = targetURL
|
|
|
-
|
|
|
|
|
- setupAudioSession()
|
|
|
|
|
-
|
|
|
|
|
- do {
|
|
|
|
|
- let player = try AVAudioPlayer(contentsOf: targetURL)
|
|
|
|
|
- player.delegate = self
|
|
|
|
|
- player.prepareToPlay()
|
|
|
|
|
- player.volume = 1.0
|
|
|
|
|
- self.audioPlayer = player
|
|
|
|
|
- // The decoded file is the authoritative clock for playback and waveform data.
|
|
|
|
|
- self.totalDurationMs = player.duration.isFinite && player.duration > 0
|
|
|
|
|
- ? player.duration * 1000.0
|
|
|
|
|
- : durationMs
|
|
|
|
|
- } catch {
|
|
|
|
|
- print("[PlaybackViewModel] Failed to initialize AVAudioPlayer: \(error.localizedDescription)")
|
|
|
|
|
- playbackError = "录音文件无法播放:\(error.localizedDescription)"
|
|
|
|
|
|
|
+ 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)"
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -144,29 +202,24 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
|
|
|
/// Starts or resumes playback from the current position.
|
|
/// Starts or resumes playback from the current position.
|
|
|
func play() {
|
|
func play() {
|
|
|
guard !isPlaying else { return }
|
|
guard !isPlaying else { return }
|
|
|
- guard let player = audioPlayer else {
|
|
|
|
|
|
|
+ shouldPlayWhenReady = true
|
|
|
|
|
+ guard isPlaybackReady, player != nil else {
|
|
|
|
|
+ if isPreparingPlayback {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
playbackError = "当前记录没有可播放的本地录音文件"
|
|
playbackError = "当前记录没有可播放的本地录音文件"
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
setupAudioSession()
|
|
setupAudioSession()
|
|
|
-
|
|
|
|
|
- player.currentTime = currentPlaybackTimeMs / 1000.0
|
|
|
|
|
- guard player.play() else {
|
|
|
|
|
- playbackError = "录音播放启动失败"
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- isPlaying = true
|
|
|
|
|
- startPlaybackTimer()
|
|
|
|
|
|
|
+ startPreparedPlayer()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// Pauses playback at the current position.
|
|
/// Pauses playback at the current position.
|
|
|
func pause() {
|
|
func pause() {
|
|
|
- guard isPlaying else { return }
|
|
|
|
|
- audioPlayer?.pause()
|
|
|
|
|
|
|
+ shouldPlayWhenReady = false
|
|
|
|
|
+ player?.pause()
|
|
|
isPlaying = false
|
|
isPlaying = false
|
|
|
- stopPlaybackTimer()
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// Toggles between playing and paused states.
|
|
/// Toggles between playing and paused states.
|
|
@@ -183,8 +236,39 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
|
|
|
func seekTo(timeMs: Double) {
|
|
func seekTo(timeMs: Double) {
|
|
|
let target = min(max(timeMs, 0), totalDurationMs)
|
|
let target = min(max(timeMs, 0), totalDurationMs)
|
|
|
currentPlaybackTimeMs = target
|
|
currentPlaybackTimeMs = target
|
|
|
- if let player = audioPlayer {
|
|
|
|
|
- player.currentTime = target / 1000.0
|
|
|
|
|
|
|
+ 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()
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -206,33 +290,64 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
|
|
|
seekTo(timeMs: 0.0)
|
|
seekTo(timeMs: 0.0)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // MARK: - AVAudioPlayerDelegate
|
|
|
|
|
|
|
+ // MARK: - Private Player Coordination & Audio Analysis
|
|
|
|
|
|
|
|
- func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
|
|
|
|
- isPlaying = false
|
|
|
|
|
- currentPlaybackTimeMs = totalDurationMs
|
|
|
|
|
- stopPlaybackTimer()
|
|
|
|
|
|
|
+ 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)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // MARK: - Private Timer & Silence Detection
|
|
|
|
|
|
|
+ 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()
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- private func startPlaybackTimer() {
|
|
|
|
|
- playbackTimer?.invalidate()
|
|
|
|
|
- playbackTimer = Timer.scheduledTimer(
|
|
|
|
|
- withTimeInterval: tickInterval,
|
|
|
|
|
- repeats: true
|
|
|
|
|
|
|
+ playbackEndObserver = NotificationCenter.default.addObserver(
|
|
|
|
|
+ forName: .AVPlayerItemDidPlayToEndTime,
|
|
|
|
|
+ object: item,
|
|
|
|
|
+ queue: .main
|
|
|
) { [weak self] _ in
|
|
) { [weak self] _ in
|
|
|
guard let self else { return }
|
|
guard let self else { return }
|
|
|
- self.tick()
|
|
|
|
|
- }
|
|
|
|
|
- if let timer = playbackTimer {
|
|
|
|
|
- RunLoop.main.add(timer, forMode: .common)
|
|
|
|
|
|
|
+ self.isPlaying = false
|
|
|
|
|
+ self.shouldPlayWhenReady = false
|
|
|
|
|
+ self.currentPlaybackTimeMs = self.totalDurationMs
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func stopPlaybackTimer() {
|
|
|
|
|
- playbackTimer?.invalidate()
|
|
|
|
|
- playbackTimer = nil
|
|
|
|
|
|
|
+ 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.
|
|
/// Runs silence detection asynchronously.
|
|
@@ -242,11 +357,13 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
|
|
|
|
|
|
|
|
/// Extracts the real waveform and silence ranges in one background pass.
|
|
/// Extracts the real waveform and silence ranges in one background pass.
|
|
|
func analyzeAudio(audioURL: URL?) {
|
|
func analyzeAudio(audioURL: URL?) {
|
|
|
|
|
+ audioAnalysisSession?.cancel()
|
|
|
audioAnalysisTask?.cancel()
|
|
audioAnalysisTask?.cancel()
|
|
|
let targetPath = audioURL?.path ?? activeAudioURL?.path
|
|
let targetPath = audioURL?.path ?? activeAudioURL?.path
|
|
|
guard let url = AudioPathHelper.resolveURL(for: targetPath) else {
|
|
guard let url = AudioPathHelper.resolveURL(for: targetPath) else {
|
|
|
waveformSamples = []
|
|
waveformSamples = []
|
|
|
silentRanges = []
|
|
silentRanges = []
|
|
|
|
|
+ waveformRevision &+= 1
|
|
|
isAnalyzingSilence = false
|
|
isAnalyzingSilence = false
|
|
|
isAnalyzingAudio = false
|
|
isAnalyzingAudio = false
|
|
|
return
|
|
return
|
|
@@ -254,49 +371,87 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
|
|
|
|
|
|
|
|
waveformSamples = []
|
|
waveformSamples = []
|
|
|
silentRanges = []
|
|
silentRanges = []
|
|
|
|
|
+ waveformRevision &+= 1
|
|
|
isAnalyzingSilence = true
|
|
isAnalyzingSilence = true
|
|
|
isAnalyzingAudio = true
|
|
isAnalyzingAudio = true
|
|
|
- audioAnalysisTask = Task { @MainActor [weak self] in
|
|
|
|
|
- let result = await SilenceDetector.analyze(url)
|
|
|
|
|
- guard !Task.isCancelled else { return }
|
|
|
|
|
- guard let self else { return }
|
|
|
|
|
- self.waveformSamples = result.waveformSamples
|
|
|
|
|
- self.silentRanges = result.silentRanges
|
|
|
|
|
- self.isAnalyzingSilence = false
|
|
|
|
|
- self.isAnalyzingAudio = false
|
|
|
|
|
|
|
+ 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() {
|
|
private func checkForSilenceAndSkip() {
|
|
|
- guard isSilenceSkipEnabled && !silentRanges.isEmpty else { return }
|
|
|
|
|
|
|
+ guard isSilenceSkipEnabled,
|
|
|
|
|
+ !silentRanges.isEmpty,
|
|
|
|
|
+ !isSilenceSkipSeekInFlight else {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
let currentTimeSec = currentPlaybackTimeMs / 1000.0
|
|
let currentTimeSec = currentPlaybackTimeMs / 1000.0
|
|
|
for range in silentRanges {
|
|
for range in silentRanges {
|
|
|
if currentTimeSec >= range.start && currentTimeSec < range.end {
|
|
if currentTimeSec >= range.start && currentTimeSec < range.end {
|
|
|
let targetMs = range.end * 1000.0
|
|
let targetMs = range.end * 1000.0
|
|
|
- seekTo(timeMs: targetMs)
|
|
|
|
|
|
|
+ isSilenceSkipSeekInFlight = true
|
|
|
|
|
+ currentPlaybackTimeMs = targetMs
|
|
|
|
|
+ performSeek(to: targetMs) { [weak self] _ in
|
|
|
|
|
+ self?.isSilenceSkipSeekInFlight = false
|
|
|
|
|
+ }
|
|
|
break
|
|
break
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /// Advances playback by one tick interval, syncing with audioPlayer if active.
|
|
|
|
|
- private func tick() {
|
|
|
|
|
- guard let player = audioPlayer, player.isPlaying else {
|
|
|
|
|
- isPlaying = false
|
|
|
|
|
- stopPlaybackTimer()
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
- currentPlaybackTimeMs = player.currentTime * 1000.0
|
|
|
|
|
-
|
|
|
|
|
- checkForSilenceAndSkip()
|
|
|
|
|
-
|
|
|
|
|
- if currentPlaybackTimeMs >= totalDurationMs {
|
|
|
|
|
- currentPlaybackTimeMs = totalDurationMs
|
|
|
|
|
- pause()
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
// MARK: - Event Queries
|
|
// MARK: - Event Queries
|
|
|
|
|
|
|
|
func sortedEvents(from events: [CelestiaTimelineEvent]) -> [CelestiaTimelineEvent] {
|
|
func sortedEvents(from events: [CelestiaTimelineEvent]) -> [CelestiaTimelineEvent] {
|