77import Foundation
88import 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}
0 commit comments