@@ -84,22 +84,30 @@ public class AudioCaptureManager: ObservableObject {
8484 }
8585
8686 #if os(iOS) || os(tvOS)
87- // Configure audio session (iOS/tvOS only)
88- // watchOS is NOT supported - AVAudioEngine inputNode tap does not work on watchOS
8987 let audioSession = AVAudioSession . sharedInstance ( )
9088 try audioSession. setCategory ( . record, mode: . measurement)
9189 try audioSession. setActive ( true )
9290 #endif
9391
94- // Create audio engine (works on all platforms)
9592 let engine = AVAudioEngine ( )
9693 let inputNode = engine. inputNode
9794
98- // Get input format
95+ #if os(macOS)
96+ // On macOS there is no AVAudioSession. Preparing the engine before
97+ // reading the input node format establishes the audio-unit graph
98+ // connections and avoids kAudioUnitErr_NoConnection (-10877).
99+ engine. prepare ( )
100+ #endif
101+
99102 let inputFormat = inputNode. outputFormat ( forBus: 0 )
103+
104+ guard inputFormat. sampleRate > 0 , inputFormat. channelCount > 0 else {
105+ logger. error ( " No valid audio input device (sampleRate= \( inputFormat. sampleRate) , channels= \( inputFormat. channelCount) ) " )
106+ throw AudioCaptureError . noInputDevice
107+ }
108+
100109 logger. info ( " Input format: \( inputFormat. sampleRate) Hz, \( inputFormat. channelCount) channels " )
101110
102- // Create converter format (16kHz, mono, int16)
103111 guard let outputFormat = AVAudioFormat (
104112 commonFormat: . pcmFormatInt16,
105113 sampleRate: targetSampleRate,
@@ -109,32 +117,26 @@ public class AudioCaptureManager: ObservableObject {
109117 throw AudioCaptureError . formatConversionFailed
110118 }
111119
112- // Create audio converter
113120 guard let converter = AVAudioConverter ( from: inputFormat, to: outputFormat) else {
114121 throw AudioCaptureError . formatConversionFailed
115122 }
116123
117- // Install tap on input node
118124 inputNode. installTap ( onBus: 0 , bufferSize: 4096 , format: inputFormat) { [ weak self] buffer, _ in
119125 guard let self = self else { return }
120126
121- // Update audio level for visualization
122127 self . updateAudioLevel ( buffer: buffer)
123128
124- // Convert to target format
125129 guard let convertedBuffer = self . convert ( buffer: buffer, using: converter, to: outputFormat) else {
126130 return
127131 }
128132
129- // Convert to Data (int16 PCM)
130133 if let audioData = self . bufferToData ( buffer: convertedBuffer) {
131134 DispatchQueue . main. async {
132135 onAudioData ( audioData)
133136 }
134137 }
135138 }
136139
137- // Start engine (remove tap on failure to avoid resource leak)
138140 do {
139141 try engine. start ( )
140142 } catch {
@@ -177,12 +179,18 @@ public class AudioCaptureManager: ObservableObject {
177179
178180 // MARK: - Private Helpers
179181
180- /// Converts a PCM buffer to the target format. Internal for unit testing (converter input block single-use behavior) .
182+ /// Converts a PCM buffer to the target format. Internal for unit testing.
181183 internal func convert(
182184 buffer: AVAudioPCMBuffer ,
183185 using converter: AVAudioConverter ,
184186 to format: AVAudioFormat
185187 ) -> AVAudioPCMBuffer ? {
188+ // The input block returns .endOfStream after providing one buffer.
189+ // On macOS the converter stays in that "finished" state across calls,
190+ // producing empty output for every subsequent buffer. Resetting before
191+ // each conversion clears the state so the next buffer is processed.
192+ converter. reset ( )
193+
186194 let capacity = AVAudioFrameCount ( Double ( buffer. frameLength) * ( format. sampleRate / buffer. format. sampleRate) )
187195
188196 guard let convertedBuffer = AVAudioPCMBuffer (
@@ -260,6 +268,7 @@ public enum AudioCaptureError: LocalizedError {
260268 case permissionDenied
261269 case formatConversionFailed
262270 case engineStartFailed
271+ case noInputDevice
263272
264273 public var errorDescription : String ? {
265274 switch self {
@@ -269,6 +278,8 @@ public enum AudioCaptureError: LocalizedError {
269278 return " Failed to convert audio format "
270279 case . engineStartFailed:
271280 return " Failed to start audio engine "
281+ case . noInputDevice:
282+ return " No audio input device available "
272283 }
273284 }
274285}
0 commit comments