Bläddra i källkod

feat(audio): 统一波形振幅归一化、优化单次解码静音与波形分析、增强多轨时间轴拖拽交互

bob.yuxinyang 1 månad sedan
förälder
incheckning
01391b6e52

+ 15 - 0
CelestiaTrace/Services/Audio/AudioRecorderProtocol.swift

@@ -1,6 +1,21 @@
 import Foundation
 import Combine
 
+/// Shared metering conversion used by both live recording and playback analysis.
+enum AudioLevelNormalizer {
+    static let minimumDecibels: Float = -50
+
+    static func normalizedLevel(
+        decibels: Float,
+        minimumDecibels: Float = AudioLevelNormalizer.minimumDecibels
+    ) -> Float {
+        guard decibels.isFinite else { return 0 }
+        if decibels < minimumDecibels { return 0 }
+        if decibels >= 0 { return 1 }
+        return min(max((decibels - minimumDecibels) / -minimumDecibels, 0), 1)
+    }
+}
+
 /// The physical source selected for one recording segment.
 enum RecordingSourceChoice: Identifiable, Equatable {
     case iPhone

+ 2 - 10
CelestiaTrace/Services/Audio/RealAudioRecorder.swift

@@ -150,16 +150,8 @@ final class RealAudioRecorder: AudioRecorderProtocol {
         // Get average decibels for channel 0
         let power = recorder.averagePower(forChannel: 0)
         
-        // Map decibels (-50.0 dB to 0.0 dB) to 0.0 - 1.0 amplitude
-        let minDb: Float = -50.0
-        let level: Float
-        if power < minDb {
-            level = 0
-        } else if power >= 0 {
-            level = 1
-        } else {
-            level = (power - minDb) / -minDb
-        }
+        // Keep live and playback waveform levels on the same scale.
+        let level = AudioLevelNormalizer.normalizedLevel(decibels: power)
         
         currentAmplitude = level
         waveformSamples.append(level)

+ 53 - 12
CelestiaTrace/Services/Audio/SilenceDetector.swift

@@ -16,6 +16,17 @@ public struct SilenceRange: Codable, Hashable, Sendable {
     }
 }
 
+/// Playback analysis generated in one pass over the decoded audio.
+public struct AudioAnalysisResult: Sendable {
+    public let waveformSamples: [Float]
+    public let silentRanges: [SilenceRange]
+
+    public init(waveformSamples: [Float], silentRanges: [SilenceRange]) {
+        self.waveformSamples = waveformSamples
+        self.silentRanges = silentRanges
+    }
+}
+
 public final class SilenceDetector: Sendable {
     /// Detects silent ranges in the specified audio file.
     /// - Parameters:
@@ -28,35 +39,57 @@ public final class SilenceDetector: Sendable {
         thresholdDB: Float = -40.0,
         minDuration: TimeInterval = 2.0
     ) async -> [SilenceRange] {
+        await analyze(
+            audioURL,
+            silenceThresholdDB: thresholdDB,
+            minimumSilenceDuration: minDuration
+        ).silentRanges
+    }
+
+    /// Extracts waveform levels and silence ranges together to avoid decoding twice.
+    /// Waveform samples use the same 50ms cadence and dB normalization as live recording.
+    public static func analyze(
+        _ audioURL: URL,
+        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 []
+            return AudioAnalysisResult(waveformSamples: [], silentRanges: [])
         }
         
-        let task = Task.detached(priority: .userInitiated) { () -> [SilenceRange] in
+        let task = Task.detached(priority: .userInitiated) { () -> AudioAnalysisResult in
             guard let audioFile = try? AVAudioFile(forReading: validURL) else {
-                return []
+                return AudioAnalysisResult(waveformSamples: [], silentRanges: [])
             }
             
             let format = audioFile.processingFormat
             let sampleRate = format.sampleRate
-            guard sampleRate > 0 else { return [] }
+            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 []
+                return AudioAnalysisResult(waveformSamples: [], silentRanges: [])
             }
             
+            var waveformSamples: [Float] = []
+            waveformSamples.reserveCapacity(Int(Double(audioFile.length) / sampleRate / 0.05) + 1)
             var silentRanges: [SilenceRange] = []
             var isSilent = false
             var silenceStart: TimeInterval = 0
             
-            // Sub-chunk size of 100ms
-            let subChunkSize = Int(sampleRate * 0.1)
+            // 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: [])
+                    }
+
                     let framesToRead = min(bufferSize, AVAudioFrameCount(audioFile.length - audioFile.framePosition))
                     if framesToRead <= 0 { break }
                     
@@ -79,12 +112,15 @@ public final class SilenceDetector: Sendable {
                         
                         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 < thresholdDB
+                        let isChunkSilent = db < silenceThresholdDB
                         
                         if isChunkSilent {
                             if !isSilent {
@@ -95,7 +131,7 @@ public final class SilenceDetector: Sendable {
                             if isSilent {
                                 isSilent = false
                                 let silenceEnd = timeInSeconds
-                                if silenceEnd - silenceStart >= minDuration {
+                                if silenceEnd - silenceStart >= minimumSilenceDuration {
                                     silentRanges.append(SilenceRange(start: silenceStart, end: silenceEnd))
                                 }
                             }
@@ -108,7 +144,7 @@ public final class SilenceDetector: Sendable {
                 // If still silent at the end of the file
                 if isSilent {
                     let silenceEnd = Double(audioFile.length) / sampleRate
-                    if silenceEnd - silenceStart >= minDuration {
+                    if silenceEnd - silenceStart >= minimumSilenceDuration {
                         silentRanges.append(SilenceRange(start: silenceStart, end: silenceEnd))
                     }
                 }
@@ -116,8 +152,13 @@ public final class SilenceDetector: Sendable {
                 print("[SilenceDetector] Error reading audio file: \(error.localizedDescription)")
             }
             
-            print("[SilenceDetector] Detection complete. Found \(silentRanges.count) silence ranges.")
-            return silentRanges
+            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
     }

+ 34 - 12
CelestiaTrace/ViewModels/PlaybackViewModel.swift

@@ -34,9 +34,16 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
     /// Detected silent intervals
     var silentRanges: [SilenceRange] = []
 
+    /// Amplitude levels extracted from the playback file on the live-recording scale.
+    var waveformSamples: [Float] = []
+
+    /// Whether playback waveform and silence analysis is active.
+    var isAnalyzingAudio: Bool = false
+
     // MARK: - Audio Player Private State
     private var audioPlayer: AVAudioPlayer?
     private var activeAudioURL: URL?
+    private var audioAnalysisTask: Task<Void, Never>?
 
     // MARK: - Computed Properties
 
@@ -78,6 +85,7 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
 
     deinit {
         playbackTimer?.invalidate()
+        audioAnalysisTask?.cancel()
         audioPlayer?.stop()
     }
 
@@ -109,11 +117,10 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
             player.prepareToPlay()
             player.volume = 1.0
             self.audioPlayer = player
-            if durationMs <= 0 {
-                self.totalDurationMs = player.duration * 1000.0
-            } else {
-                self.totalDurationMs = durationMs
-            }
+            // 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)"
@@ -123,7 +130,9 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
     private func setupAudioSession() {
         let session = AVAudioSession.sharedInstance()
         do {
-            try session.setCategory(.playback, mode: .default, options: [.defaultToSpeaker])
+            // `.playback` already routes through the speaker by default.
+            // `.defaultToSpeaker` is valid only with `.playAndRecord`.
+            try session.setCategory(.playback, mode: .default)
             try session.setActive(true)
         } catch {
             print("[PlaybackViewModel] Failed to setup AVAudioSession: \(error.localizedDescription)")
@@ -228,20 +237,33 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
 
     /// Runs silence detection asynchronously.
     func analyzeSilence(audioURL: URL?) {
+        analyzeAudio(audioURL: audioURL)
+    }
+
+    /// Extracts the real waveform and silence ranges in one background pass.
+    func analyzeAudio(audioURL: URL?) {
+        audioAnalysisTask?.cancel()
         let targetPath = audioURL?.path ?? activeAudioURL?.path
         guard let url = AudioPathHelper.resolveURL(for: targetPath) else {
+            waveformSamples = []
             silentRanges = []
             isAnalyzingSilence = false
+            isAnalyzingAudio = false
             return
         }
         
+        waveformSamples = []
+        silentRanges = []
         isAnalyzingSilence = true
-        Task {
-            let ranges = await SilenceDetector.detectSilence(in: url)
-            await MainActor.run {
-                self.silentRanges = ranges
-                self.isAnalyzingSilence = false
-            }
+        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
         }
     }
     

+ 123 - 16
CelestiaTrace/Views/Detail/MultiTrackTimeline.swift

@@ -11,8 +11,11 @@ struct MultiTrackTimeline: View {
     let events: [CelestiaTimelineEvent]
     @Binding var currentTimeMs: Double
     let totalDurationMs: Double
+    var waveformSamples: [Float] = []
     var silentRanges: [SilenceRange] = []
 
+    @State private var playheadDragStartTimeMs: Double?
+
     /// Scale: points per second
     private let pointsPerSecond: CGFloat = 2.5
     /// Minimum timeline width
@@ -62,6 +65,7 @@ struct MultiTrackTimeline: View {
                 .clipShape(RoundedRectangle(cornerRadius: 10))
                 .businessBorder(cornerRadius: 10)
                 .onChange(of: currentTimeMs) { _, _ in
+                    guard playheadDragStartTimeMs == nil else { return }
                     withAnimation(.easeOut(duration: 0.2)) {
                         scrollProxy.scrollTo("playhead", anchor: .center)
                     }
@@ -115,15 +119,64 @@ struct MultiTrackTimeline: View {
     // MARK: - Audio Track
 
     private var audioTrack: some View {
-        ZStack {
-            Rectangle()
-                .fill(Color.primary.opacity(0.12))
-                .frame(height: 1)
-            ForEach(Array(silentRanges.enumerated()), id: \.offset) { _, range in
-                Rectangle()
-                    .fill(Color.secondary.opacity(0.16))
-                    .frame(width: max(1, xPosition(for: range.end * 1000) - xPosition(for: range.start * 1000)))
-                    .offset(x: xPosition(for: range.start * 1000) - timelineWidth / 2)
+        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))
+                )
             }
         }
     }
@@ -169,11 +222,50 @@ struct MultiTrackTimeline: View {
 
     private var playhead: some View {
         let x = xPosition(for: currentTimeMs)
-        return Rectangle()
-            .fill(Color.primary) // Dynamic primary color for playhead line
-            .frame(width: 1.0)
-            .offset(x: x)
-            .animation(.easeOut(duration: 0.15), value: currentTimeMs)
+        return ZStack {
+            Color.clear
+                .contentShape(Rectangle())
+
+            Rectangle()
+                .fill(Color.primary)
+                .frame(width: 1)
+        }
+            .frame(width: 28)
+            .offset(x: x - 14)
+            .animation(
+                playheadDragStartTimeMs == nil ? .easeOut(duration: 0.15) : nil,
+                value: currentTimeMs
+            )
+            .highPriorityGesture(playheadDragGesture)
+            .accessibilityLabel("当前播放位置")
+            .accessibilityValue(formattedTime(currentTimeMs))
+            .accessibilityAdjustableAction { direction in
+                let stepMs = 1_000.0
+                switch direction {
+                case .increment:
+                    currentTimeMs = min(totalDurationMs, currentTimeMs + stepMs)
+                case .decrement:
+                    currentTimeMs = max(0, currentTimeMs - stepMs)
+                @unknown default:
+                    break
+                }
+            }
+    }
+
+    private var playheadDragGesture: some Gesture {
+        DragGesture(minimumDistance: 0, coordinateSpace: .global)
+            .onChanged { value in
+                let startTime = playheadDragStartTimeMs ?? currentTimeMs
+                if playheadDragStartTimeMs == nil {
+                    playheadDragStartTimeMs = startTime
+                }
+
+                let startX = xPosition(for: startTime)
+                currentTimeMs = timeMs(forXPosition: startX + value.translation.width)
+            }
+            .onEnded { _ in
+                playheadDragStartTimeMs = nil
+            }
     }
 
     // MARK: - Track Divider
@@ -210,10 +302,24 @@ struct MultiTrackTimeline: View {
         guard totalDurationMs > 0 else { return 30 }
         let padding: CGFloat = 30
         let usableWidth = timelineWidth - padding * 2
-        let ratio = timeMs / totalDurationMs
+        let ratio = min(max(timeMs / totalDurationMs, 0), 1)
         return padding + usableWidth * CGFloat(ratio)
     }
 
+    private func timeMs(forXPosition x: CGFloat) -> Double {
+        guard totalDurationMs > 0 else { return 0 }
+        let padding: CGFloat = 30
+        let usableWidth = timelineWidth - padding * 2
+        guard usableWidth > 0 else { return 0 }
+        let ratio = min(max((x - padding) / usableWidth, 0), 1)
+        return Double(ratio) * totalDurationMs
+    }
+
+    private func formattedTime(_ timeMs: Double) -> String {
+        let totalSeconds = max(0, Int(timeMs / 1_000))
+        return String(format: "%d:%02d", totalSeconds / 60, totalSeconds % 60)
+    }
+
     private var photoEvents: [CelestiaTimelineEvent] {
         events.filter { $0.eventType == "PHOTO" }
     }
@@ -230,7 +336,8 @@ struct MultiTrackTimeline: View {
         MultiTrackTimeline(
             events: [],
             currentTimeMs: .constant(30000),
-            totalDurationMs: 120000
+            totalDurationMs: 120000,
+            waveformSamples: [0.1, 0.3, 0.8, 0.4, 0.2]
         )
         .frame(height: 140)
         .padding()

+ 8 - 7
CelestiaTrace/Views/Detail/SessionDetailView.swift

@@ -89,7 +89,7 @@ struct SessionDetailView: View {
             ) {
                 playbackVM.totalDurationMs = Double(session.durationMs)
                 playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
-                playbackVM.analyzeSilence(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
+                playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
             }
         }
         .sheet(isPresented: $showRecordingSourcePicker) {
@@ -102,7 +102,7 @@ struct SessionDetailView: View {
         .onAppear {
             playbackVM.totalDurationMs = Double(session.durationMs)
             playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
-            playbackVM.analyzeSilence(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
+            playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
         }
     }
 
@@ -197,7 +197,7 @@ struct SessionDetailView: View {
 
                 // Sync status
                 VStack(spacing: 4) {
-                    Image(systemName: session.isSynced ? "cloud" : "cloud.dashed")
+                    Image(systemName: session.isSynced ? "cloud.fill" : "cloud")
                         .font(.system(size: 14, weight: .light))
                         .foregroundStyle(Color.secondary)
 
@@ -242,7 +242,8 @@ struct SessionDetailView: View {
                     get: { playbackVM.currentPlaybackTimeMs },
                     set: { playbackVM.seekTo(timeMs: $0) }
                 ),
-                totalDurationMs: Double(session.durationMs),
+                totalDurationMs: playbackVM.totalDurationMs,
+                waveformSamples: playbackVM.waveformSamples,
                 silentRanges: playbackVM.silentRanges
             )
             .frame(height: 140)
@@ -271,7 +272,7 @@ struct SessionDetailView: View {
                 value: Binding(
                     get: { playbackVM.progress },
                     set: { newValue in
-                        playbackVM.seekTo(timeMs: newValue * Double(session.durationMs))
+                        playbackVM.seekTo(timeMs: newValue * playbackVM.totalDurationMs)
                     }
                 ),
                 in: 0...1
@@ -308,7 +309,7 @@ struct SessionDetailView: View {
 
                 // Forward 10s
                 Button {
-                    let target = min(Double(session.durationMs), playbackVM.currentPlaybackTimeMs + 10_000)
+                    let target = min(playbackVM.totalDurationMs, playbackVM.currentPlaybackTimeMs + 10_000)
                     playbackVM.seekTo(timeMs: target)
                 } label: {
                     Image(systemName: "goforward.10")
@@ -408,7 +409,7 @@ struct SessionDetailView: View {
     }
 
     private var totalTimeFormatted: String {
-        let totalSeconds = Int(session.durationMs / 1000)
+        let totalSeconds = Int(playbackVM.totalDurationMs / 1000)
         let hours = totalSeconds / 3600
         let minutes = (totalSeconds % 3600) / 60
         let seconds = totalSeconds % 60