Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions iOS/KeyVox Keyboard/Core/Dictation/AudioIndicatorDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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))
Expand Down
29 changes: 12 additions & 17 deletions iOS/KeyVox Keyboard/Views/Components/KeyboardLogoBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 26 additions & 2 deletions iOS/KeyVox iOS/Core/Audio/AudioRecorder+Streaming.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,6 +38,8 @@ final class AudioCaptureAccumulator {
lastNonDeadSignalTime = .distantPast
lastVisualActiveSignalTime = .distantPast
lastMeaningfulSpeechTime = .distantPast
visualMeterPreviousInput = 0
visualMeterPreviousOutput = 0
currentActiveSignalRunDuration = 0
maxActiveSignalRunDuration = 0
hadNonDeadSignal = false
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Comment thread
macmixing marked this conversation as resolved.
lastVisualActiveSignalTime = now
}

Expand Down
2 changes: 2 additions & 0 deletions macOS/Core/Audio/AudioRecorder+Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ extension AudioRecorder {

lastNonDeadSignalTime = Date.distantPast
lastVisualActiveSignalTime = Date.distantPast
visualMeterPreviousInput = 0
visualMeterPreviousOutput = 0
currentActiveSignalRunDuration = 0
maxActiveSignalRunDuration = 0
lastCaptureHadNonDeadSignal = false
Expand Down
29 changes: 23 additions & 6 deletions macOS/Core/Audio/AudioRecorder+Streaming.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down
3 changes: 3 additions & 0 deletions macOS/Core/Audio/AudioRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions macOS/Core/Overlay/AudioIndicatorDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?)?
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 23 additions & 10 deletions macOS/Views/Components/LogoBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading