Explorar o código

feat(audio/timeline): 优化多轨时间轴波形桶采样算法与网络层适配

bob.yuxinyang hai 1 mes
pai
achega
6676676742

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

@@ -4,7 +4,7 @@ protocol NetworkServiceProtocol {
     func fetchSessionIndex() async throws -> [RemoteSessionIndex]
     func fetchSession(sessionID: String) async throws -> RemoteSession
     func syncSession(
-        _ session: CelestiaSession,
+        _ session: SyncSessionSnapshot,
         deletedEventClientIDs: [String]
     ) async throws -> RemoteSession
     func deleteSession(sessionID: String) async throws

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 567 - 147
CelestiaTrace/Services/Network/RemoteNetworkService.swift


+ 3 - 2
CelestiaTrace/ViewModels/RecordingViewModel.swift

@@ -172,8 +172,9 @@ final class RecordingViewModel {
 
     private func startSyncTimer() {
         syncTimer?.invalidate()
-        // ~60 Hz polling to bridge ObservableObject → @Observable
-        syncTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in
+        // 20 Hz is sufficient for the amplitude display while avoiding a
+        // permanent 60 Hz main-run-loop wakeup during multi-hour recordings.
+        syncTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 20.0, repeats: true) { [weak self] _ in
             self?.syncState()
         }
         // Ensure timer fires during UI tracking (scrolling, etc.)

+ 73 - 18
CelestiaTrace/Views/Detail/MultiTrackTimeline.swift

@@ -543,32 +543,87 @@ private struct PlaybackWaveformCanvas: View, Equatable {
             )
 
             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
+            let envelopes = WaveformMinMaxDownsampler.envelopes(
+                waveformSamples,
+                targetBucketCount: barCount
+            )
+            guard !envelopes.isEmpty else { return }
+            let bucketWidth = waveformWidth / CGFloat(envelopes.count)
+
+            for (index, envelope) in envelopes.enumerated() {
+                let maximumHeight = max(
+                    CGFloat(max(envelope.maximum, 0)) * size.height * 0.75,
+                    1
                 )
+                let x = startX + CGFloat(index) * bucketWidth
                 context.fill(
-                    Path(rect),
-                    with: .color(Color.primary.opacity(0.85))
+                    Path(CGRect(
+                        x: x,
+                        y: centerY - maximumHeight / 2,
+                        width: min(barWidth, bucketWidth),
+                        height: maximumHeight
+                    )),
+                    with: .color(Color.primary.opacity(0.58))
                 )
+
+                let minimumHeight = CGFloat(max(envelope.minimum, 0)) * size.height * 0.75
+                if minimumHeight >= 1 {
+                    context.fill(
+                        Path(CGRect(
+                            x: x,
+                            y: centerY - minimumHeight / 2,
+                            width: min(barWidth, bucketWidth),
+                            height: minimumHeight
+                        )),
+                        with: .color(Color.primary.opacity(0.9))
+                    )
+                }
             }
         }
     }
 }
 
+private struct WaveformEnvelope {
+    let minimum: Float
+    let maximum: Float
+}
+
+/// Reduces an arbitrarily long recording to at most one min/max envelope per
+/// drawable screen bucket. Peaks survive zooming and scrolling without
+/// creating one SwiftUI element (or one Canvas draw) per source sample.
+private enum WaveformMinMaxDownsampler {
+    static func envelopes(
+        _ samples: [Float],
+        targetBucketCount: Int
+    ) -> [WaveformEnvelope] {
+        guard !samples.isEmpty, targetBucketCount > 0 else { return [] }
+        let bucketCount = min(samples.count, targetBucketCount)
+        var result: [WaveformEnvelope] = []
+        result.reserveCapacity(bucketCount)
+
+        for bucket in 0..<bucketCount {
+            let lowerBound = bucket * samples.count / bucketCount
+            let upperBound = max(
+                lowerBound + 1,
+                (bucket + 1) * samples.count / bucketCount
+            )
+            var minimum = Float.greatestFiniteMagnitude
+            var maximum = -Float.greatestFiniteMagnitude
+            for sample in samples[lowerBound..<min(upperBound, samples.count)] {
+                minimum = min(minimum, sample)
+                maximum = max(maximum, sample)
+            }
+            result.append(
+                WaveformEnvelope(
+                    minimum: minimum.isFinite ? minimum : 0,
+                    maximum: maximum.isFinite ? maximum : 0
+                )
+            )
+        }
+        return result
+    }
+}
+
 // MARK: - Preview
 
 #Preview {

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio