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 { 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 {