Skip to content

Recover the mic when the audio device changes mid-session - #3

Open
CansuKhon wants to merge 1 commit into
jaredrhod:mainfrom
CansuKhon:fix/mic-survives-device-switch
Open

Recover the mic when the audio device changes mid-session#3
CansuKhon wants to merge 1 commit into
jaredrhod:mainfrom
CansuKhon:fix/mic-survives-device-switch

Conversation

@CansuKhon

Copy link
Copy Markdown

The bug

On macOS with Bluetooth headphones (AirPods Max here), the voice line transcribes
fine for the first several turns, then every recording after that fails:

||PaMacCore (AUHAL)|| Error on line 1322: err='-10851', msg=Audio Unit: Invalid Property Value
[ears] record/transcribe failed: PortAudioError('Error opening InputStream: Internal PortAudio error', -9986)
[ptt] (tap or empty — ignored)

Once it starts, it never recovers — every subsequent press fails the same way until
the process is restarted.

Why

PortAudio caches the device list when it initializes. A Bluetooth headset flips
between listen mode and mic mode every time the mic opens, which invalidates the
cached device. record_held opens a fresh InputStream per press, so every open
after the switch hits a device that no longer exists as PortAudio remembers it.

The failure mode is quiet in the worst way: the voice line stays up, the greeting
plays, the key registers, [ptt] recording still prints — and the agent answers
"my ears hit an error" on every single press. Everything looks healthy.

The fix

_input_stream() re-initializes PortAudio (which refreshes the device list) and
retries once on PortAudioError. Both capture paths use it — record_held for
push-to-talk and Ears.listen_once for the open mic.

The _terminate() call is guarded: it raises PortAudioError if PortAudio is
already down, which would otherwise turn the recovery path into a second crash.
(Found that one by testing the recovery, not by reading it.)

Testing

On macOS 15 (darwin 25.6.0), Python 3.12, sounddevice 0.5.6:

  • Normal open: unchanged.
  • Simulated device invalidation (sd._terminate() to reproduce the -9986 state),
    then _input_stream() — recovers, logs [ears] audio device changed — reopening the mic.
  • Full record_held() through a deliberately broken device — recovers and returns
    transcribed text.
  • Live push-to-talk after the patch on the machine that produced the original logs.

Found while setting up fullstack-agent. Happy to adjust the approach if you'd
rather handle it at a different layer.

🤖 Generated with Claude Code

PortAudio caches the device list when it initializes. A Bluetooth
headset flipping between listen mode and mic mode (AirPods do this
every time the mic opens) invalidates the cached device, and every
capture after that fails with -9986 until the process restarts.

The failure is quiet in the worst way: the voice line stays up, the
greeting still plays, the key still registers, and the agent answers
"my ears hit an error" on every single press.

Add _input_stream(), which re-initializes PortAudio and retries once
on PortAudioError. Both capture paths use it: record_held for
push-to-talk and Ears.listen_once for the open mic. The _terminate()
call is guarded because it raises if PortAudio is already down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant