From e712eab4f860ab662338de82507769a62273f496 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Wed, 11 Mar 2026 17:10:44 -0700 Subject: [PATCH 1/2] fix: prevent audio buffer truncation when silence or max duration is detected The AudioEngine.processAudioBuffer method had a bug where audio frames were discarded when silence detection or max duration conditions fired. Both checks returned early BEFORE appending the current audio chunk to the buffer, causing the last few seconds of speech to be lost. Changes: - Reorder processAudioBuffer to always append audio samples to the buffer BEFORE checking silence/max-duration stop conditions - Add silenceCallbackFired and maxDurationCallbackFired flags to prevent repeated callback invocations (previously, silence detection fired on every audio callback once the duration threshold was met) - Reset both flags in startRecording() for clean state on each session This fixes push-to-talk mode where releasing the key after a brief pause would result in truncated transcription, because trailing audio frames with low energy were silently dropped. --- Sources/VocaMac/Services/AudioEngine.swift | 52 ++++--- Tests/VocaMacTests/VocaMacTests.swift | 162 +++++++++++++++++++++ 2 files changed, 194 insertions(+), 20 deletions(-) diff --git a/Sources/VocaMac/Services/AudioEngine.swift b/Sources/VocaMac/Services/AudioEngine.swift index ebb98d1..ad32540 100644 --- a/Sources/VocaMac/Services/AudioEngine.swift +++ b/Sources/VocaMac/Services/AudioEngine.swift @@ -95,6 +95,8 @@ final class AudioEngine { } lastSoundTime = Date() recordingStartTime = Date() + silenceCallbackFired = false + maxDurationCallbackFired = false isCurrentlyRecording = true let inputNode = engine.inputNode @@ -133,19 +135,16 @@ final class AudioEngine { // MARK: - Audio Processing + /// Whether silence detection has already fired (prevents repeated callbacks) + private var silenceCallbackFired = false + + /// Whether max duration callback has already fired + private var maxDurationCallbackFired = false + /// Process an incoming audio buffer from AVAudioEngine private func processAudioBuffer(_ buffer: AVAudioPCMBuffer, inputFormat: AVAudioFormat) { guard isCurrentlyRecording else { return } - // Check max duration - let elapsed = Date().timeIntervalSince(recordingStartTime) - if elapsed >= maxDuration { - DispatchQueue.main.async { [weak self] in - self?.onMaxDurationReached?() - } - return - } - // Convert to whisper format (16kHz, mono, Float32) guard let convertedBuffer = convertToWhisperFormat(buffer, from: inputFormat) else { return @@ -162,17 +161,9 @@ final class AudioEngine { onAudioLevel?(normalizedLevel) } - // Silence detection - if energy > silenceThreshold { - lastSoundTime = now - } else if now.timeIntervalSince(lastSoundTime) >= silenceDuration { - DispatchQueue.main.async { [weak self] in - self?.onSilenceDetected?() - } - return - } - - // Append samples to buffer + // Always append audio samples to the buffer BEFORE checking stop conditions. + // This ensures no audio frames are discarded when silence or max duration + // is detected — the triggering frame and any trailing audio are preserved. if let channelData = convertedBuffer.floatChannelData { let frameCount = Int(convertedBuffer.frameLength) bufferQueue.sync { @@ -182,6 +173,27 @@ final class AudioEngine { } } } + + // Check max duration (fire callback only once) + let elapsed = now.timeIntervalSince(recordingStartTime) + if elapsed >= maxDuration && !maxDurationCallbackFired { + maxDurationCallbackFired = true + DispatchQueue.main.async { [weak self] in + self?.onMaxDurationReached?() + } + return + } + + // Silence detection + if energy > silenceThreshold { + lastSoundTime = now + silenceCallbackFired = false // Reset so silence can be detected again after speech resumes + } else if now.timeIntervalSince(lastSoundTime) >= silenceDuration && !silenceCallbackFired { + silenceCallbackFired = true + DispatchQueue.main.async { [weak self] in + self?.onSilenceDetected?() + } + } } /// Convert an audio buffer to whisper.cpp's required format (16kHz, mono, Float32) diff --git a/Tests/VocaMacTests/VocaMacTests.swift b/Tests/VocaMacTests/VocaMacTests.swift index 9610ed0..63a82dd 100644 --- a/Tests/VocaMacTests/VocaMacTests.swift +++ b/Tests/VocaMacTests/VocaMacTests.swift @@ -445,6 +445,168 @@ final class OnboardingStepTests: XCTestCase { } } +// MARK: - AudioEngine Tests + +final class AudioEngineTests: XCTestCase { + + func testStopRecordingWithoutStartReturnsEmpty() { + let engine = AudioEngine() + let samples = engine.stopRecording() + XCTAssertTrue(samples.isEmpty) + } + + func testSilenceCallbackFiresOnlyOnce() { + // Verify that the silence detection callback doesn't fire repeatedly + // by simulating the scenario where multiple silent buffers arrive + let engine = AudioEngine() + var silenceCallCount = 0 + + engine.onSilenceDetected = { + silenceCallCount += 1 + } + + // Start recording with a very short silence duration so it triggers quickly + engine.startRecording( + silenceThreshold: 0.5, // High threshold so normal ambient noise counts as silence + silenceDuration: 0.01, // Very short so it fires quickly + maxDuration: 60.0 + ) + + // Wait for a few audio callbacks to process silence + let expectation = XCTestExpectation(description: "Silence detection fires") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 2.0) + + let _ = engine.stopRecording() + + // The callback should have fired at most once due to the silenceCallbackFired guard + XCTAssertLessThanOrEqual(silenceCallCount, 1, + "Silence callback should fire at most once, but fired \(silenceCallCount) times") + } + + func testMaxDurationCallbackFiresOnlyOnce() { + let engine = AudioEngine() + var maxDurationCallCount = 0 + + engine.onMaxDurationReached = { + maxDurationCallCount += 1 + } + + // Start recording with a very short max duration + engine.startRecording( + silenceThreshold: 0.01, + silenceDuration: 999.0, // Long silence duration so it doesn't interfere + maxDuration: 0.01 // Very short max duration + ) + + // Wait for max duration to be reached + let expectation = XCTestExpectation(description: "Max duration fires") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 2.0) + + let _ = engine.stopRecording() + + // The callback should have fired at most once + XCTAssertLessThanOrEqual(maxDurationCallCount, 1, + "Max duration callback should fire at most once, but fired \(maxDurationCallCount) times") + } + + func testAudioBufferNotEmptyAfterRecording() { + // When we record for a short time, we should get some audio data back + let engine = AudioEngine() + + engine.startRecording( + silenceThreshold: 0.01, + silenceDuration: 999.0, + maxDuration: 60.0 + ) + + // Record for a brief period + let expectation = XCTestExpectation(description: "Recording period") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 2.0) + + let samples = engine.stopRecording() + + // We should have captured some audio (even if it's silence/ambient noise) + XCTAssertFalse(samples.isEmpty, + "Audio buffer should contain samples after recording") + } + + func testAudioBufferPreservedWhenSilenceDetected() { + // The key bug fix: audio should be buffered BEFORE silence detection fires, + // so we don't lose the audio frames that triggered the silence condition + let engine = AudioEngine() + var silenceDetected = false + + engine.onSilenceDetected = { + silenceDetected = true + } + + // Use a high silence threshold so even ambient noise triggers silence detection + engine.startRecording( + silenceThreshold: 0.99, // Almost everything is "silence" + silenceDuration: 0.01, // Fire immediately + maxDuration: 60.0 + ) + + // Wait for silence to be detected and audio to accumulate + let expectation = XCTestExpectation(description: "Silence detected") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 2.0) + + let samples = engine.stopRecording() + + // Even though silence was detected, audio should still be in the buffer + // because we now append BEFORE checking silence conditions + if silenceDetected { + XCTAssertFalse(samples.isEmpty, + "Audio buffer should NOT be empty even when silence is detected — " + + "frames must be appended before the silence check") + } + } + + func testAudioBufferPreservedWhenMaxDurationReached() { + // Audio should be buffered even when max duration is reached + let engine = AudioEngine() + var maxDurationReached = false + + engine.onMaxDurationReached = { + maxDurationReached = true + } + + engine.startRecording( + silenceThreshold: 0.01, + silenceDuration: 999.0, + maxDuration: 0.01 // Reach max duration almost immediately + ) + + // Wait for max duration to fire + let expectation = XCTestExpectation(description: "Max duration reached") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 2.0) + + let samples = engine.stopRecording() + + // Even though max duration was reached, audio should still be in the buffer + if maxDurationReached { + XCTAssertFalse(samples.isEmpty, + "Audio buffer should NOT be empty when max duration is reached — " + + "frames must be appended before the max duration check") + } + } +} + // MARK: - AppState Onboarding Tests final class AppStateOnboardingTests: XCTestCase { From c038b5d4d6d945a17292cbc3b6d7dff2534778ad Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Wed, 11 Mar 2026 17:18:16 -0700 Subject: [PATCH 2/2] fix: eliminate recording start delay by removing blocking sound playback The startRecording() method awaited playStartSoundAsync() which blocked for the entire sound duration (up to 1s timeout) before activating the microphone. This caused a noticeable lag between pressing the hotkey and the mic actually capturing audio. Now the mic starts immediately and the start sound plays fire-and-forget afterward. Any brief sound bleed into the mic buffer is negligible and handled well by WhisperKit's noise model. --- Sources/VocaMac/Models/AppState.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/VocaMac/Models/AppState.swift b/Sources/VocaMac/Models/AppState.swift index 23e9eae..6b640ca 100644 --- a/Sources/VocaMac/Models/AppState.swift +++ b/Sources/VocaMac/Models/AppState.swift @@ -319,17 +319,19 @@ final class AppState: ObservableObject { cursorOverlay.show() } - // Play start sound and wait for completion before starting mic - // This prevents the sound from being captured into the audio buffer - if soundEffectsEnabled { - await soundManager.playStartSoundAsync() - } - + // Start recording immediately for instant responsiveness. + // The start sound is played concurrently — any brief bleed into the + // mic buffer is negligible and handled well by WhisperKit's noise model. audioEngine.startRecording( silenceThreshold: Float(silenceThreshold), silenceDuration: silenceDuration, maxDuration: TimeInterval(maxRecordingDuration) ) + + // Play start sound after mic is active (fire-and-forget) + if soundEffectsEnabled { + soundManager.playStartSound() + } } func stopRecordingAndTranscribe() async {