Skip to content

Commit 97c5b9f

Browse files
authored
fix: prevent sound effects from bleeding into transcription (closes #49) (#52)
1 parent 9cada17 commit 97c5b9f

3 files changed

Lines changed: 139 additions & 9 deletions

File tree

Sources/VocaMac/Models/AppState.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -311,16 +311,17 @@ final class AppState: ObservableObject {
311311
isRecording = true
312312
errorMessage = nil
313313

314-
// Play start sound
315-
if soundEffectsEnabled {
316-
soundManager.playStartSound()
317-
}
318-
319314
// Show cursor indicator
320315
if showCursorIndicator {
321316
cursorOverlay.show()
322317
}
323318

319+
// Play start sound and wait for completion before starting mic
320+
// This prevents the sound from being captured into the audio buffer
321+
if soundEffectsEnabled {
322+
await soundManager.playStartSoundAsync()
323+
}
324+
324325
audioEngine.startRecording(
325326
silenceThreshold: Float(silenceThreshold),
326327
silenceDuration: silenceDuration,

Sources/VocaMac/Services/SoundManager.swift

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import Foundation
88
import AppKit
99

10-
final class SoundManager {
10+
final class SoundManager: NSObject, NSSoundDelegate, @unchecked Sendable {
1111

1212
// MARK: - Sound Names
1313

@@ -22,21 +22,41 @@ final class SoundManager {
2222
/// Volume for sound effects (0.0 to 1.0)
2323
var volume: Float = 0.5
2424

25+
/// Lock for thread-safe access to continuation
26+
private let continuationLock = NSLock()
27+
28+
/// Continuation for async sound playback completion
29+
private var soundCompletionContinuation: CheckedContinuation<Void, Never>?
30+
2531
// MARK: - Public API
2632

27-
/// Play the recording-started sound
33+
/// Play the recording-started sound (synchronous, fire-and-forget)
2834
func playStartSound() {
2935
playSystemSound(startSoundName)
3036
}
3137

32-
/// Play the recording-stopped sound
38+
/// Play the recording-started sound and wait for completion
39+
/// Ensures the sound finishes before returning, preventing mic capture of the sound.
40+
/// - Throws: May timeout if sound is stuck
41+
func playStartSoundAsync() async {
42+
await playSystemSoundAsync(startSoundName)
43+
}
44+
45+
/// Play the recording-stopped sound (synchronous, fire-and-forget)
3346
func playStopSound() {
3447
playSystemSound(stopSoundName)
3548
}
3649

50+
/// Play the recording-stopped sound and wait for completion
51+
/// Ensures the sound finishes before returning.
52+
/// - Throws: May timeout if sound is stuck
53+
func playStopSoundAsync() async {
54+
await playSystemSoundAsync(stopSoundName)
55+
}
56+
3757
// MARK: - Private
3858

39-
/// Play a macOS system sound by name
59+
/// Play a macOS system sound by name (fire-and-forget)
4060
private func playSystemSound(_ name: String) {
4161
let soundPath = "/System/Library/Sounds/\(name).aiff"
4262
guard let sound = NSSound(contentsOfFile: soundPath, byReference: true) else {
@@ -46,4 +66,55 @@ final class SoundManager {
4666
sound.volume = volume
4767
sound.play()
4868
}
69+
70+
/// Play a system sound and wait for completion using async/await
71+
/// Uses NSSoundDelegate callback to detect when playback finishes.
72+
/// Includes a 1-second timeout to prevent stuck sounds from blocking recording.
73+
/// - Parameter name: The system sound name to play (e.g., "Pop", "Bottle")
74+
private func playSystemSoundAsync(_ name: String) async {
75+
let soundPath = "/System/Library/Sounds/\(name).aiff"
76+
guard let sound = NSSound(contentsOfFile: soundPath, byReference: true) else {
77+
NSLog("[SoundManager] Could not load system sound: %@", name)
78+
return
79+
}
80+
81+
sound.volume = volume
82+
sound.delegate = self
83+
84+
return await withCheckedContinuation { continuation in
85+
continuationLock.lock()
86+
soundCompletionContinuation = continuation
87+
continuationLock.unlock()
88+
89+
sound.play()
90+
91+
// Timeout after 1 second to prevent stuck sounds from blocking
92+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
93+
guard let self = self else { return }
94+
self.continuationLock.lock()
95+
if self.soundCompletionContinuation != nil {
96+
NSLog("[SoundManager] Sound playback timeout for: %@", name)
97+
self.soundCompletionContinuation?.resume()
98+
self.soundCompletionContinuation = nil
99+
}
100+
self.continuationLock.unlock()
101+
}
102+
}
103+
}
104+
105+
// MARK: - NSSoundDelegate
106+
107+
/// Called when sound finishes playing
108+
nonisolated func sound(_ sound: NSSound, didFinishPlaying FinishedPlaying: Bool) {
109+
// Dispatch back to main thread to safely access and resume continuation
110+
DispatchQueue.main.async { [weak self] in
111+
guard let self = self else { return }
112+
self.continuationLock.lock()
113+
if let continuation = self.soundCompletionContinuation {
114+
continuation.resume()
115+
self.soundCompletionContinuation = nil
116+
}
117+
self.continuationLock.unlock()
118+
}
119+
}
49120
}

Tests/VocaMacTests/VocaMacTests.swift

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,3 +314,61 @@ final class TextInjectorTests: XCTestCase {
314314
injector.inject(text: "", preserveClipboard: false)
315315
}
316316
}
317+
318+
// MARK: - SoundManager Tests
319+
320+
final class SoundManagerTests: XCTestCase {
321+
322+
var soundManager: SoundManager!
323+
324+
override func setUp() {
325+
super.setUp()
326+
soundManager = SoundManager()
327+
}
328+
329+
func testPlayStartSoundSync() {
330+
// Test that synchronous play doesn't crash
331+
soundManager.playStartSound()
332+
// If we get here without crashing, the test passes
333+
XCTAssertTrue(true)
334+
}
335+
336+
func testPlayStopSoundSync() {
337+
// Test that synchronous play doesn't crash
338+
soundManager.playStopSound()
339+
// If we get here without crashing, the test passes
340+
XCTAssertTrue(true)
341+
}
342+
343+
func testPlayStartSoundAsync() async {
344+
// Test that async play completes without hanging
345+
let startTime = Date()
346+
await soundManager.playStartSoundAsync()
347+
let elapsed = Date().timeIntervalSince(startTime)
348+
349+
// Should complete in reasonable time (under 2 seconds even with timeout)
350+
XCTAssertLessThan(elapsed, 2.0)
351+
}
352+
353+
func testPlayStopSoundAsync() async {
354+
// Test that async play completes without hanging
355+
let startTime = Date()
356+
await soundManager.playStopSoundAsync()
357+
let elapsed = Date().timeIntervalSince(startTime)
358+
359+
// Should complete in reasonable time (under 2 seconds even with timeout)
360+
XCTAssertLessThan(elapsed, 2.0)
361+
}
362+
363+
func testVolumeControl() {
364+
soundManager.volume = 0.0
365+
XCTAssertEqual(soundManager.volume, 0.0)
366+
367+
soundManager.volume = 0.5
368+
XCTAssertEqual(soundManager.volume, 0.5)
369+
370+
soundManager.volume = 1.0
371+
XCTAssertEqual(soundManager.volume, 1.0)
372+
}
373+
}
374+

0 commit comments

Comments
 (0)