3 Commits b2104bcdf9 ... 7b6b5fb5aa

Auteur SHA1 Message Date
  bob.yuxinyang 7b6b5fb5aa style(ui): 简化实时活动卡片文案、优化空状态与登录弹窗说明 il y a 1 mois
  bob.yuxinyang 62991be2e1 feat(sync): 实现两阶段轻量级云端同步协议索引 (SessionSyncIndex) il y a 1 mois
  bob.yuxinyang f7daaa43c6 feat(audio): 升级 AVPlayer 播放引擎、实现 30s 分段渐进波形分析与静音融合 il y a 1 mois

+ 4 - 0
CelestiaTrace/Models/CelestiaSession.swift

@@ -15,6 +15,9 @@ final class CelestiaSession {
     // existing rows during a lightweight migration. Initializer defaults only
     // apply to newly-created model instances.
     var serverRevision: Int64 = 0
+    // Timestamp of the newest server detail whose metadata and assets have
+    // both been fully applied locally. Nil forces one initial detail refresh.
+    var serverUpdatedAt: Date?
     var syncStateRaw: String = SessionSyncState.localOnly.rawValue
     var lastSyncError: String?
     var lastSyncedAt: Date?
@@ -94,6 +97,7 @@ final class CelestiaSession {
         self.cloudSessionId = nil
         self.ownerUserID = nil
         self.serverRevision = 0
+        self.serverUpdatedAt = nil
         self.syncStateRaw = SessionSyncState.localOnly.rawValue
         self.lastSyncError = nil
         self.lastSyncedAt = nil

+ 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
     }
 }

+ 7 - 1
CelestiaTrace/Services/Network/APIClient.swift

@@ -140,7 +140,11 @@ actor APIClient {
             // offline or TLS negotiation fails instead of leaving the UI waiting.
             configuration.waitsForConnectivity = false
             configuration.timeoutIntervalForRequest = 15
-            configuration.timeoutIntervalForResource = 60
+            // A resource transfer may legitimately take much longer than a normal
+            // API request, especially for recordings on a slow mobile connection.
+            // Keep the short inactivity timeout above, but do not impose a
+            // one-minute absolute deadline on the complete upload/download.
+            configuration.timeoutIntervalForResource = 24 * 60 * 60
             self.session = URLSession(configuration: configuration)
         }
         self.tokenStore = tokenStore
@@ -357,6 +361,8 @@ actor APIClient {
         let response: URLResponse
         do {
             (temporaryURL, response) = try await session.download(for: request)
+        } catch let error as URLError where error.code == .timedOut {
+            throw APIError.transport("云端文件下载超时,请检查网络稳定性后重试")
         } catch {
             throw APIError.transport(error.localizedDescription)
         }

+ 2 - 1
CelestiaTrace/Services/Network/NetworkServiceProtocol.swift

@@ -1,7 +1,8 @@
 import Foundation
 
 protocol NetworkServiceProtocol {
-    func fetchSessions() async throws -> [RemoteSession]
+    func fetchSessionIndex() async throws -> [RemoteSessionIndex]
+    func fetchSession(sessionID: String) async throws -> RemoteSession
     func syncSession(
         _ session: CelestiaSession,
         deletedEventClientIDs: [String]

+ 531 - 48
CelestiaTrace/Services/Network/RemoteNetworkService.swift

@@ -6,7 +6,7 @@ import CryptoKit
 
 // Remote contract: Docs/RemoteAPI.openapi.yaml
 
-struct RemoteAsset: Decodable {
+struct RemoteAsset: Decodable, Sendable {
     let id: String
     let clientId: String
     let kind: String
@@ -42,6 +42,20 @@ struct RemoteEvent: Decodable {
     let longitude: Double?
 }
 
+struct RemoteSessionIndex: Decodable {
+    let id: String
+    let clientId: String?
+    let title: String
+    let startTime: Date
+    let endTime: Date?
+    let durationMs: Int64
+    let photoCount: Int
+    let noteCount: Int
+    let revision: Int64
+    let deletedAt: Date?
+    let updatedAt: Date
+}
+
 struct RemoteSession: Decodable {
     let id: String
     let clientId: String?
@@ -51,6 +65,7 @@ struct RemoteSession: Decodable {
     let durationMs: Int64
     let revision: Int64
     let deletedAt: Date?
+    let updatedAt: Date
     let events: [RemoteEvent]?
     let assets: [RemoteAsset]?
 }
@@ -138,10 +153,14 @@ final class RemoteNetworkService: NetworkServiceProtocol {
         self.client = client
     }
 
-    func fetchSessions() async throws -> [RemoteSession] {
+    func fetchSessionIndex() async throws -> [RemoteSessionIndex] {
         try await client.request("sessions?includeDeleted=true", authenticated: true)
     }
 
+    func fetchSession(sessionID: String) async throws -> RemoteSession {
+        try await client.request("sessions/\(sessionID)", authenticated: true)
+    }
+
     func syncSession(
         _ session: CelestiaSession,
         deletedEventClientIDs: [String]
@@ -431,6 +450,7 @@ final class NetworkStatusMonitor: ObservableObject, @unchecked Sendable {
 @MainActor
 final class SyncManager: ObservableObject {
     static let shared = SyncManager()
+    private static let maxConcurrentAssetDownloads = 4
 
     @Published private(set) var isSyncing = false
     @Published private(set) var lastSyncDate: Date?
@@ -444,6 +464,33 @@ final class SyncManager: ObservableObject {
     private var activeSyncTask: Task<Bool, Never>?
     private var automaticSyncTasks: [UUID: Task<Void, Never>] = [:]
     private var automaticSyncGenerations: [UUID: UUID] = [:]
+    private var queuedSyncRequest: SyncRequest?
+    private static let diagnosticsByteFormatter: ByteCountFormatter = {
+        let formatter = ByteCountFormatter()
+        formatter.countStyle = .file
+        formatter.allowedUnits = [.useKB, .useMB, .useGB]
+        formatter.isAdaptive = true
+        return formatter
+    }()
+
+    private struct SyncRequest {
+        let sessions: [CelestiaSession]
+        let modelContext: ModelContext
+        let userID: String
+    }
+
+    private enum AssetDownloadTarget: Sendable {
+        case audio
+        case photo(eventID: UUID)
+    }
+
+    private struct AssetDownloadJob: Sendable {
+        let sessionID: String
+        let asset: RemoteAsset
+        let destination: URL
+        let target: AssetDownloadTarget
+        let displayName: String
+    }
 
     init(service: NetworkServiceProtocol = RemoteNetworkService()) {
         self.service = service
@@ -518,10 +565,17 @@ final class SyncManager: ObservableObject {
         userID: String
     ) async -> Bool {
         guard !isSyncing else {
-            DeveloperLogStore.log("现场记录同步", "已有同步任务正在运行,忽略重复请求", level: .warning)
+            queuedSyncRequest = SyncRequest(
+                sessions: sessions,
+                modelContext: modelContext,
+                userID: userID
+            )
+            DeveloperLogStore.log("现场记录同步", "已有同步任务正在运行,已排队再次检查时间戳")
             return false
         }
         DeveloperLogStore.log("现场记录同步", "开始同步,本地共 \(sessions.count) 条记录")
+        let syncStartedAt = diagnosticsNow
+        diagnosticsLog("整轮开始:本地记录=\(sessions.count)")
         isSyncing = true
         let task = Task { @MainActor [self] in
             await performSync(
@@ -544,11 +598,27 @@ final class SyncManager: ObservableObject {
             result ? "同步任务完成" : "同步任务未完成",
             level: result ? .success : .warning
         )
+        diagnosticsLog(
+            "整轮\(result ? "完成" : "未完成"):总耗时 \(diagnosticsElapsed(since: syncStartedAt))",
+            level: result ? .success : .warning
+        )
+        if let queuedRequest = queuedSyncRequest {
+            queuedSyncRequest = nil
+            Task { @MainActor [weak self] in
+                guard AuthManager.shared.currentUser?.id == queuedRequest.userID else { return }
+                _ = await self?.sync(
+                    sessions: queuedRequest.sessions,
+                    modelContext: queuedRequest.modelContext,
+                    userID: queuedRequest.userID
+                )
+            }
+        }
         return result
     }
 
     func pauseSync(sessionID: UUID) {
         guard isSyncing, activeSessionID == sessionID else { return }
+        queuedSyncRequest = nil
         activeSyncTask?.cancel()
     }
 
@@ -571,35 +641,144 @@ final class SyncManager: ObservableObject {
         // Give the UI immediate feedback while the remote index is being fetched.
         // The persisted state is changed only when this session actually begins syncing.
         activeSessionID = queuedSessions.first?.id
+        var pendingRemoteDownloads: [CelestiaSession] = []
         do {
-            let remoteSessions = try await service.fetchSessions()
-            DeveloperLogStore.log("现场记录同步", "获取到 \(remoteSessions.count) 条云端记录", level: .success)
+            let indexStartedAt = diagnosticsNow
+            diagnosticsLog("阶段 1/5 开始:请求服务器时间戳索引", level: .receive)
+            let remoteIndex = try await service.fetchSessionIndex()
+            diagnosticsLog(
+                "阶段 1/5 完成:云端记录=\(remoteIndex.count),耗时 \(diagnosticsElapsed(since: indexStartedAt))",
+                level: .success
+            )
+            DeveloperLogStore.log("现场记录同步", "获取到 \(remoteIndex.count) 条云端时间戳", level: .success)
             try Task.checkCancellation()
-            // Detail and post-recording syncs may upload only one session, while
-            // fetchSessions always returns the complete cloud history. Use the
-            // complete local store as the merge baseline to avoid inserting the
-            // other cloud sessions again with duplicate client IDs.
+            let mergeStartedAt = diagnosticsNow
             let allLocalSessions = try modelContext.fetch(FetchDescriptor<CelestiaSession>())
-            let mergedSessions = try await merge(
-                remoteSessions,
+            let indexedSessions = try await mergeIndex(
+                remoteIndex,
                 into: allLocalSessions,
                 modelContext: modelContext,
                 userID: userID
             )
-            for (remote, local) in mergedSessions {
-                try await downloadMissingAssets(for: local, remote: remote)
-                try Task.checkCancellation()
-                await Task.yield()
+            diagnosticsLog(
+                "阶段 2/5 完成:读取并合并本地记录=\(allLocalSessions.count),索引匹配=\(indexedSessions.count),耗时 \(diagnosticsElapsed(since: mergeStartedAt))"
+            )
+            var failures: [String] = []
+            var remoteDetailsByID: [String: RemoteSession] = [:]
+
+            // mergeIndex has already persisted list metadata. Only timestamp
+            // changes proceed to the second phase that fetches concrete content.
+            let sessionsRequiringDetail = indexedSessions.filter { $0.needsDetail }
+            pendingRemoteDownloads = sessionsRequiringDetail.map(\.local)
+            if let firstDownload = sessionsRequiringDetail.first?.local {
+                activeSessionID = firstDownload.id
+            }
+            await Task.yield()
+
+            let detailStageStartedAt = diagnosticsNow
+            diagnosticsLog("阶段 3/5 开始:需要拉取详情=\(sessionsRequiringDetail.count)")
+            for (detailIndex, match) in sessionsRequiringDetail.enumerated() {
+                let local = match.local
+                let recordLabel = diagnosticsRecordLabel(
+                    local,
+                    position: detailIndex + 1,
+                    total: sessionsRequiringDetail.count
+                )
+                let recordStartedAt = diagnosticsNow
+                do {
+                    activeSessionID = local.id
+                    let detailStartedAt = diagnosticsNow
+                    let remote = try await service.fetchSession(sessionID: match.index.id)
+                    let remoteAssets = remote.assets ?? []
+                    let remoteBytes = remoteAssets.reduce(Int64(0)) { $0 + $1.sizeBytes }
+                    diagnosticsLog(
+                        "\(recordLabel) 详情响应:事件=\(remote.events?.count ?? 0),资源=\(remoteAssets.count)(\(diagnosticsBytes(remoteBytes))),耗时 \(diagnosticsElapsed(since: detailStartedAt))",
+                        level: .receive
+                    )
+                    remoteDetailsByID[remote.id] = remote
+                    let applyStartedAt = diagnosticsNow
+                    await apply(
+                        remote,
+                        to: local,
+                        userID: userID,
+                        syncState: .syncing
+                    )
+                    try modelContext.save()
+                    diagnosticsLog(
+                        "\(recordLabel) 详情写入本地:耗时 \(diagnosticsElapsed(since: applyStartedAt))"
+                    )
+                    await Task.yield()
+
+                    let assetStartedAt = diagnosticsNow
+                    let downloadedAssets = try await downloadMissingAssets(
+                        for: local,
+                        remote: remote
+                    ) {
+                        guard local.hasConfirmedCloudSync else { return }
+                        activeSessionID = local.id
+                        local.syncState = .syncing
+                        local.lastSyncError = nil
+                        try? modelContext.save()
+                    }
+                    try Task.checkCancellation()
+                    local.serverUpdatedAt = remote.updatedAt
+                    local.syncState = .synced
+                    local.lastSyncError = nil
+                    local.lastSyncedAt = Date()
+                    let finalSaveStartedAt = diagnosticsNow
+                    try modelContext.save()
+                    diagnosticsLog(
+                        "\(recordLabel) 资源阶段:\(downloadedAssets ? "发生下载" : "无需下载"),耗时 \(diagnosticsElapsed(since: assetStartedAt));最终保存 \(diagnosticsElapsed(since: finalSaveStartedAt))",
+                        level: .success
+                    )
+                    diagnosticsLog(
+                        "\(recordLabel) 云端拉取完成:总耗时 \(diagnosticsElapsed(since: recordStartedAt))",
+                        level: .success
+                    )
+                    await Task.yield()
+                } catch where Task.isCancelled {
+                    diagnosticsLog(
+                        "\(recordLabel) 云端拉取取消:已运行 \(diagnosticsElapsed(since: recordStartedAt))",
+                        level: .warning
+                    )
+                    if local.hasConfirmedCloudSync,
+                       local.syncState == .syncing {
+                        local.syncState = .failed
+                        local.lastSyncError = "已暂停从云端下载"
+                        try? modelContext.save()
+                    }
+                    throw CancellationError()
+                } catch {
+                    if local.hasConfirmedCloudSync {
+                        local.syncState = .failed
+                        local.lastSyncError = "从云端下载失败:\(error.localizedDescription)"
+                        try? modelContext.save()
+                    }
+                    diagnosticsLog(
+                        "\(recordLabel) 云端拉取失败:耗时 \(diagnosticsElapsed(since: recordStartedAt));\(error.localizedDescription)",
+                        level: .error
+                    )
+                    failures.append("\(local.title):\(error.localizedDescription)")
+                    DeveloperLogStore.log(
+                        "现场记录同步",
+                        "\(local.title) 从云端下载失败:\(error.localizedDescription)",
+                        level: .error
+                    )
+                }
             }
             try modelContext.save()
+            diagnosticsLog(
+                "阶段 3/5 完成:详情记录=\(sessionsRequiringDetail.count),总耗时 \(diagnosticsElapsed(since: detailStageStartedAt))"
+            )
 
             let eligible = deduplicatedSessions(sessions.filter {
                 ($0.ownerUserID == nil || $0.ownerUserID == userID)
                     && $0.needsCloudSync
                     && $0.syncState != .conflict
             })
-            var failures: [String] = []
             let eligibleCount = max(eligible.count, 1)
+            let uploadStageStartedAt = diagnosticsNow
+            diagnosticsLog("阶段 4/5 开始:需要上传=\(eligible.count)")
             for (index, session) in eligible.enumerated() {
                 try Task.checkCancellation()
                 await Task.yield()
@@ -614,23 +793,46 @@ final class SyncManager: ObservableObject {
                     "现场记录同步",
                     "正在同步 \(index + 1)/\(eligible.count):\(session.title)"
                 )
+                let recordLabel = diagnosticsRecordLabel(
+                    session,
+                    position: index + 1,
+                    total: eligible.count
+                )
+                let recordStartedAt = diagnosticsNow
                 do {
-                    let previousRemote = remoteSessions.first {
-                        $0.id == session.cloudSessionId
-                            || $0.clientId?.caseInsensitiveCompare(session.id.uuidString) == .orderedSame
+                    var previousRemote: RemoteSession?
+                    if let cloudID = session.cloudSessionId {
+                        if let cached = remoteDetailsByID[cloudID] {
+                            previousRemote = cached
+                        } else if remoteIndex.contains(where: { $0.id == cloudID }) {
+                            let previousDetailStartedAt = diagnosticsNow
+                            let fetched = try await service.fetchSession(sessionID: cloudID)
+                            diagnosticsLog(
+                                "\(recordLabel) 上传前详情:耗时 \(diagnosticsElapsed(since: previousDetailStartedAt))",
+                                level: .receive
+                            )
+                            remoteDetailsByID[cloudID] = fetched
+                            previousRemote = fetched
+                        }
                     }
                     let localEventIDs = Set(session.events.map { $0.id.uuidString.lowercased() })
                     let deletedEventClientIDs = previousRemote?.events?
                         .compactMap(\.clientId)
                         .filter { !localEventIDs.contains($0.lowercased()) } ?? []
+                    let metadataStartedAt = diagnosticsNow
                     let remote = try await service.syncSession(
                         session,
                         deletedEventClientIDs: deletedEventClientIDs
                     )
+                    diagnosticsLog(
+                        "\(recordLabel) 元数据上传:耗时 \(diagnosticsElapsed(since: metadataStartedAt))",
+                        level: .transmit
+                    )
                     try Task.checkCancellation()
                     syncProgress = sessionStartProgress + sessionProgressSpan * 0.12
                     session.cloudSessionId = remote.id
                     session.serverRevision = remote.revision
+                    session.serverUpdatedAt = remote.updatedAt
                     try await uploadLocalAssets(for: session, remote: remote) { [weak self] assetProgress in
                         Task { @MainActor [weak self] in
                             self?.syncProgress = sessionStartProgress
@@ -638,6 +840,15 @@ final class SyncManager: ObservableObject {
                         }
                     }
                     try Task.checkCancellation()
+                    let finalDetailStartedAt = diagnosticsNow
+                    let finalRemote = try await service.fetchSession(sessionID: remote.id)
+                    diagnosticsLog(
+                        "\(recordLabel) 上传后详情确认:耗时 \(diagnosticsElapsed(since: finalDetailStartedAt))",
+                        level: .receive
+                    )
+                    remoteDetailsByID[finalRemote.id] = finalRemote
+                    session.serverRevision = finalRemote.revision
+                    session.serverUpdatedAt = finalRemote.updatedAt
                     session.isSynced = true
                     session.syncState = .synced
                     session.lastSyncedAt = Date()
@@ -648,7 +859,15 @@ final class SyncManager: ObservableObject {
                         "\(session.title) 同步成功,云端版本 v\(session.serverRevision)",
                         level: .success
                     )
+                    diagnosticsLog(
+                        "\(recordLabel) 上传链路完成:总耗时 \(diagnosticsElapsed(since: recordStartedAt))",
+                        level: .success
+                    )
                 } catch where Task.isCancelled {
+                    diagnosticsLog(
+                        "\(recordLabel) 上传链路取消:已运行 \(diagnosticsElapsed(since: recordStartedAt))",
+                        level: .warning
+                    )
                     session.isSynced = false
                     session.syncState = .pending
                     session.lastSyncError = nil
@@ -656,6 +875,10 @@ final class SyncManager: ObservableObject {
                     try? modelContext.save()
                     return false
                 } catch APIError.server(let code, _) where code == 409 {
+                    diagnosticsLog(
+                        "\(recordLabel) 上传链路冲突:耗时 \(diagnosticsElapsed(since: recordStartedAt))",
+                        level: .error
+                    )
                     session.isSynced = false
                     session.syncState = .conflict
                     session.lastSyncError = "本地版本 \(session.serverRevision) 与云端版本不一致"
@@ -665,6 +888,10 @@ final class SyncManager: ObservableObject {
                         level: .error
                     )
                 } catch {
+                    diagnosticsLog(
+                        "\(recordLabel) 上传链路失败:耗时 \(diagnosticsElapsed(since: recordStartedAt));\(error.localizedDescription)",
+                        level: .error
+                    )
                     session.isSynced = false
                     session.syncState = .failed
                     session.lastSyncError = error.localizedDescription
@@ -677,6 +904,9 @@ final class SyncManager: ObservableObject {
                 }
                 try modelContext.save()
             }
+            diagnosticsLog(
+                "阶段 4/5 完成:上传记录=\(eligible.count),总耗时 \(diagnosticsElapsed(since: uploadStageStartedAt))"
+            )
             let conflicts = sessions.filter { $0.syncState == .conflict }
             if !conflicts.isEmpty {
                 failures.append(contentsOf: conflicts.map {
@@ -688,16 +918,31 @@ final class SyncManager: ObservableObject {
                 DeveloperLogStore.log("现场记录同步", "同步结束,存在 \(failures.count) 个问题", level: .error)
                 return false
             }
+            let checkpointStartedAt = diagnosticsNow
             try await service.recordSyncCheckpoint()
+            diagnosticsLog(
+                "阶段 5/5 完成:提交同步检查点,耗时 \(diagnosticsElapsed(since: checkpointStartedAt))",
+                level: .success
+            )
             let now = Date()
             lastSyncDate = now
             UserDefaults.standard.set(now, forKey: "com.celestia.trace.last_server_sync")
             DeveloperLogStore.log("现场记录同步", "云端检查点已更新", level: .success)
             return true
         } catch where Task.isCancelled {
+            for session in pendingRemoteDownloads where session.syncState == .syncing {
+                session.syncState = .failed
+                session.lastSyncError = "已暂停从云端下载"
+            }
+            try? modelContext.save()
             syncProgress = 0
             return false
         } catch {
+            for session in pendingRemoteDownloads where session.syncState == .syncing {
+                session.syncState = .failed
+                session.lastSyncError = "从云端下载中断:\(error.localizedDescription)"
+            }
+            try? modelContext.save()
             lastErrorMessage = error.localizedDescription
             DeveloperLogStore.log("现场记录同步", "同步中断:\(error.localizedDescription)", level: .error)
             return false
@@ -710,13 +955,18 @@ final class SyncManager: ObservableObject {
         progress: @escaping @Sendable (Double) -> Void
     ) async throws {
         guard let cloudID = session.cloudSessionId else { throw APIError.missingData }
+        let stageStartedAt = diagnosticsNow
+        let recordLabel = diagnosticsRecordLabel(session)
         let existingIDs = Set((remote.assets ?? []).map(\.clientId))
         var uploads: [(clientID: String, kind: String, url: URL, size: Int64)] = []
         if let path = session.localAudioPath {
             guard let url = AudioPathHelper.resolveURL(for: path) else {
                 throw APIError.transport("本地录音文件不存在:\(path)")
             }
-            let sha256 = try await fileSHA256(of: url)
+            let sha256 = try await fileSHA256(
+                of: url,
+                purpose: "\(recordLabel) 音频上传校验"
+            )
             let hasMatchingAudio = (remote.assets ?? []).contains {
                 $0.kind.uppercased() == "AUDIO"
                     && $0.sha256.caseInsensitiveCompare(sha256) == .orderedSame
@@ -740,7 +990,12 @@ final class SyncManager: ObservableObject {
         let totalBytes = max(uploads.reduce(Int64(0)) { $0 + $1.size }, 1)
         let requiredBytes = uploads.reduce(Int64(0)) { $0 + $1.size }
         if requiredBytes > 0 {
+            let quotaStartedAt = diagnosticsNow
             let quota = try await service.fetchStorageQuota()
+            diagnosticsLog(
+                "\(recordLabel) 存储配额查询:耗时 \(diagnosticsElapsed(since: quotaStartedAt))",
+                level: .receive
+            )
             guard requiredBytes <= quota.remainingBytes else {
                 throw APIError.storageQuotaExceeded(
                     requiredBytes: requiredBytes,
@@ -750,8 +1005,19 @@ final class SyncManager: ObservableObject {
         }
         var completedBytes: Int64 = 0
         progress(uploads.isEmpty ? 1 : 0)
-        for upload in uploads {
+        if uploads.isEmpty {
+            diagnosticsLog(
+                "\(recordLabel) 本地资源上传:无需上传,检查耗时 \(diagnosticsElapsed(since: stageStartedAt))"
+            )
+        }
+        for (uploadIndex, upload) in uploads.enumerated() {
             let bytesBeforeUpload = completedBytes
+            let uploadStartedAt = diagnosticsNow
+            let assetKind = upload.kind.uppercased() == "AUDIO" ? "音频" : "照片"
+            diagnosticsLog(
+                "\(recordLabel) \(assetKind) \(uploadIndex + 1)/\(uploads.count) 上传开始:\(diagnosticsBytes(upload.size))",
+                level: .transmit
+            )
             let result = try await service.uploadAsset(
                 sessionID: cloudID,
                 clientID: upload.clientID,
@@ -766,6 +1032,17 @@ final class SyncManager: ObservableObject {
             }
             completedBytes += upload.size
             progress(Double(completedBytes) / Double(totalBytes))
+            let uploadDuration = diagnosticsDuration(since: uploadStartedAt)
+            diagnosticsLog(
+                "\(recordLabel) \(assetKind) \(uploadIndex + 1)/\(uploads.count) 上传完成:耗时 \(diagnosticsDurationText(uploadDuration)),平均 \(diagnosticsRate(bytes: upload.size, duration: uploadDuration))",
+                level: .success
+            )
+        }
+        if !uploads.isEmpty {
+            diagnosticsLog(
+                "\(recordLabel) 本地资源上传完成:文件=\(uploads.count),总大小=\(diagnosticsBytes(requiredBytes)),总耗时 \(diagnosticsElapsed(since: stageStartedAt))",
+                level: .success
+            )
         }
     }
 
@@ -774,7 +1051,10 @@ final class SyncManager: ObservableObject {
         return Int64(values?.fileSize ?? 0)
     }
 
-    private func fileSHA256(of url: URL) async throws -> String {
+    private func fileSHA256(of url: URL, purpose: String) async throws -> String {
+        let sizeBytes = fileSize(of: url)
+        let startedAt = diagnosticsNow
+        diagnosticsLog("\(purpose) 开始:\(diagnosticsBytes(sizeBytes))")
         let task = Task.detached(priority: .utility) {
             let input = try FileHandle(forReadingFrom: url)
             defer { try? input.close() }
@@ -786,15 +1066,24 @@ final class SyncManager: ObservableObject {
             }
             return hasher.finalize().map { String(format: "%02x", $0) }.joined()
         }
-        return try await withTaskCancellationHandler {
+        let digest = try await withTaskCancellationHandler {
             try await task.value
         } onCancel: {
             task.cancel()
         }
+        diagnosticsLog("\(purpose) 完成:耗时 \(diagnosticsElapsed(since: startedAt))")
+        return digest
     }
 
-    private func downloadMissingAssets(for session: CelestiaSession, remote: RemoteSession) async throws {
+    private func downloadMissingAssets(
+        for session: CelestiaSession,
+        remote: RemoteSession,
+        onDownloadStarted: () -> Void
+    ) async throws -> Bool {
         let assets = remote.assets ?? []
+        var jobs: [AssetDownloadJob] = []
+        var downloadedAssets = false
+        let recordLabel = diagnosticsRecordLabel(session)
         if let audio = assets
             .filter({ $0.kind.uppercased() == "AUDIO" })
             .max(by: {
@@ -803,7 +1092,10 @@ final class SyncManager: ObservableObject {
             let localURL = AudioPathHelper.resolveURL(for: session.localAudioPath)
             let localHash: String?
             if let localURL {
-                localHash = try? await fileSHA256(of: localURL)
+                localHash = try? await fileSHA256(
+                    of: localURL,
+                    purpose: "\(recordLabel) 音频本地校验"
+                )
             } else {
                 localHash = nil
             }
@@ -812,8 +1104,15 @@ final class SyncManager: ObservableObject {
                     && localHash?.caseInsensitiveCompare(audio.sha256) != .orderedSame)
             if needsDownload {
                 let destination = downloadDestination(for: audio)
-                try await service.downloadAsset(sessionID: remote.id, asset: audio, destinationURL: destination)
-                session.localAudioPath = AudioPathHelper.relativePath(from: destination.path)
+                jobs.append(
+                    AssetDownloadJob(
+                        sessionID: remote.id,
+                        asset: audio,
+                        destination: destination,
+                        target: .audio,
+                        displayName: "音频"
+                    )
+                )
             }
         }
 
@@ -840,9 +1139,141 @@ final class SyncManager: ObservableObject {
             let key = "\(event.id.uuidString)-photo".lowercased()
             guard let asset = photoAssets[key] else { continue }
             let destination = downloadDestination(for: asset)
-            try await service.downloadAsset(sessionID: remote.id, asset: asset, destinationURL: destination)
-            event.localFilePath = AudioPathHelper.relativePath(from: destination.path)
+            jobs.append(
+                AssetDownloadJob(
+                    sessionID: remote.id,
+                    asset: asset,
+                    destination: destination,
+                    target: .photo(eventID: event.id),
+                    displayName: "照片"
+                )
+            )
+        }
+
+        guard !jobs.isEmpty else { return false }
+        diagnosticsLog(
+            "\(recordLabel) 资源并发下载:文件=\(jobs.count),并发上限=\(Self.maxConcurrentAssetDownloads)",
+            level: .receive
+        )
+
+        for batchStart in stride(
+            from: 0,
+            to: jobs.count,
+            by: Self.maxConcurrentAssetDownloads
+        ) {
+            try Task.checkCancellation()
+            let batchEnd = min(batchStart + Self.maxConcurrentAssetDownloads, jobs.count)
+            let batch = Array(jobs[batchStart..<batchEnd])
+            batch.forEach { _ in onDownloadStarted() }
+
+            let tasks = batch.map { job in
+                Task { @MainActor [self] in
+                    try await downloadAssetJob(job, recordLabel: recordLabel)
+                }
+            }
+            do {
+                try await withTaskCancellationHandler {
+                    for task in tasks {
+                        let completedJob = try await task.value
+                        applyDownloadedAsset(completedJob, to: session)
+                        downloadedAssets = true
+                    }
+                } onCancel: {
+                    tasks.forEach { $0.cancel() }
+                }
+            } catch {
+                tasks.forEach { $0.cancel() }
+                throw error
+            }
+        }
+        return downloadedAssets
+    }
+
+    private func downloadAssetJob(
+        _ job: AssetDownloadJob,
+        recordLabel: String
+    ) async throws -> AssetDownloadJob {
+        try Task.checkCancellation()
+        let downloadStartedAt = diagnosticsNow
+        diagnosticsLog(
+            "\(recordLabel) \(job.displayName)下载开始:\(diagnosticsBytes(job.asset.sizeBytes))",
+            level: .receive
+        )
+        try await service.downloadAsset(
+            sessionID: job.sessionID,
+            asset: job.asset,
+            destinationURL: job.destination
+        )
+        try Task.checkCancellation()
+        let downloadDuration = diagnosticsDuration(since: downloadStartedAt)
+        diagnosticsLog(
+            "\(recordLabel) \(job.displayName)下载完成:耗时 \(diagnosticsDurationText(downloadDuration)),平均 \(diagnosticsRate(bytes: job.asset.sizeBytes, duration: downloadDuration))",
+            level: .success
+        )
+        return job
+    }
+
+    private func applyDownloadedAsset(
+        _ job: AssetDownloadJob,
+        to session: CelestiaSession
+    ) {
+        let relativePath = AudioPathHelper.relativePath(from: job.destination.path)
+        switch job.target {
+        case .audio:
+            session.localAudioPath = relativePath
+            Task.detached(priority: .utility) {
+                await SilenceDetector.warmCache(for: job.destination)
+            }
+        case .photo(let eventID):
+            session.events.first(where: { $0.id == eventID })?.localFilePath = relativePath
+        }
+    }
+
+    private var diagnosticsNow: TimeInterval {
+        ProcessInfo.processInfo.systemUptime
+    }
+
+    private func diagnosticsDuration(since startedAt: TimeInterval) -> TimeInterval {
+        max(0, diagnosticsNow - startedAt)
+    }
+
+    private func diagnosticsElapsed(since startedAt: TimeInterval) -> String {
+        diagnosticsDurationText(diagnosticsDuration(since: startedAt))
+    }
+
+    private func diagnosticsDurationText(_ duration: TimeInterval) -> String {
+        if duration < 1 {
+            return "\(Int((duration * 1_000).rounded())) 毫秒"
+        }
+        return String(format: "%.2f 秒", duration)
+    }
+
+    private func diagnosticsBytes(_ bytes: Int64) -> String {
+        Self.diagnosticsByteFormatter.string(fromByteCount: max(0, bytes))
+    }
+
+    private func diagnosticsRate(bytes: Int64, duration: TimeInterval) -> String {
+        guard duration > 0 else { return "—" }
+        return "\(diagnosticsBytes(Int64(Double(bytes) / duration)))/秒"
+    }
+
+    private func diagnosticsRecordLabel(
+        _ session: CelestiaSession,
+        position: Int? = nil,
+        total: Int? = nil
+    ) -> String {
+        let shortID = session.id.uuidString.lowercased().prefix(8)
+        if let position, let total {
+            return "记录 \(position)/\(total) [\(shortID)]"
         }
+        return "记录 [\(shortID)]"
+    }
+
+    private func diagnosticsLog(
+        _ message: String,
+        level: DeveloperLogEntry.Level = .info
+    ) {
+        DeveloperLogStore.log("同步性能", message, level: level)
     }
 
     private func downloadDestination(for asset: RemoteAsset) -> URL {
@@ -852,20 +1283,35 @@ final class SyncManager: ObservableObject {
         return documents.appendingPathComponent("cloud_\(asset.id)\(suffix)")
     }
 
-    private func merge(
-        _ remoteSessions: [RemoteSession],
+    private struct IndexedSessionMatch {
+        let index: RemoteSessionIndex
+        let local: CelestiaSession
+        let needsDetail: Bool
+    }
+
+    private func mergeIndex(
+        _ remoteSessions: [RemoteSessionIndex],
         into localSessions: [CelestiaSession],
         modelContext: ModelContext,
         userID: String
-    ) async throws -> [(RemoteSession, CelestiaSession)] {
+    ) async throws -> [IndexedSessionMatch] {
+        let scopedLocalSessions = localSessions.filter {
+            $0.ownerUserID == nil || $0.ownerUserID == userID
+        }
         var byClientID = Dictionary(
-            localSessions.map { ($0.id.uuidString.lowercased(), $0) },
+            scopedLocalSessions.map { ($0.id.uuidString.lowercased(), $0) },
             uniquingKeysWith: preferredLocalSession
         )
-        if byClientID.count < localSessions.count {
+        var byCloudID = Dictionary(
+            scopedLocalSessions.compactMap { session in
+                session.cloudSessionId.map { ($0, session) }
+            },
+            uniquingKeysWith: preferredLocalSession
+        )
+        if byClientID.count < scopedLocalSessions.count {
             DeveloperLogStore.log(
                 "现场记录同步",
-                "检测到 \(localSessions.count - byClientID.count) 条重复本地记录,已选择云端版本较新的记录继续同步",
+                "检测到 \(scopedLocalSessions.count - byClientID.count) 条重复本地记录,已选择云端版本较新的记录继续同步",
                 level: .warning
             )
         }
@@ -874,13 +1320,14 @@ final class SyncManager: ObservableObject {
         let activeRemoteClientIDs = Set(activeRemoteSessions.compactMap { $0.clientId?.lowercased() })
         var deletedLocalObjects: Set<ObjectIdentifier> = []
         var deletedFileURLs: Set<URL> = []
-        var merged: [(RemoteSession, CelestiaSession)] = []
+        var merged: [IndexedSessionMatch] = []
         for (index, remote) in remoteSessions.enumerated() {
             if index.isMultiple(of: 20) {
                 await Task.yield()
             }
-            guard let clientID = remote.clientId?.lowercased() else { continue }
-            if let local = byClientID[clientID] {
+            let clientID = remote.clientId?.lowercased()
+            let existingLocal = clientID.flatMap { byClientID[$0] } ?? byCloudID[remote.id]
+            if let local = existingLocal {
                 if remote.deletedAt != nil, local.hasConfirmedCloudSync {
                     deletedFileURLs.formUnion(localFileURLs(for: local))
                     deletedLocalObjects.insert(ObjectIdentifier(local))
@@ -897,19 +1344,50 @@ final class SyncManager: ObservableObject {
                     local.lastSyncError = "本地版本 \(local.serverRevision) 与云端版本 \(remote.revision) 不一致"
                     continue
                 }
-                if local.isSynced, remote.revision > local.serverRevision {
-                    await apply(remote, to: local, userID: userID)
-                }
                 if remote.deletedAt == nil {
-                    merged.append((remote, local))
+                    let needsDetail =
+                        local.isSynced
+                        && (local.serverUpdatedAt.map { remote.updatedAt > $0 } ?? true
+                            || remote.revision > local.serverRevision
+                            || local.syncState == .failed
+                            || local.syncState == .syncing)
+                    local.cloudSessionId = remote.id
+                    local.ownerUserID = userID
+                    if local.isSynced {
+                        local.title = remote.title
+                        local.startTime = remote.startTime
+                        local.endTime = remote.endTime
+                    }
+                    if needsDetail {
+                        local.serverRevision = remote.revision
+                        local.syncState = .syncing
+                        local.lastSyncError = nil
+                    }
+                    merged.append(IndexedSessionMatch(
+                        index: remote,
+                        local: local,
+                        needsDetail: needsDetail
+                    ))
                 }
             } else if remote.deletedAt == nil {
-                let id = UUID(uuidString: clientID) ?? UUID()
+                let id = clientID.flatMap(UUID.init(uuidString:)) ?? UUID()
                 let session = CelestiaSession(id: id, title: remote.title, startTime: remote.startTime)
-                await apply(remote, to: session, userID: userID)
+                session.endTime = remote.endTime
+                session.cloudSessionId = remote.id
+                session.ownerUserID = userID
+                session.serverRevision = remote.revision
+                session.isSynced = true
+                session.syncState = .syncing
                 modelContext.insert(session)
-                byClientID[clientID] = session
-                merged.append((remote, session))
+                if let clientID {
+                    byClientID[clientID] = session
+                }
+                byCloudID[remote.id] = session
+                merged.append(IndexedSessionMatch(
+                    index: remote,
+                    local: session,
+                    needsDetail: true
+                ))
             }
         }
 
@@ -975,7 +1453,12 @@ final class SyncManager: ObservableObject {
             : candidate
     }
 
-    private func apply(_ remote: RemoteSession, to local: CelestiaSession, userID: String) async {
+    private func apply(
+        _ remote: RemoteSession,
+        to local: CelestiaSession,
+        userID: String,
+        syncState: SessionSyncState = .synced
+    ) async {
         local.title = remote.title
         local.startTime = remote.startTime
         local.endTime = remote.endTime
@@ -983,7 +1466,7 @@ final class SyncManager: ObservableObject {
         local.ownerUserID = userID
         local.serverRevision = remote.revision
         local.isSynced = true
-        local.syncState = .synced
+        local.syncState = syncState
         local.lastSyncError = nil
         local.events.removeAll()
         for (index, remoteEvent) in (remote.events ?? []).enumerated() {

+ 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] {

+ 3 - 2
CelestiaTrace/Views/Components/DeveloperLogOverlay.swift

@@ -21,6 +21,7 @@ struct DeveloperLogEntry: Identifiable, Equatable {
 final class DeveloperLogStore: ObservableObject, @unchecked Sendable {
     static let shared = DeveloperLogStore()
     static let enabledKey = "com.celestia.trace.developer_mode"
+    private static let maximumEntryCount = 2_000
 
     @Published private(set) var entries: [DeveloperLogEntry] = []
 
@@ -40,8 +41,8 @@ final class DeveloperLogStore: ObservableObject, @unchecked Sendable {
         )
         DispatchQueue.main.async {
             shared.entries.append(entry)
-            if shared.entries.count > 500 {
-                shared.entries.removeFirst(shared.entries.count - 500)
+            if shared.entries.count > maximumEntryCount {
+                shared.entries.removeFirst(shared.entries.count - maximumEntryCount)
             }
         }
     }

+ 42 - 2
CelestiaTrace/Views/ContentView.swift

@@ -16,13 +16,15 @@ struct ContentView: View {
     @State private var selectedTab: Tab = .home
     /// Session selected for detail playback navigation in Field Records tab.
     @State private var fieldRecordsSelectedSession: CelestiaSession?
+    @State private var showFieldRecordsAuthentication = false
+    @State private var shouldOpenFieldRecordsAfterLogin = false
 
     var body: some View {
         ZStack(alignment: .bottomTrailing) {
-            TabView(selection: $selectedTab) {
+            TabView(selection: protectedTabSelection) {
                 HomeView { session in
                     fieldRecordsSelectedSession = session
-                    selectedTab = .fieldRecords
+                    selectTab(.fieldRecords)
                 }
                 .tabItem {
                     Label(Tab.home.title, systemImage: Tab.home.icon)
@@ -55,6 +57,15 @@ struct ContentView: View {
             }
         }
         .animation(.easeInOut(duration: 0.2), value: isDeveloperModeEnabled)
+        .sheet(isPresented: $showFieldRecordsAuthentication, onDismiss: {
+            if !authManager.isLoggedIn {
+                shouldOpenFieldRecordsAfterLogin = false
+            }
+        }) {
+            AuthModalView(
+                guidanceMessage: "登录或注册后,才能查看现场记录"
+            )
+        }
         .onAppear {
             if isDeveloperModeEnabled {
                 DeveloperLogStore.log("App", "开发者模式已启用,开始记录本次运行日志", level: .success)
@@ -67,6 +78,15 @@ struct ContentView: View {
         .task(id: authManager.currentUser?.id) {
             await synchronizeFieldRecords()
         }
+        .onChange(of: authManager.currentUser?.id) { _, userID in
+            if userID != nil, shouldOpenFieldRecordsAfterLogin {
+                shouldOpenFieldRecordsAfterLogin = false
+                selectedTab = .fieldRecords
+            } else if userID == nil, selectedTab == .fieldRecords {
+                selectedTab = .home
+                requestFieldRecordsAuthentication()
+            }
+        }
         .onChange(of: scenePhase) { _, newPhase in
             guard newPhase == .active else { return }
             Task {
@@ -84,6 +104,26 @@ struct ContentView: View {
         }
     }
 
+    private var protectedTabSelection: Binding<Tab> {
+        Binding(
+            get: { selectedTab },
+            set: { selectTab($0) }
+        )
+    }
+
+    private func selectTab(_ tab: Tab) {
+        guard tab != .fieldRecords || authManager.isLoggedIn else {
+            requestFieldRecordsAuthentication()
+            return
+        }
+        selectedTab = tab
+    }
+
+    private func requestFieldRecordsAuthentication() {
+        shouldOpenFieldRecordsAfterLogin = true
+        showFieldRecordsAuthentication = true
+    }
+
     @MainActor
     private func synchronizeFieldRecords() async {
         guard let userID = authManager.currentUser?.id else { return }

+ 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 {

+ 28 - 6
CelestiaTrace/Views/Detail/SessionDetailView.swift

@@ -238,6 +238,10 @@ struct SessionDetailView: View {
                 scheduleAutoSync()
             }
         }
+        .onDisappear {
+            playbackVM.pause()
+            playbackVM.cancelAudioAnalysis()
+        }
         .onChange(of: session.syncStateRaw) { _, newValue in
             guard let state = SessionSyncState(rawValue: newValue),
                   state == .pending || state == .localOnly,
@@ -696,7 +700,8 @@ struct SessionDetailView: View {
     }
 
     private var isThisSessionSyncing: Bool {
-        syncManager.isSyncing && syncManager.activeSessionID == session.id
+        session.syncState == .syncing
+            || (syncManager.isSyncing && syncManager.activeSessionID == session.id)
     }
 
     private var isDisplayedSyncing: Bool {
@@ -704,7 +709,10 @@ struct SessionDetailView: View {
     }
 
     private var isDisplayedSynced: Bool {
-        !isDisplayedSyncing && session.hasConfirmedCloudSync
+        !isDisplayedSyncing
+            && session.hasConfirmedCloudSync
+            && session.syncState != .failed
+            && session.syncState != .conflict
     }
 
     private var sessionSyncProgress: Double {
@@ -810,17 +818,20 @@ struct SessionDetailView: View {
                 events: session.events,
                 currentTimeMs: Binding(
                     get: { playbackVM.currentPlaybackTimeMs },
-                    set: { playbackVM.seekTo(timeMs: $0) }
+                    set: { playbackVM.previewScrub(to: $0) }
                 ),
                 totalDurationMs: playbackVM.totalDurationMs,
                 waveformSamples: playbackVM.waveformSamples,
+                waveformRevision: playbackVM.waveformRevision,
                 silentRanges: playbackVM.silentRanges,
                 onEmptyTrackTap: handleEmptyTrackTap,
                 onEventTap: { event in
                     playbackVM.seekTo(timeMs: Double(event.relativeTimeMs))
                     openTimelineEvent(event)
                     HapticManager.trigger(.tapFeedback)
-                }
+                },
+                onScrubBegan: playbackVM.beginScrubbing,
+                onScrubEnded: playbackVM.endScrubbing
             )
             .frame(height: 220)
         }
@@ -848,10 +859,21 @@ struct SessionDetailView: View {
                 value: Binding(
                     get: { playbackVM.progress },
                     set: { newValue in
-                        playbackVM.seekTo(timeMs: newValue * playbackVM.totalDurationMs)
+                        playbackVM.previewScrub(
+                            to: newValue * playbackVM.totalDurationMs
+                        )
                     }
                 ),
-                in: 0...1
+                in: 0...1,
+                onEditingChanged: { isEditing in
+                    if isEditing {
+                        playbackVM.beginScrubbing()
+                    } else {
+                        playbackVM.endScrubbing(
+                            at: playbackVM.currentPlaybackTimeMs
+                        )
+                    }
+                }
             )
             .tint(Color.primary)
 

+ 10 - 3
CelestiaTrace/Views/FieldRecords/SessionListView.swift

@@ -292,18 +292,25 @@ private struct SessionRowCard: View {
         } else {
             Circle()
                 .fill(
-                    session.hasConfirmedCloudSync
+                    isDisplayedSynced
                         ? Color.lessCosmosGold
                         : Color.secondary.opacity(0.4)
                 )
                 .frame(width: 6, height: 6)
                 .frame(width: 10, height: 10)
-                .accessibilityLabel(session.hasConfirmedCloudSync ? "已同步" : "未同步")
+                .accessibilityLabel(isDisplayedSynced ? "已同步" : "未同步")
         }
     }
 
     private var isThisSessionSyncing: Bool {
-        syncManager.isSyncing && syncManager.activeSessionID == session.id
+        session.syncState == .syncing
+            || (syncManager.isSyncing && syncManager.activeSessionID == session.id)
+    }
+
+    private var isDisplayedSynced: Bool {
+        session.hasConfirmedCloudSync
+            && session.syncState != .failed
+            && session.syncState != .conflict
     }
 
     private func countBadge(icon: String, count: Int) -> some View {

+ 52 - 1
CelestiaTrace/Views/Home/HomeView.swift

@@ -19,6 +19,8 @@ struct HomeView: View {
     @State private var dismissedWarnings: Set<String> = []
     @State private var showRecordingSourcePicker = false
     @State private var pendingRecordingSource: RecordingSourceChoice?
+    @State private var showAuthentication = false
+    @State private var pendingProtectedAction: ProtectedRecordingAction?
 
     var onRecordingFinished: ((CelestiaSession) -> Void)? = nil
 
@@ -59,6 +61,20 @@ struct HomeView: View {
                 detector.checkEnvironment()
                 restoreActiveRecordingIfNeeded()
             }
+            .onChange(of: authManager.currentUser?.id) { _, userID in
+                guard userID != nil, let action = pendingProtectedAction else { return }
+                pendingProtectedAction = nil
+
+                switch action {
+                case .startNew:
+                    beginNewRecording()
+                case .restore(let sessionID, let recordingAction):
+                    restoreActiveRecordingIfNeeded(
+                        sessionID: sessionID,
+                        action: recordingAction
+                    )
+                }
+            }
             .onOpenURL(perform: handleRecordingURL)
             .fullScreenCover(item: $activeRecording, onDismiss: {
                 if let session = completedRecordingSession {
@@ -85,6 +101,15 @@ struct HomeView: View {
                     showRecordingSourcePicker = false
                 }
             }
+            .sheet(isPresented: $showAuthentication, onDismiss: {
+                if !authManager.isLoggedIn {
+                    pendingProtectedAction = nil
+                }
+            }) {
+                AuthModalView(
+                    guidanceMessage: "登录或注册后,才能开始和继续现场记录"
+                )
+            }
         }
     }
 
@@ -156,6 +181,11 @@ struct HomeView: View {
     // MARK: - Actions
 
     private func beginNewRecording() {
+        guard authManager.isLoggedIn else {
+            requestAuthentication(for: .startNew)
+            return
+        }
+
         if connectedSparkDevices.isEmpty {
             createSessionAndNavigate(source: .iPhone)
         } else {
@@ -164,6 +194,11 @@ struct HomeView: View {
     }
 
     private func createSessionAndNavigate(source: RecordingSourceChoice) {
+        guard authManager.isLoggedIn else {
+            requestAuthentication(for: .startNew)
+            return
+        }
+
         let formatter = DateFormatter()
         formatter.dateFormat = "yyyyMMdd_HHmm"
         let title = "\(formatter.string(from: Date()))_现场记录"
@@ -186,7 +221,7 @@ struct HomeView: View {
         sessionID requestedSessionID: UUID? = nil,
         action: RecordingLiveActivityAction? = nil
     ) {
-        guard activeRecording == nil else { return }
+        guard authManager.isLoggedIn, activeRecording == nil else { return }
 
         let persistedID = RecordingRecoveryStore.activeSessionID
         let activityID = RecordingLiveActivityManager.shared.recoverableSessionID
@@ -267,12 +302,23 @@ struct HomeView: View {
 
     private func handleRecordingURL(_ url: URL) {
         guard let route = RecordingLiveActivityRoute(url: url) else { return }
+        guard authManager.isLoggedIn else {
+            requestAuthentication(
+                for: .restore(sessionID: route.sessionID, action: route.action)
+            )
+            return
+        }
         restoreActiveRecordingIfNeeded(
             sessionID: route.sessionID,
             action: route.action
         )
     }
 
+    private func requestAuthentication(for action: ProtectedRecordingAction) {
+        pendingProtectedAction = action
+        showAuthentication = true
+    }
+
     private var connectedSparkDevices: [BoundDevice] {
         guard let userID = authManager.currentUser?.id else { return [] }
         return bleManager.connectedDevices(forUserId: userID)
@@ -289,6 +335,11 @@ struct HomeView: View {
     }
 }
 
+private enum ProtectedRecordingAction {
+    case startNew
+    case restore(sessionID: UUID?, action: RecordingLiveActivityAction?)
+}
+
 private struct ActiveRecordingPresentation: Identifiable {
     let id = UUID()
     let session: CelestiaSession

+ 7 - 1
CelestiaTrace/Views/Profile/Auth/AuthModalView.swift

@@ -4,6 +4,7 @@ import SwiftUI
 struct AuthModalView: View {
     @Environment(\.dismiss) private var dismiss
     @ObservedObject var authManager: AuthManager = .shared
+    var guidanceMessage: String? = nil
     
     @State private var selectedTab: AuthTab = .login
     @State private var usernameInput: String = ""
@@ -51,7 +52,12 @@ struct AuthModalView: View {
                         .font(.system(size: 22, weight: .bold))
                         .foregroundStyle(Color.primary)
                     
-                    Text(selectedTab == .login ? "登录以同步录音并管理专属设备" : "注册后可将本机记录安全同步至云端")
+                    Text(
+                        guidanceMessage
+                            ?? (selectedTab == .login
+                                ? "登录以同步录音并管理专属设备"
+                                : "注册后可将本机记录安全同步至云端")
+                    )
                         .font(.system(size: 12))
                         .foregroundStyle(Color.secondary)
                         .multilineTextAlignment(.center)

+ 4 - 4
CelestiaTraceLiveActivity/RecordingLiveActivityWidget.swift

@@ -102,12 +102,12 @@ struct RecordingLiveActivityWidget: Widget {
             VStack(spacing: 8) {
                 HStack(spacing: 10) {
                     quickAction(
-                        title: context.state.isPaused ? "继续记录" : "暂停记录",
+                        title: context.state.isPaused ? "继续" : "暂停",
                         systemImage: context.state.isPaused ? "play.fill" : "pause.fill",
                         url: actionURL("toggle-pause", context.attributes)
                     )
                     quickAction(
-                        title: "结束记录",
+                        title: "结束",
                         systemImage: "stop.fill",
                         url: actionURL("stop", context.attributes),
                         tint: .red
@@ -115,12 +115,12 @@ struct RecordingLiveActivityWidget: Widget {
                 }
                 HStack(spacing: 10) {
                     quickAction(
-                        title: "拍照记录",
+                        title: "拍照",
                         systemImage: "camera.fill",
                         url: actionURL("photo", context.attributes)
                     )
                     quickAction(
-                        title: "文字记录",
+                        title: "文字",
                         systemImage: "note.text",
                         url: actionURL("note", context.attributes)
                     )

+ 75 - 7
Docs/RemoteAPI.openapi.yaml

@@ -246,8 +246,14 @@ paths:
 
   /sessions:
     get:
-      tags: [Sessions]
-      operationId: listSessions
+      tags: [Sessions, Sync]
+      operationId: getSessionSyncIndex
+      description: |
+        Lightweight first phase of field-record synchronization. Returns one
+        timestamped row per record without events or assets. The client compares
+        `updatedAt` (and `revision` as a monotonic guard) with local state, then
+        fetches concrete content only for new or changed records from
+        `/sessions/{sessionId}`.
       parameters:
         - name: includeDeleted
           in: query
@@ -255,13 +261,21 @@ paths:
             type: boolean
             default: true
           description: iOS uses true to receive deletion tombstones.
+        - name: updatedAfter
+          in: query
+          schema:
+            type: string
+            format: date-time
+          description: Optional exclusive timestamp cursor for incremental checks.
       responses:
         "200":
-          description: All sessions visible to the current user
+          description: Timestamp index for records visible to the current user
           content:
             application/json:
               schema:
-                $ref: "#/components/schemas/SessionListEnvelope"
+                $ref: "#/components/schemas/SessionSyncIndexEnvelope"
+        "400":
+          $ref: "#/components/responses/Error"
         "401":
           $ref: "#/components/responses/Error"
     post:
@@ -298,6 +312,21 @@ paths:
   /sessions/{sessionId}:
     parameters:
       - $ref: "#/components/parameters/SessionId"
+    get:
+      tags: [Sessions]
+      operationId: getSessionDetail
+      description: Second synchronization phase for one new or changed record.
+      responses:
+        "200":
+          description: Complete session metadata, events, and asset descriptors
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/SessionEnvelope"
+        "401":
+          $ref: "#/components/responses/Error"
+        "404":
+          $ref: "#/components/responses/Error"
     delete:
       tags: [Sessions]
       operationId: deleteSession
@@ -824,7 +853,7 @@ components:
               format: date-time
     RemoteSession:
       type: object
-      required: [id, title, startTime, durationMs, revision]
+      required: [id, title, startTime, durationMs, revision, updatedAt]
       properties:
         id:
           type: string
@@ -845,6 +874,9 @@ components:
         revision:
           type: integer
           format: int64
+        updatedAt:
+          type: string
+          format: date-time
         deletedAt:
           type: [string, "null"]
           format: date-time
@@ -856,6 +888,42 @@ components:
           type: array
           items:
             $ref: "#/components/schemas/Asset"
+    SessionSyncIndexItem:
+      type: object
+      required:
+        [id, title, startTime, durationMs, photoCount, noteCount, revision, updatedAt]
+      properties:
+        id:
+          type: string
+          format: uuid
+        clientId:
+          type: [string, "null"]
+        title:
+          type: string
+        startTime:
+          type: string
+          format: date-time
+        endTime:
+          type: [string, "null"]
+          format: date-time
+        durationMs:
+          type: integer
+          format: int64
+        photoCount:
+          type: integer
+          minimum: 0
+        noteCount:
+          type: integer
+          minimum: 0
+        revision:
+          type: integer
+          format: int64
+        updatedAt:
+          type: string
+          format: date-time
+        deletedAt:
+          type: [string, "null"]
+          format: date-time
     SessionEnvelope:
       allOf:
         - $ref: "#/components/schemas/EnvelopeBase"
@@ -864,7 +932,7 @@ components:
           properties:
             data:
               $ref: "#/components/schemas/RemoteSession"
-    SessionListEnvelope:
+    SessionSyncIndexEnvelope:
       allOf:
         - $ref: "#/components/schemas/EnvelopeBase"
         - type: object
@@ -873,7 +941,7 @@ components:
             data:
               type: array
               items:
-                $ref: "#/components/schemas/RemoteSession"
+                $ref: "#/components/schemas/SessionSyncIndexItem"
     ConflictEnvelope:
       allOf:
         - $ref: "#/components/schemas/EnvelopeBase"

+ 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
+        )
+    }
+}