Fix: microphone capture dies on input format/route change (Meet, earbuds)#30
Conversation
The mic captured the input format once, created an AVAudioFile in that format, and wrote raw tap buffers directly. When a conferencing app reconfigures the shared input device mid-stream (e.g. Google Meet's 3-channel voice-processing mode), or the default input device changes (plugging in earbuds), the tap buffers stop matching the file format, every write fails with CoreAudio -50, and the mic track dies silently. Two independent safety nets, mirroring the existing SystemAudioCapture: - writeBuffer() normalises every tap buffer to the file's fixed processingFormat via AVAudioConverter (block-based form, handles sample-rate/channel changes), with a direct-write fast path when the format is unchanged. - An AVAudioEngineConfigurationChange observer (delivered on .main, so it serialises with stop()) rebinds the input and reinstalls the tap on a route change, with a bounded retry for a transient zero sample-rate while the route settles. applyInputDevice() no longer force-unwraps inputNode.audioUnit (can be nil during route churn). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
paberr
left a comment
There was a problem hiding this comment.
Error message style differs a bit throughout now. I'll make it consistent before merging.
Overall great PR and good catch.
| audioConverter = AVAudioConverter(from: buffer.format, to: fileFormat) | ||
| } | ||
| guard let converter = audioConverter else { | ||
| fputs("Mic converter unavailable for format \(buffer.format)\n", stderr) |
There was a problem hiding this comment.
Non-blocking (I'll take care of it before the merge):
This logs once per tap buffer (~10+/sec) and a persistent failure would flood stderr. We could gate them behind a flag that resets on successful write.
| return buffer | ||
| } | ||
| if status == .error || convError != nil { | ||
| fputs("Mic convert error: \(convError?.description ?? "unknown")\n", stderr) |
There was a problem hiding this comment.
Non-blocking (I'll take care of it before the merge):
This logs once per tap buffer (~10+/sec) and a persistent failure would flood stderr. We could gate them behind a flag that resets on successful write.
| try audioFile.write(from: outBuffer) | ||
| } | ||
| } catch { | ||
| fputs("Mic write error: \(error)\n", stderr) |
There was a problem hiding this comment.
Non-blocking (I'll take care of it before the merge):
This logs once per tap buffer (~10+/sec) and a persistent failure would flood stderr. We could gate them behind a flag that resets on successful write.
Gate the per-buffer write/convert error logs in writeBuffer behind a flag that resets on each successful write, so a persistent failure logs once instead of flooding stderr at the tap rate (~10+/sec). Also unify the mic diagnostics to a consistent "Mic <category>: ..." prefix: fold "converter unavailable" into "Mic convert error", and restyle the "[MIC_RECONFIG]" route-change line to "Mic reconfigure:" to match its siblings.
Problem
MicCapturereads the input format once at start, creates theAVAudioFilein that format, and writes raw tap buffers directly. If the input device is reconfigured mid-stream, the tap buffers stop matching the file's format and everywrite(from:)fails with CoreAudio-50(paramErr), so the microphone track dies silently for the rest of the recording.Two common triggers:
In both cases
--miccapture produced only the audio recorded before the change.Reproduction
ownscribe ... --mic(start recording).-50write errors.Fix
Two independent safety nets, both mirroring patterns already used by
SystemAudioCapturein the same file:writeBuffer(_:)converts every tap buffer to the file's fixedprocessingFormatthrough anAVAudioConverter(rebuilt when the incoming format changes), using the block-basedconvert(to:error:withInputFrom:)form so sample-rate / channel-count changes are handled. A fast path writes directly when the format is unchanged, so the steady-state path is unchanged from before.AVAudioEngineConfigurationChangeobserver (delivered on.main, so it serialises withstop()) removes the tap, re-applies a named device if one was requested, reinstalls the tap, and restarts the engine. A transient zero sample-rate while the route settles is retried on.main(bounded) rather than relying on a second notification, so one transient change cannot leave the mic dead.Also removed an
inputNode.audioUnit!force-unwrap that could crash during route churn.Testing
swift/build.sh(Swift 6.2, macOS 26).--mic, joined a Google Meet call mid-recording, and confirmed both the pre-call and post-call speech are present in the merged output (previously only pre-call survived).Notes / deliberate tradeoffs
🤖 Generated with Claude Code