Răsfoiți Sursa

feat(audio): 升级 AVPlayer 播放引擎、实现 30s 分段渐进波形分析与静音融合

bob.yuxinyang 1 lună în urmă
părinte
comite
f7daaa43c6

+ 436 - 103
CelestiaTrace/Services/Audio/SilenceDetector.swift

@@ -27,6 +27,390 @@ public struct AudioAnalysisResult: Sendable {
     }
 }
 
+/// One progressively decoded section of a recording.
+public struct AudioAnalysisProgress: Sendable {
+    public let totalSampleCount: Int
+    public let sampleOffset: Int
+    public let waveformSamples: [Float]
+    public let silentRanges: [SilenceRange]
+    public let isComplete: Bool
+}
+
+private struct AudioAnalysisCacheEntry: Codable {
+    let version: Int
+    let fileSize: Int64
+    let modificationTime: TimeInterval
+    let waveformSamples: [Float]
+    let silentRanges: [SilenceRange]
+}
+
+/// A cancellable, priority-aware analysis run. The encoded recording stays as
+/// one file; only decoding is divided into logical 60-second sections.
+public final class AudioAnalysisSession: @unchecked Sendable {
+    private static let cacheVersion = 1
+    private static let sampleInterval: TimeInterval = 0.05
+    private static let segmentDuration: TimeInterval = 60
+
+    private let audioURL: URL
+    private let silenceThresholdDB: Float
+    private let minimumSilenceDuration: TimeInterval
+    private let stateLock = NSLock()
+    private var cancelled = false
+    private var prioritizedTime: TimeInterval?
+
+    public init(
+        audioURL: URL,
+        silenceThresholdDB: Float = -40,
+        minimumSilenceDuration: TimeInterval = 2
+    ) {
+        self.audioURL = audioURL
+        self.silenceThresholdDB = silenceThresholdDB
+        self.minimumSilenceDuration = minimumSilenceDuration
+    }
+
+    public func cancel() {
+        stateLock.lock()
+        cancelled = true
+        stateLock.unlock()
+    }
+
+    public func prioritize(time: TimeInterval) {
+        stateLock.lock()
+        prioritizedTime = max(0, time)
+        stateLock.unlock()
+    }
+
+    public func run(
+        onUpdate: @escaping @Sendable (AudioAnalysisProgress) async -> Void
+    ) async {
+        await Task.detached(priority: .utility) { [self] in
+            await runDetached(onUpdate: onUpdate)
+        }.value
+    }
+
+    private func runDetached(
+        onUpdate: @escaping @Sendable (AudioAnalysisProgress) async -> Void
+    ) async {
+        guard let validURL = AudioPathHelper.resolveURL(for: audioURL.path),
+              let identity = Self.fileIdentity(for: validURL) else {
+            return
+        }
+
+        if usesDefaultCacheSettings,
+           let cached = Self.loadCache(for: validURL, identity: identity) {
+            await onUpdate(AudioAnalysisProgress(
+                totalSampleCount: cached.waveformSamples.count,
+                sampleOffset: 0,
+                waveformSamples: cached.waveformSamples,
+                silentRanges: cached.silentRanges,
+                isComplete: true
+            ))
+            return
+        }
+
+        guard let audioFile = try? AVAudioFile(forReading: validURL) else { return }
+        let format = audioFile.processingFormat
+        let sampleRate = format.sampleRate
+        guard sampleRate > 0 else { return }
+
+        let subChunkFrames = max(1, Int64(sampleRate * Self.sampleInterval))
+        let segmentFrames = max(subChunkFrames, Int64(sampleRate * Self.segmentDuration))
+        let totalFrames = audioFile.length
+        let totalSampleCount = Int((totalFrames + subChunkFrames - 1) / subChunkFrames)
+        let totalSegmentCount = max(1, Int((totalFrames + segmentFrames - 1) / segmentFrames))
+
+        var completedSegments = Set<Int>()
+        var completeWaveform = Array(repeating: Float(0), count: totalSampleCount)
+        var rawSilentRanges: [SilenceRange] = []
+
+        while completedSegments.count < totalSegmentCount {
+            if isCancelled || Task.isCancelled { return }
+            let segmentIndex = nextSegmentIndex(
+                totalSegmentCount: totalSegmentCount,
+                completedSegments: completedSegments
+            )
+            guard let segmentIndex else { break }
+
+            let startFrame = Int64(segmentIndex) * segmentFrames
+            let endFrame = min(startFrame + segmentFrames, totalFrames)
+            guard let segment = Self.analyzeSegment(
+                audioFile: audioFile,
+                format: format,
+                sampleRate: sampleRate,
+                startFrame: startFrame,
+                endFrame: endFrame,
+                subChunkFrames: subChunkFrames,
+                silenceThresholdDB: silenceThresholdDB
+            ) else {
+                return
+            }
+
+            completedSegments.insert(segmentIndex)
+            let sampleOffset = Int(startFrame / subChunkFrames)
+            let upperBound = min(sampleOffset + segment.waveformSamples.count, completeWaveform.count)
+            if sampleOffset < upperBound {
+                completeWaveform.replaceSubrange(
+                    sampleOffset..<upperBound,
+                    with: segment.waveformSamples.prefix(upperBound - sampleOffset)
+                )
+            }
+            rawSilentRanges.append(contentsOf: segment.rawSilentRanges)
+            let mergedSilence = SilenceDetector.mergeSilentRanges(
+                rawSilentRanges,
+                minimumDuration: minimumSilenceDuration
+            )
+            let isComplete = completedSegments.count == totalSegmentCount
+
+            await onUpdate(AudioAnalysisProgress(
+                totalSampleCount: totalSampleCount,
+                sampleOffset: sampleOffset,
+                waveformSamples: segment.waveformSamples,
+                silentRanges: mergedSilence,
+                isComplete: isComplete
+            ))
+            await Task.yield()
+
+            if isComplete, usesDefaultCacheSettings {
+                Self.saveCache(
+                    AudioAnalysisCacheEntry(
+                        version: Self.cacheVersion,
+                        fileSize: identity.fileSize,
+                        modificationTime: identity.modificationTime,
+                        waveformSamples: completeWaveform,
+                        silentRanges: mergedSilence
+                    ),
+                    for: validURL
+                )
+            }
+        }
+    }
+
+    private var isCancelled: Bool {
+        stateLock.lock()
+        defer { stateLock.unlock() }
+        return cancelled
+    }
+
+    private var usesDefaultCacheSettings: Bool {
+        silenceThresholdDB == -40 && minimumSilenceDuration == 2
+    }
+
+    private func nextSegmentIndex(
+        totalSegmentCount: Int,
+        completedSegments: Set<Int>
+    ) -> Int? {
+        stateLock.lock()
+        let preferredTime = prioritizedTime
+        prioritizedTime = nil
+        stateLock.unlock()
+
+        if let preferredTime {
+            let preferredIndex = min(
+                max(Int(preferredTime / Self.segmentDuration), 0),
+                totalSegmentCount - 1
+            )
+            if !completedSegments.contains(preferredIndex) {
+                return preferredIndex
+            }
+            // After the requested section, favor its immediate neighbors.
+            for distance in 1..<totalSegmentCount {
+                let forward = preferredIndex + distance
+                if forward < totalSegmentCount, !completedSegments.contains(forward) {
+                    return forward
+                }
+                let backward = preferredIndex - distance
+                if backward >= 0, !completedSegments.contains(backward) {
+                    return backward
+                }
+            }
+        }
+        return (0..<totalSegmentCount).first { !completedSegments.contains($0) }
+    }
+
+    private struct SegmentResult {
+        let waveformSamples: [Float]
+        let rawSilentRanges: [SilenceRange]
+    }
+
+    private static func analyzeSegment(
+        audioFile: AVAudioFile,
+        format: AVAudioFormat,
+        sampleRate: Double,
+        startFrame: Int64,
+        endFrame: Int64,
+        subChunkFrames: Int64,
+        silenceThresholdDB: Float
+    ) -> SegmentResult? {
+        let oneSecondFrames = max(1, AVAudioFrameCount(sampleRate))
+        guard let buffer = AVAudioPCMBuffer(
+            pcmFormat: format,
+            frameCapacity: oneSecondFrames
+        ) else {
+            return nil
+        }
+
+        audioFile.framePosition = startFrame
+        var waveformSamples: [Float] = []
+        waveformSamples.reserveCapacity(Int((endFrame - startFrame + subChunkFrames - 1) / subChunkFrames))
+        var rawSilentRanges: [SilenceRange] = []
+        var silenceStart: TimeInterval?
+
+        do {
+            while audioFile.framePosition < endFrame {
+                if Task.isCancelled { return nil }
+                let remainingFrames = endFrame - audioFile.framePosition
+                let framesToRead = AVAudioFrameCount(
+                    min(Int64(oneSecondFrames), remainingFrames)
+                )
+                guard framesToRead > 0 else { break }
+                try audioFile.read(into: buffer, frameCount: framesToRead)
+                guard let channelData = buffer.floatChannelData?[0] else { continue }
+
+                let bufferStartFrame = audioFile.framePosition - Int64(buffer.frameLength)
+                var offset = 0
+                while offset < Int(buffer.frameLength) {
+                    let sampleCount = min(Int(subChunkFrames), Int(buffer.frameLength) - offset)
+                    guard sampleCount > 0 else { break }
+                    var sum: Float = 0
+                    for index in 0..<sampleCount {
+                        let sample = channelData[offset + index]
+                        sum += sample * sample
+                    }
+
+                    let rms = sqrt(sum / Float(sampleCount))
+                    let decibels = rms > 0 ? 20 * log10(rms) : -100
+                    waveformSamples.append(
+                        AudioLevelNormalizer.normalizedLevel(decibels: decibels)
+                    )
+
+                    let absoluteFrame = bufferStartFrame + Int64(offset)
+                    let time = Double(absoluteFrame) / sampleRate
+                    if decibels < silenceThresholdDB {
+                        if silenceStart == nil {
+                            silenceStart = time
+                        }
+                    } else if let start = silenceStart {
+                        rawSilentRanges.append(SilenceRange(start: start, end: time))
+                        silenceStart = nil
+                    }
+                    offset += sampleCount
+                }
+            }
+        } catch {
+            print("[SilenceDetector] Error reading audio segment: \(error.localizedDescription)")
+            return nil
+        }
+
+        if let start = silenceStart {
+            rawSilentRanges.append(
+                SilenceRange(start: start, end: Double(endFrame) / sampleRate)
+            )
+        }
+        return SegmentResult(
+            waveformSamples: waveformSamples,
+            rawSilentRanges: rawSilentRanges
+        )
+    }
+
+    private struct FileIdentity {
+        let fileSize: Int64
+        let modificationTime: TimeInterval
+    }
+
+    private static func fileIdentity(for url: URL) -> FileIdentity? {
+        guard let values = try? url.resourceValues(
+            forKeys: [.fileSizeKey, .contentModificationDateKey]
+        ) else {
+            return nil
+        }
+        return FileIdentity(
+            fileSize: Int64(values.fileSize ?? 0),
+            modificationTime: values.contentModificationDate?.timeIntervalSince1970 ?? 0
+        )
+    }
+
+    private static func cacheURL(for audioURL: URL) -> URL? {
+        guard let cacheRoot = FileManager.default.urls(
+            for: .cachesDirectory,
+            in: .userDomainMask
+        ).first else {
+            return nil
+        }
+        let directory = cacheRoot
+            .appendingPathComponent("CelestiaTrace", isDirectory: true)
+            .appendingPathComponent("AudioAnalysis", isDirectory: true)
+        try? FileManager.default.createDirectory(
+            at: directory,
+            withIntermediateDirectories: true
+        )
+
+        var hash: UInt64 = 1_469_598_103_934_665_603
+        // Persist across app-container path changes by keying on the stable
+        // recording filename; size and modification time validate the content.
+        for byte in audioURL.lastPathComponent.utf8 {
+            hash ^= UInt64(byte)
+            hash &*= 1_099_511_628_211
+        }
+        return directory.appendingPathComponent(String(hash, radix: 16) + ".plist")
+    }
+
+    private static func loadCache(
+        for audioURL: URL,
+        identity: FileIdentity
+    ) -> AudioAnalysisCacheEntry? {
+        guard let cacheURL = cacheURL(for: audioURL),
+              let data = try? Data(contentsOf: cacheURL),
+              let entry = try? PropertyListDecoder().decode(
+                AudioAnalysisCacheEntry.self,
+                from: data
+              ),
+              entry.version == cacheVersion,
+              entry.fileSize == identity.fileSize,
+              abs(entry.modificationTime - identity.modificationTime) < 0.001 else {
+            return nil
+        }
+        return entry
+    }
+
+    private static func saveCache(
+        _ entry: AudioAnalysisCacheEntry,
+        for audioURL: URL
+    ) {
+        guard let cacheURL = cacheURL(for: audioURL) else { return }
+        let encoder = PropertyListEncoder()
+        encoder.outputFormat = .binary
+        guard let data = try? encoder.encode(entry) else { return }
+        try? data.write(to: cacheURL, options: .atomic)
+    }
+}
+
+private actor AudioAnalysisCollector {
+    private var waveformSamples: [Float] = []
+    private var silentRanges: [SilenceRange] = []
+
+    func apply(_ 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
+    }
+
+    func result() -> AudioAnalysisResult {
+        AudioAnalysisResult(
+            waveformSamples: waveformSamples,
+            silentRanges: silentRanges
+        )
+    }
+}
+
 public final class SilenceDetector: Sendable {
     /// Detects silent ranges in the specified audio file.
     /// - Parameters:
@@ -53,113 +437,62 @@ public final class SilenceDetector: Sendable {
         silenceThresholdDB: Float = -40.0,
         minimumSilenceDuration: TimeInterval = 2.0
     ) async -> AudioAnalysisResult {
-        guard let validURL = AudioPathHelper.resolveURL(for: audioURL.path) else {
-            print("[SilenceDetector] File not found or empty path: \(audioURL.path).")
-            return AudioAnalysisResult(waveformSamples: [], silentRanges: [])
-        }
-        
-        let task = Task.detached(priority: .userInitiated) { () -> AudioAnalysisResult in
-            guard let audioFile = try? AVAudioFile(forReading: validURL) else {
-                return AudioAnalysisResult(waveformSamples: [], silentRanges: [])
-            }
-            
-            let format = audioFile.processingFormat
-            let sampleRate = format.sampleRate
-            guard sampleRate > 0 else {
-                return AudioAnalysisResult(waveformSamples: [], silentRanges: [])
-            }
-            
-            // Buffer size of ~1.0 second
-            let bufferSize = AVAudioFrameCount(sampleRate)
-            guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: bufferSize) else {
-                return AudioAnalysisResult(waveformSamples: [], silentRanges: [])
+        let collector = AudioAnalysisCollector()
+        let session = AudioAnalysisSession(
+            audioURL: audioURL,
+            silenceThresholdDB: silenceThresholdDB,
+            minimumSilenceDuration: minimumSilenceDuration
+        )
+        await withTaskCancellationHandler {
+            await session.run { update in
+                await collector.apply(update)
             }
-            
-            var waveformSamples: [Float] = []
-            waveformSamples.reserveCapacity(Int(Double(audioFile.length) / sampleRate / 0.05) + 1)
-            var silentRanges: [SilenceRange] = []
-            var isSilent = false
-            var silenceStart: TimeInterval = 0
-            
-            // Match RealAudioRecorder's 50ms metering cadence.
-            let subChunkSize = max(1, Int(sampleRate * 0.05))
-            
-            do {
-                while audioFile.framePosition < audioFile.length {
-                    if Task.isCancelled {
-                        return AudioAnalysisResult(waveformSamples: [], silentRanges: [])
-                    }
+        } onCancel: {
+            session.cancel()
+        }
+        return await collector.result()
+    }
 
-                    let framesToRead = min(bufferSize, AVAudioFrameCount(audioFile.length - audioFile.framePosition))
-                    if framesToRead <= 0 { break }
-                    
-                    try audioFile.read(into: buffer, frameCount: framesToRead)
-                    
-                    guard let floatChannelData = buffer.floatChannelData else { continue }
-                    let channelData = floatChannelData[0]
-                    let frameLength = Int(buffer.frameLength)
-                    
-                    var offset = 0
-                    while offset < frameLength {
-                        let currentSubChunkSize = min(subChunkSize, frameLength - offset)
-                        if currentSubChunkSize <= 0 { break }
-                        
-                        var sum: Float = 0
-                        for i in 0..<currentSubChunkSize {
-                            let sample = channelData[offset + i]
-                            sum += sample * sample
-                        }
-                        
-                        let rms = sqrt(sum / Float(currentSubChunkSize))
-                        let db = rms > 0 ? 20 * log10(rms) : -100.0
-                        waveformSamples.append(
-                            AudioLevelNormalizer.normalizedLevel(decibels: db)
-                        )
-                        
-                        // Calculate absolute time of this sub-chunk
-                        let absoluteFramePosition = audioFile.framePosition - Int64(frameLength) + Int64(offset)
-                        let timeInSeconds = Double(absoluteFramePosition) / sampleRate
-                        
-                        let isChunkSilent = db < silenceThresholdDB
-                        
-                        if isChunkSilent {
-                            if !isSilent {
-                                isSilent = true
-                                silenceStart = timeInSeconds
-                            }
-                        } else {
-                            if isSilent {
-                                isSilent = false
-                                let silenceEnd = timeInSeconds
-                                if silenceEnd - silenceStart >= minimumSilenceDuration {
-                                    silentRanges.append(SilenceRange(start: silenceStart, end: silenceEnd))
-                                }
-                            }
-                        }
-                        
-                        offset += subChunkSize
-                    }
-                }
-                
-                // If still silent at the end of the file
-                if isSilent {
-                    let silenceEnd = Double(audioFile.length) / sampleRate
-                    if silenceEnd - silenceStart >= minimumSilenceDuration {
-                        silentRanges.append(SilenceRange(start: silenceStart, end: silenceEnd))
-                    }
+    /// Warms the derived cache without retaining waveform data in a view model.
+    public static func warmCache(for audioURL: URL) async {
+        let session = AudioAnalysisSession(audioURL: audioURL)
+        await withTaskCancellationHandler {
+            await session.run { _ in }
+        } onCancel: {
+            session.cancel()
+        }
+    }
+
+    /// Merges silence that crosses a logical segment boundary, then applies the
+    /// product's two-second minimum. Kept internal for focused logic tests.
+    static func mergeSilentRanges(
+        _ ranges: [SilenceRange],
+        minimumDuration: TimeInterval = 2
+    ) -> [SilenceRange] {
+        let sorted = ranges.sorted {
+            if $0.start == $1.start {
+                return $0.end < $1.end
+            }
+            return $0.start < $1.start
+        }
+        guard var current = sorted.first else { return [] }
+        var merged: [SilenceRange] = []
+        for next in sorted.dropFirst() {
+            if next.start <= current.end + 0.001 {
+                current = SilenceRange(
+                    start: current.start,
+                    end: max(current.end, next.end)
+                )
+            } else {
+                if current.duration >= minimumDuration {
+                    merged.append(current)
                 }
-            } catch {
-                print("[SilenceDetector] Error reading audio file: \(error.localizedDescription)")
+                current = next
             }
-            
-            print(
-                "[SilenceDetector] Analysis complete. Generated \(waveformSamples.count) waveform samples and found \(silentRanges.count) silence ranges."
-            )
-            return AudioAnalysisResult(
-                waveformSamples: waveformSamples,
-                silentRanges: silentRanges
-            )
         }
-        return await task.value
+        if current.duration >= minimumDuration {
+            merged.append(current)
+        }
+        return merged
     }
 }

+ 248 - 93
CelestiaTrace/ViewModels/PlaybackViewModel.swift

@@ -2,12 +2,20 @@ 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 AVAudioPlayer for real audio output.
+/// for session detail/review screens using asynchronously prepared AVPlayer media.
 @Observable
-final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
+final class PlaybackViewModel: NSObject {
 
     // MARK: - Playback State
 
@@ -20,6 +28,15 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
     /// 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
     
@@ -37,13 +54,24 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
     /// 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 audioPlayer: AVAudioPlayer?
+    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
 
@@ -71,12 +99,6 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
 
     // 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() {
@@ -84,46 +106,82 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
     }
 
     deinit {
-        playbackTimer?.invalidate()
+        playerPreparationTask?.cancel()
         audioAnalysisTask?.cancel()
-        audioPlayer?.stop()
+        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) {
-        stopPlaybackTimer()
-        audioPlayer?.stop()
-        audioPlayer = nil
+        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 = "未找到本地录音文件,请先从云端同步或重新录制"
-            if durationMs > 0 {
-                self.totalDurationMs = durationMs
-            }
             return
         }
         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.
     func play() {
         guard !isPlaying else { return }
-        guard let player = audioPlayer else {
+        shouldPlayWhenReady = true
+        guard isPlaybackReady, player != nil else {
+            if isPreparingPlayback {
+                return
+            }
             playbackError = "当前记录没有可播放的本地录音文件"
             return
         }
 
         setupAudioSession()
-
-        player.currentTime = currentPlaybackTimeMs / 1000.0
-        guard player.play() else {
-            playbackError = "录音播放启动失败"
-            return
-        }
-
-        isPlaying = true
-        startPlaybackTimer()
+        startPreparedPlayer()
     }
 
     /// Pauses playback at the current position.
     func pause() {
-        guard isPlaying else { return }
-        audioPlayer?.pause()
+        shouldPlayWhenReady = false
+        player?.pause()
         isPlaying = false
-        stopPlaybackTimer()
     }
 
     /// Toggles between playing and paused states.
@@ -183,8 +236,39 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
     func seekTo(timeMs: Double) {
         let target = min(max(timeMs, 0), totalDurationMs)
         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)
     }
 
-    // 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
             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.
@@ -242,11 +357,13 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
 
     /// 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
@@ -254,49 +371,87 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
         
         waveformSamples = []
         silentRanges = []
+        waveformRevision &+= 1
         isAnalyzingSilence = 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() {
-        guard isSilenceSkipEnabled && !silentRanges.isEmpty else { return }
+        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
-                seekTo(timeMs: targetMs)
+                isSilenceSkipSeekInFlight = true
+                currentPlaybackTimeMs = targetMs
+                performSeek(to: targetMs) { [weak self] _ in
+                    self?.isSilenceSkipSeekInFlight = false
+                }
                 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
 
     func sortedEvents(from events: [CelestiaTimelineEvent]) -> [CelestiaTimelineEvent] {

+ 137 - 64
CelestiaTrace/Views/Detail/MultiTrackTimeline.swift

@@ -24,22 +24,33 @@ struct MultiTrackTimeline: View {
     @Binding var currentTimeMs: Double
     let totalDurationMs: Double
     var waveformSamples: [Float] = []
+    var waveformRevision: Int = 0
     var silentRanges: [SilenceRange] = []
     var onEmptyTrackTap: ((EditableTrack, Double) -> Void)?
     var onEventTap: ((CelestiaTimelineEvent) -> Void)?
+    var onScrubBegan: (() -> Void)?
+    var onScrubEnded: ((Double) -> Void)?
 
     @State private var playheadDragStartTimeMs: Double?
+    @State private var lastAutoScrollTimeMs = -Double.greatestFiniteMagnitude
+    @State private var viewportWidth: CGFloat = 0
+    @State private var zoomScale: CGFloat = 1
+    @GestureState private var gestureMagnification: CGFloat = 1
 
     /// Scale: points per second
     private let pointsPerSecond: CGFloat = 2.5
-    /// Minimum timeline width
-    private let minimumWidth: CGFloat = 400
+    private let maximumZoomScale: CGFloat = 8
     /// Matches the live-recording waveform height.
     private let audioTrackHeight: CGFloat = 90
     private let eventTrackHeight: CGFloat = 34
 
     private var timelineWidth: CGFloat {
-        max(minimumWidth, CGFloat(totalDurationMs / 1000.0) * pointsPerSecond + 60)
+        let durationWidth = CGFloat(totalDurationMs / 1000.0) * pointsPerSecond + 60
+        return max(viewportWidth, durationWidth) * effectiveZoomScale
+    }
+
+    private var effectiveZoomScale: CGFloat {
+        min(max(zoomScale * gestureMagnification, 1), maximumZoomScale)
     }
 
     var body: some View {
@@ -85,8 +96,26 @@ struct MultiTrackTimeline: View {
                 }
                 .clipShape(RoundedRectangle(cornerRadius: 10))
                 .businessBorder(cornerRadius: 10)
-                .onChange(of: currentTimeMs) { _, _ in
+                .background {
+                    GeometryReader { geometry in
+                        Color.clear
+                            .onAppear {
+                                viewportWidth = geometry.size.width
+                            }
+                            .onChange(of: geometry.size.width) { _, newWidth in
+                                viewportWidth = newWidth
+                            }
+                    }
+                }
+                .simultaneousGesture(timelineMagnificationGesture)
+                .onChange(of: currentTimeMs) { _, newValue in
                     guard playheadDragStartTimeMs == nil else { return }
+                    guard abs(newValue - lastAutoScrollTimeMs) >= 750
+                            || newValue <= 0
+                            || newValue >= totalDurationMs else {
+                        return
+                    }
+                    lastAutoScrollTimeMs = newValue
                     withAnimation(.easeOut(duration: 0.2)) {
                         scrollProxy.scrollTo("playhead", anchor: .center)
                     }
@@ -99,6 +128,19 @@ struct MultiTrackTimeline: View {
         }
     }
 
+    private var timelineMagnificationGesture: some Gesture {
+        MagnifyGesture()
+            .updating($gestureMagnification) { value, state, _ in
+                state = value.magnification
+            }
+            .onEnded { value in
+                zoomScale = min(
+                    max(zoomScale * value.magnification, 1),
+                    maximumZoomScale
+                )
+            }
+    }
+
     // MARK: - Time Ruler
 
     private var timeRuler: some View {
@@ -141,66 +183,13 @@ struct MultiTrackTimeline: View {
 
     private var audioTrack: some View {
         ZStack(alignment: .leading) {
-            Canvas { context, size in
-                let centerY = size.height / 2
-                let barWidth: CGFloat = 1
-                let barSpacing: CGFloat = 2
-                let barSlot = barWidth + barSpacing
-                let startX = xPosition(for: 0)
-                let endX = xPosition(for: totalDurationMs)
-                let waveformWidth = max(0, endX - startX)
-                let barCount = max(0, Int(waveformWidth / barSlot))
-
-                for range in silentRanges {
-                    let rangeStart = max(startX, xPosition(for: range.start * 1000))
-                    let rangeEnd = min(endX, xPosition(for: range.end * 1000))
-                    guard rangeEnd > rangeStart else { continue }
-                    context.fill(
-                        Path(CGRect(
-                            x: rangeStart,
-                            y: 0,
-                            width: rangeEnd - rangeStart,
-                            height: size.height
-                        )),
-                        with: .color(Color.secondary.opacity(0.10))
-                    )
-                }
-
-                let centerLine = Path { path in
-                    path.move(to: CGPoint(x: startX, y: centerY))
-                    path.addLine(to: CGPoint(x: endX, y: centerY))
-                }
-                context.stroke(
-                    centerLine,
-                    with: .color(Color.primary.opacity(0.1)),
-                    lineWidth: 1
-                )
-
-                guard barCount > 0, !waveformSamples.isEmpty else { return }
-
-                for index in 0..<barCount {
-                    let sampleStart = index * waveformSamples.count / barCount
-                    let sampleEnd = max(
-                        sampleStart + 1,
-                        (index + 1) * waveformSamples.count / barCount
-                    )
-                    let upperBound = min(sampleEnd, waveformSamples.count)
-                    guard sampleStart < upperBound else { continue }
-
-                    let amplitude = waveformSamples[sampleStart..<upperBound].max() ?? 0
-                    let barHeight = max(CGFloat(max(amplitude, 0)) * size.height * 0.75, 1)
-                    let rect = CGRect(
-                        x: startX + CGFloat(index) * barSlot,
-                        y: centerY - barHeight / 2,
-                        width: barWidth,
-                        height: barHeight
-                    )
-                    context.fill(
-                        Path(rect),
-                        with: .color(Color.primary.opacity(0.85))
-                    )
-                }
-            }
+            PlaybackWaveformCanvas(
+                totalDurationMs: totalDurationMs,
+                waveformSamples: waveformSamples,
+                silentRanges: silentRanges,
+                revision: waveformRevision
+            )
+            .equatable()
 
             trackLeadingIcon(systemName: "waveform", accessibilityLabel: "音频轨道")
 
@@ -359,6 +348,7 @@ struct MultiTrackTimeline: View {
             .accessibilityValue(formattedTime(currentTimeMs))
             .accessibilityAdjustableAction { direction in
                 let stepMs = 1_000.0
+                onScrubBegan?()
                 switch direction {
                 case .increment:
                     currentTimeMs = min(totalDurationMs, currentTimeMs + stepMs)
@@ -367,6 +357,7 @@ struct MultiTrackTimeline: View {
                 @unknown default:
                     break
                 }
+                onScrubEnded?(currentTimeMs)
             }
     }
 
@@ -376,6 +367,7 @@ struct MultiTrackTimeline: View {
                 let startTime = playheadDragStartTimeMs ?? currentTimeMs
                 if playheadDragStartTimeMs == nil {
                     playheadDragStartTimeMs = startTime
+                    onScrubBegan?()
                 }
 
                 let startX = xPosition(for: startTime)
@@ -383,6 +375,7 @@ struct MultiTrackTimeline: View {
             }
             .onEnded { _ in
                 playheadDragStartTimeMs = nil
+                onScrubEnded?(currentTimeMs)
             }
     }
 
@@ -496,6 +489,86 @@ struct MultiTrackTimeline: View {
     }
 }
 
+/// The expensive waveform aggregation is isolated from the frequently moving
+/// playhead. Its equality check uses the view-model revision instead of
+/// comparing every sample on each 100ms playback update.
+private struct PlaybackWaveformCanvas: View, Equatable {
+    let totalDurationMs: Double
+    let waveformSamples: [Float]
+    let silentRanges: [SilenceRange]
+    let revision: Int
+
+    static func == (lhs: Self, rhs: Self) -> Bool {
+        lhs.revision == rhs.revision
+            && lhs.totalDurationMs == rhs.totalDurationMs
+    }
+
+    var body: some View {
+        Canvas { context, size in
+            let centerY = size.height / 2
+            let barWidth: CGFloat = 1
+            let barSpacing: CGFloat = 2
+            let barSlot = barWidth + barSpacing
+            let startX: CGFloat = 30
+            let endX = max(startX, size.width - 30)
+            let waveformWidth = max(0, endX - startX)
+            let barCount = max(0, Int(waveformWidth / barSlot))
+
+            for range in silentRanges {
+                guard totalDurationMs > 0 else { continue }
+                let rangeStart = startX + waveformWidth
+                    * CGFloat(max(0, range.start * 1_000) / totalDurationMs)
+                let rangeEnd = startX + waveformWidth
+                    * CGFloat(min(totalDurationMs, range.end * 1_000) / totalDurationMs)
+                guard rangeEnd > rangeStart else { continue }
+                context.fill(
+                    Path(CGRect(
+                        x: rangeStart,
+                        y: 0,
+                        width: rangeEnd - rangeStart,
+                        height: size.height
+                    )),
+                    with: .color(Color.secondary.opacity(0.10))
+                )
+            }
+
+            let centerLine = Path { path in
+                path.move(to: CGPoint(x: startX, y: centerY))
+                path.addLine(to: CGPoint(x: endX, y: centerY))
+            }
+            context.stroke(
+                centerLine,
+                with: .color(Color.primary.opacity(0.1)),
+                lineWidth: 1
+            )
+
+            guard barCount > 0, !waveformSamples.isEmpty else { return }
+            for index in 0..<barCount {
+                let sampleStart = index * waveformSamples.count / barCount
+                let sampleEnd = max(
+                    sampleStart + 1,
+                    (index + 1) * waveformSamples.count / barCount
+                )
+                let upperBound = min(sampleEnd, waveformSamples.count)
+                guard sampleStart < upperBound else { continue }
+
+                let amplitude = waveformSamples[sampleStart..<upperBound].max() ?? 0
+                let barHeight = max(CGFloat(max(amplitude, 0)) * size.height * 0.75, 1)
+                let rect = CGRect(
+                    x: startX + CGFloat(index) * barSlot,
+                    y: centerY - barHeight / 2,
+                    width: barWidth,
+                    height: barHeight
+                )
+                context.fill(
+                    Path(rect),
+                    with: .color(Color.primary.opacity(0.85))
+                )
+            }
+        }
+    }
+}
+
 // MARK: - Preview
 
 #Preview {

+ 38 - 0
Tests/CelestiaTraceTests/BLEFilteringTests.swift

@@ -23,3 +23,41 @@ final class BLEFilteringTests: XCTestCase {
         XCTAssertFalse(BLEManager.isSparkAdvertisement(name: "Living Room TV", serviceUUIDs: []))
     }
 }
+
+final class SegmentedAudioAnalysisTests: XCTestCase {
+    func testSilenceCrossingSegmentBoundaryIsMerged() {
+        let ranges = [
+            SilenceRange(start: 58.5, end: 60),
+            SilenceRange(start: 60, end: 61.5)
+        ]
+
+        XCTAssertEqual(
+            SilenceDetector.mergeSilentRanges(ranges),
+            [SilenceRange(start: 58.5, end: 61.5)]
+        )
+    }
+
+    func testShortIsolatedSilenceIsDiscarded() {
+        let ranges = [
+            SilenceRange(start: 10, end: 11.5),
+            SilenceRange(start: 20, end: 22)
+        ]
+
+        XCTAssertEqual(
+            SilenceDetector.mergeSilentRanges(ranges),
+            [SilenceRange(start: 20, end: 22)]
+        )
+    }
+
+    func testDistinctSilenceRangesStaySeparate() {
+        let ranges = [
+            SilenceRange(start: 8, end: 11),
+            SilenceRange(start: 11.05, end: 14)
+        ]
+
+        XCTAssertEqual(
+            SilenceDetector.mergeSilentRanges(ranges),
+            ranges
+        )
+    }
+}