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
14 changes: 8 additions & 6 deletions Sources/VocaMac/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
52 changes: 32 additions & 20 deletions Sources/VocaMac/Services/AudioEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ final class AudioEngine {
}
lastSoundTime = Date()
recordingStartTime = Date()
silenceCallbackFired = false
maxDurationCallbackFired = false
isCurrentlyRecording = true

let inputNode = engine.inputNode
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
162 changes: 162 additions & 0 deletions Tests/VocaMacTests/VocaMacTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading