From 006910dc763955bbf7b9f36f78a78e8457b9dec4 Mon Sep 17 00:00:00 2001 From: Dom Esposito Date: Fri, 14 Aug 2026 19:18:44 -0700 Subject: [PATCH 1/2] feat(mac-overlay): refine audio indicator response - filter low-frequency noise from the visual meter - keep the listening ripple active beneath live volume - tune gain and motion for smoother transitions --- macOS/Core/Audio/AudioRecorder+Session.swift | 2 ++ .../Core/Audio/AudioRecorder+Streaming.swift | 29 ++++++++++++---- macOS/Core/Audio/AudioRecorder.swift | 3 ++ macOS/Core/Overlay/AudioIndicatorDriver.swift | 8 +++-- macOS/Views/Components/LogoBarView.swift | 33 +++++++++++++------ 5 files changed, 57 insertions(+), 18 deletions(-) diff --git a/macOS/Core/Audio/AudioRecorder+Session.swift b/macOS/Core/Audio/AudioRecorder+Session.swift index d1e39a55..240725f9 100644 --- a/macOS/Core/Audio/AudioRecorder+Session.swift +++ b/macOS/Core/Audio/AudioRecorder+Session.swift @@ -67,6 +67,8 @@ extension AudioRecorder { lastNonDeadSignalTime = Date.distantPast lastVisualActiveSignalTime = Date.distantPast + visualMeterPreviousInput = 0 + visualMeterPreviousOutput = 0 currentActiveSignalRunDuration = 0 maxActiveSignalRunDuration = 0 lastCaptureHadNonDeadSignal = false diff --git a/macOS/Core/Audio/AudioRecorder+Streaming.swift b/macOS/Core/Audio/AudioRecorder+Streaming.swift index 012b7dbe..c1f1db12 100644 --- a/macOS/Core/Audio/AudioRecorder+Streaming.swift +++ b/macOS/Core/Audio/AudioRecorder+Streaming.swift @@ -57,24 +57,36 @@ extension AudioRecorder: AVCaptureAudioDataOutputSampleBufferDelegate { audioData.append(contentsOf: frames) } - // Calculate RMS for UI visualization. + let sampleInterval = 1 / Float(outputFormat.sampleRate) + let highPassTimeConstant = 1 / (2 * Float.pi * visualMeterHighPassCutoffFrequency) + let highPassCoefficient = highPassTimeConstant / (highPassTimeConstant + sampleInterval) + + // Calculate raw capture RMS and a high-passed RMS for UI visualization. var sum: Float = 0 + var visualSum: Float = 0 var peak: Float = 0 + var previousVisualInput = visualMeterPreviousInput + var previousVisualOutput = visualMeterPreviousOutput for frame in frames { sum += frame * frame + + let visualFrame = highPassCoefficient * (previousVisualOutput + frame - previousVisualInput) + visualSum += visualFrame * visualFrame + previousVisualInput = frame + previousVisualOutput = visualFrame + let magnitude = abs(frame) if magnitude > peak { peak = magnitude } } + visualMeterPreviousInput = previousVisualInput + visualMeterPreviousOutput = previousVisualOutput let rms = sqrt(sum / Float(frames.count)) + let visualMeterRMS = sqrt(visualSum / Float(frames.count)) let frameDuration = TimeInterval(Double(convertedBuffer.frameLength) / outputFormat.sampleRate) - // Visual meter scaling only. This does not modify captured audio samples. - // Keep boosted UI response so waveform movement remains readable. - let level = min(max(sqrt(rms) * 3.5, 0.0), 1.0) - let now = Date() if peak > deadSignalPeakThreshold { lastNonDeadSignalTime = now @@ -90,7 +102,12 @@ extension AudioRecorder: AVCaptureAudioDataOutputSampleBufferDelegate { } let visualActiveThreshold = sessionActiveSignalRMSThreshold * visualActiveSignalThresholdMultiplier - if rms > visualActiveThreshold { + // Visual meter scaling only. This does not modify captured audio samples. + // Remove low-level background noise before boosting the remaining signal. + let visualRMS = max(visualMeterRMS - visualActiveThreshold, 0) + let level = min(sqrt(visualRMS) * 5.5, 1.0) + + if visualMeterRMS > visualActiveThreshold { lastVisualActiveSignalTime = now } diff --git a/macOS/Core/Audio/AudioRecorder.swift b/macOS/Core/Audio/AudioRecorder.swift index d64569d2..4bd872aa 100644 --- a/macOS/Core/Audio/AudioRecorder.swift +++ b/macOS/Core/Audio/AudioRecorder.swift @@ -51,10 +51,13 @@ class AudioRecorder: NSObject, ObservableObject { var sessionInputVolumeScalar: Float = AudioSilenceGatePolicy.defaultInputVolumeScalar var sessionThresholdScale: Float = 1.0 let deadStateHoldDuration: TimeInterval = 0.35 + let visualMeterHighPassCutoffFrequency: Float = 120 let visualActiveStateHoldDuration: TimeInterval = 0.16 let visualActiveSignalThresholdMultiplier: Float = 1.85 var lastNonDeadSignalTime: Date = Date.distantPast var lastVisualActiveSignalTime: Date = Date.distantPast + var visualMeterPreviousInput: Float = 0 + var visualMeterPreviousOutput: Float = 0 var currentActiveSignalRunDuration: TimeInterval = 0 var maxActiveSignalRunDuration: TimeInterval = 0 var lastCaptureHadNonDeadSignal: Bool = false diff --git a/macOS/Core/Overlay/AudioIndicatorDriver.swift b/macOS/Core/Overlay/AudioIndicatorDriver.swift index 5779440c..8604aeac 100644 --- a/macOS/Core/Overlay/AudioIndicatorDriver.swift +++ b/macOS/Core/Overlay/AudioIndicatorDriver.swift @@ -47,7 +47,8 @@ final class AudioIndicatorDriver: ObservableObject { static let timerInterval: TimeInterval = AudioIndicatorDriver.animationFrameDuration static let meterPollInterval: TimeInterval = 1.0 / 30.0 static let sampleFreshnessWindow: TimeInterval = 0.35 - static let smoothingRate: CGFloat = 16 + static let attackSmoothingRate: CGFloat = 15 + static let decaySmoothingRate: CGFloat = 5 } var sampleProvider: (() -> AudioIndicatorSample?)? @@ -114,7 +115,10 @@ final class AudioIndicatorDriver: ObservableObject { refreshSampleIfNeeded(at: timestamp, sample: sample) - let smoothing = min(delta * Metrics.smoothingRate, 1) + let smoothingRate = targetLevel > displayedLevel + ? Metrics.attackSmoothingRate + : Metrics.decaySmoothingRate + let smoothing = min(delta * smoothingRate, 1) displayedLevel += (targetLevel - displayedLevel) * smoothing publishTimelineState(at: timestamp) diff --git a/macOS/Views/Components/LogoBarView.swift b/macOS/Views/Components/LogoBarView.swift index 89e623e7..98cd0ca1 100644 --- a/macOS/Views/Components/LogoBarView.swift +++ b/macOS/Views/Components/LogoBarView.swift @@ -162,7 +162,7 @@ private struct IndicatorLogoView: View { if phase == .processing { return .processing } - if phase == .listening && timelineState.signalState == .lowActivity { + if phase == .listening { return .lowActivity } return nil @@ -223,10 +223,16 @@ private struct TimelineIndicatorBarsView: View { for index in 0..<5 { let barHeight: CGFloat = switch mode { case .lowActivity: - lowActivityBarHeight( - index: index, - phase: phase, - displayedLevel: displayedLevel + max( + lowActivityBarHeight( + index: index, + phase: phase, + displayedLevel: displayedLevel + ), + listeningAudioBarHeight( + index: index, + displayedLevel: displayedLevel + ) ) case .processing: processingBarHeight(index: index, phase: phase) @@ -300,9 +306,7 @@ private struct ReactiveIndicatorSegmentView: View { } private var height: CGFloat { - let minHeight: CGFloat = isDevModeOversized ? 18 : 6 let flatHeight: CGFloat = isDevModeOversized ? 9 : 3 - let maxHeight: CGFloat = isDevModeOversized ? 170 : 30 guard phase == .listening else { return flatHeight @@ -320,12 +324,21 @@ private struct ReactiveIndicatorSegmentView: View { ) } - let multipliers: [CGFloat] = [0.4, 0.7, 1.0, 0.7, 0.4] - let dynamicHeight = timelineState.displayedLevel * multipliers[index] * maxHeight - return max(minHeight, dynamicHeight) + return listeningAudioBarHeight( + index: index, + displayedLevel: timelineState.displayedLevel + ) } } +private func listeningAudioBarHeight(index: Int, displayedLevel: CGFloat) -> CGFloat { + let minHeight: CGFloat = isDevModeOversized ? 18 : 6 + let maxHeight: CGFloat = isDevModeOversized ? 170 : 30 + let multipliers: [CGFloat] = [0.4, 0.7, 1.0, 0.7, 0.4] + let dynamicHeight = displayedLevel * multipliers[index] * maxHeight + return max(minHeight, dynamicHeight) +} + private func processingBarHeight(index: Int, phase: Double) -> CGFloat { let flatHeight: CGFloat = isDevModeOversized ? 9 : 3 let waveOffset = phase + Double(index) * 0.8 From bb17b02e54a2c454f28f3e11f054bc504fb9614b Mon Sep 17 00:00:00 2001 From: Dom Esposito Date: Fri, 14 Aug 2026 19:32:08 -0700 Subject: [PATCH 2/2] feat(ios-keyboard): match audio indicator behavior - filter low-frequency noise without altering captured audio - keep the listening ripple active beneath live volume - tune platform-specific gain and motion for visual parity --- .../Core/Dictation/AudioIndicatorDriver.swift | 7 +++-- .../Components/KeyboardLogoBarView.swift | 29 ++++++++----------- .../Core/Audio/AudioRecorder+Streaming.swift | 28 ++++++++++++++++-- 3 files changed, 43 insertions(+), 21 deletions(-) diff --git a/iOS/KeyVox Keyboard/Core/Dictation/AudioIndicatorDriver.swift b/iOS/KeyVox Keyboard/Core/Dictation/AudioIndicatorDriver.swift index 7162955a..32d58502 100644 --- a/iOS/KeyVox Keyboard/Core/Dictation/AudioIndicatorDriver.swift +++ b/iOS/KeyVox Keyboard/Core/Dictation/AudioIndicatorDriver.swift @@ -43,7 +43,8 @@ final class AudioIndicatorDriver { static let speakingMeterPollInterval: CFTimeInterval = 1.0 / 60.0 static let sampleFreshnessWindow: TimeInterval = 0.35 static let speakingSampleFreshnessWindow: TimeInterval = 0.35 - static let listeningSmoothingRate: CGFloat = 16 + static let listeningAttackSmoothingRate: CGFloat = 25 + static let listeningDecaySmoothingRate: CGFloat = 8 static let speakingAttackSmoothingRate: CGFloat = 34 static let speakingDecaySmoothingRate: CGFloat = 18 } @@ -104,7 +105,9 @@ final class AudioIndicatorDriver { ? Metrics.speakingAttackSmoothingRate : Metrics.speakingDecaySmoothingRate } else { - smoothingRate = Metrics.listeningSmoothingRate + smoothingRate = targetLevel > displayedLevel + ? Metrics.listeningAttackSmoothingRate + : Metrics.listeningDecaySmoothingRate } let smoothing = CGFloat(min(delta * Double(smoothingRate), 1)) diff --git a/iOS/KeyVox Keyboard/Views/Components/KeyboardLogoBarView.swift b/iOS/KeyVox Keyboard/Views/Components/KeyboardLogoBarView.swift index bc9a017e..3e87f201 100644 --- a/iOS/KeyVox Keyboard/Views/Components/KeyboardLogoBarView.swift +++ b/iOS/KeyVox Keyboard/Views/Components/KeyboardLogoBarView.swift @@ -443,26 +443,21 @@ final class KeyboardLogoBarView: UIControl { return flatHeight } - if timelineState.signalState == .inactive { - return flatHeight - } - - if timelineState.signalState == .lowActivity { - let quietWaveOffset = timelineState.lowActivityPhase + Double(index) * 0.8 - let quietRipple = (sin(quietWaveOffset) * 0.5) + 0.5 - let wiggleOffset = (timelineState.lowActivityPhase * 0.9) + Double(index) * 1.35 - let ambientWiggle = (sin(wiggleOffset) * 0.5) + 0.5 - let quietLevel = min(max(timelineState.displayedLevel / 0.14, 0), 1) - return flatHeight - + (1.2 * scale) - + (CGFloat(quietLevel) * (0.8 * scale)) - + (CGFloat(ambientWiggle) * (0.9 * scale)) - + (CGFloat(quietRipple) * (2.0 * scale)) - } + let quietWaveOffset = timelineState.lowActivityPhase + Double(index) * 0.8 + let quietRipple = (sin(quietWaveOffset) * 0.5) + 0.5 + let wiggleOffset = (timelineState.lowActivityPhase * 0.9) + Double(index) * 1.35 + let ambientWiggle = (sin(wiggleOffset) * 0.5) + 0.5 + let quietLevel = min(max(timelineState.displayedLevel / 0.14, 0), 1) + let rippleHeight = flatHeight + + (1.2 * scale) + + (CGFloat(quietLevel) * (0.8 * scale)) + + (CGFloat(ambientWiggle) * (0.9 * scale)) + + (CGFloat(quietRipple) * (2.0 * scale)) let multipliers: [CGFloat] = [0.4, 0.7, 1.0, 0.7, 0.4] let dynamicHeight = timelineState.displayedLevel * multipliers[index] * maxHeight - return max(minHeight, dynamicHeight) + let audioHeight = max(minHeight, dynamicHeight) + return max(rippleHeight, audioHeight) } private func pixelAligned(_ rect: CGRect) -> CGRect { diff --git a/iOS/KeyVox iOS/Core/Audio/AudioRecorder+Streaming.swift b/iOS/KeyVox iOS/Core/Audio/AudioRecorder+Streaming.swift index 3b711192..90fe3002 100644 --- a/iOS/KeyVox iOS/Core/Audio/AudioRecorder+Streaming.swift +++ b/iOS/KeyVox iOS/Core/Audio/AudioRecorder+Streaming.swift @@ -25,6 +25,8 @@ final class AudioCaptureAccumulator { private var lastNonDeadSignalTime: Date = .distantPast private var lastVisualActiveSignalTime: Date = .distantPast private var lastMeaningfulSpeechTime: Date = .distantPast + private var visualMeterPreviousInput: Float = 0 + private var visualMeterPreviousOutput: Float = 0 private var currentActiveSignalRunDuration: TimeInterval = 0 private var maxActiveSignalRunDuration: TimeInterval = 0 private var hadNonDeadSignal = false @@ -36,6 +38,8 @@ final class AudioCaptureAccumulator { lastNonDeadSignalTime = .distantPast lastVisualActiveSignalTime = .distantPast lastMeaningfulSpeechTime = .distantPast + visualMeterPreviousInput = 0 + visualMeterPreviousOutput = 0 currentActiveSignalRunDuration = 0 maxActiveSignalRunDuration = 0 hadNonDeadSignal = false @@ -113,19 +117,35 @@ final class AudioCaptureAccumulator { let frames = Array(UnsafeBufferPointer(start: floatData[0], count: frameCount)) audioData.append(contentsOf: frames) + let visualMeterHighPassCutoffFrequency: Float = 120 + let sampleInterval = 1 / Float(outputFormat.sampleRate) + let highPassTimeConstant = 1 / (2 * Float.pi * visualMeterHighPassCutoffFrequency) + let highPassCoefficient = highPassTimeConstant / (highPassTimeConstant + sampleInterval) + var sumSquares: Float = 0 + var visualSumSquares: Float = 0 var peak: Float = 0 + var previousVisualInput = visualMeterPreviousInput + var previousVisualOutput = visualMeterPreviousOutput for frame in frames { sumSquares += frame * frame + + let visualFrame = highPassCoefficient * (previousVisualOutput + frame - previousVisualInput) + visualSumSquares += visualFrame * visualFrame + previousVisualInput = frame + previousVisualOutput = visualFrame + let magnitude = abs(frame) if magnitude > peak { peak = magnitude } } + visualMeterPreviousInput = previousVisualInput + visualMeterPreviousOutput = previousVisualOutput let rms = sqrt(sumSquares / Float(frameCount)) + let visualMeterRMS = sqrt(visualSumSquares / Float(frameCount)) let frameDuration = TimeInterval(Double(convertedBuffer.frameLength) / outputFormat.sampleRate) - let level = min(max(sqrt(rms) * 2.5, 0), 1) let now = Date() if peak > deadSignalPeakThreshold { @@ -143,7 +163,11 @@ final class AudioCaptureAccumulator { } let visualActiveThreshold = activeSignalRMSThreshold * visualActiveSignalThresholdMultiplier - if rms > visualActiveThreshold { + // Visual meter scaling only. This does not modify captured audio samples. + let visualRMS = max(visualMeterRMS - visualActiveThreshold, 0) + let level = min(sqrt(visualRMS) * 2.7, 1) + + if visualMeterRMS > visualActiveThreshold { lastVisualActiveSignalTime = now }