Skip to content

Voice: keep a silent output open on USB speakerphones so the mic streams - #70

Merged
ThinkOffApp merged 1 commit into
mainfrom
fix/usb-speakerphone-capture-keepalive
Sep 26, 2026
Merged

ThinkOffApp merged 1 commit into
mainfrom
fix/usb-speakerphone-capture-keepalive

Conversation

@ThinkOffApp

Copy link
Copy Markdown
Owner

The bug (measured on vadelma, Pi 5, 26 Sep 2026 11:42-11:45Z)

  • Device: Jabra Speak2 40 MS (USB 0b0e:ae6b), ALSA card 0. /proc/asound/card0/stream0: Playback S16_LE 2 ch at 48000/44100/32000/16000/8000; Capture S16_LE 1 ch at 16000 only.
  • arecord -D plughw:0,0 -f S16_LE -r 16000 -c 1 -d 2 x.wav fails every time with pcm_read:2272: read error: Input/output error (also on hw:0,0), listener paused, no kernel messages.
  • The same arecord succeeds (2 s, 64044 bytes) while aplay -q -D plughw:0,0 -f S16_LE -r 16000 -c 2 /dev/zero & holds the card's output open.
  • So this speakerphone only streams its mic while its output is open. _open_mic() opened only arecord, every read came back empty, and listen() reopened the mic forever: the journal shows mic: USB audio plughw:0,0 every ~3 s and nothing is ever heard.

The fix

While the mic is open on a USB audio device, a silent aplay -D <same plughw:N,M> -f S16_LE -r 16000 -c 2 -t raw /dev/zero holds that card's output open.

  • Lifetime tied to the mic. _open_mic() starts it (USB branch only) right before arecord, with a 0.3 s settle so arecord does not race the output coming up and trip the "died within 2 s = wedged" branch. A new _close_mic(proc) terminates/reaps the arecord (same terminate, wait, kill fallback as before) and then the keepalive. All three close paths use it: the reopen path, before _speak, and the finally on shutdown. _open_mic() also stops any leftover keepalive first, so there are never two.
  • Stop and restart around _speak, not dmix. _speak plays straight to plughw on the same card and a hw PCM is exclusive. A dmix would need an asoundrc on the Pi, which is deploy-side config this repo does not own. The mic is already closed for every reply, so the keepalive goes down with it and comes back when the mic reopens. Replies still play on the Jabra.
  • No leaks. _stop_keepalive() terminates, waits, and falls back to kill, the same way arecord is handled. The wedged-device branch stops our keepalive by handle before its pkill -9 -x arecord. There is deliberately no blanket pkill aplay, because that would also kill reply playback. On a service restart, systemd's default control-group KillMode takes the child aplay down too.
  • BT HFP and default device are unchanged. Neither gets a keepalive, including CARWATCH_MIC=bt with USB attached.
  • Not gated on detecting the I/O error. 16 kHz stereo zeros cost nothing, and a card that captures on its own is not affected by its output being open. A mic-only USB card (the SF-558) just makes aplay exit at once: this is logged once per open as mic: keepalive aplay exited (N), and capture goes ahead exactly as before.
  • The keepalive uses the capture device's own plughw:N,M rather than _usb_audio_device("playback"), because that one picks the first matching card, which with two USB devices could be a different card from the mic.
  • A small side effect: the reopen path now also wait()s on the dead arecord (through _close_mic), so it gets reaped instead of left as a zombie. For a process that has already exited, this returns immediately.

Tests (run on the MacBook, Python 3.14.3, subprocess mocked)

New file tests/test_usb_mic_keepalive.py has 12 tests:

  • USB: the aplay starts before arecord, on the same device, with S16_LE/16000/2 ch from /dev/zero
  • A second open does not leak the first keepalive
  • Reopen after an instant arecord death stops the old keepalive
  • The keepalive and arecord are both dead while _speak runs, and both come back after
  • Shutdown stops the keepalive
  • The kill fallback runs when terminate does not stick
  • A mic-only card's aplay exiting at once is harmless
  • BT HFP, CARWATCH_MIC=bt with USB present, and the default device all get no aplay, both in _open_mic and through the listen() loop
Run Command Result Exit
New tests, isolated python3 -m unittest tests.test_usb_mic_keepalive 12 OK 0
Negative control A: _stop_keepalive drops the handle without terminating same 5 FAIL (reopen, speak, shutdown, second-open, kill-fallback) 1
Negative control B: _close_mic no longer stops the keepalive same 2 FAIL (speak, shutdown); reopen is still covered by _open_mic's own stop 1
Restored, isolated same 12 OK 0
Full suite (as in CI) python3 -m unittest discover -s tests 160 OK 0

Deploy + verify on vadelma: NOT DONE (the owner deploys on his word)

  • On the Pi, run cd ~/CarWatch && git pull once this is merged, or check out this branch.
  • sudo systemctl restart carwatch-listen.service
  • journalctl -u carwatch-listen -f: mic: USB audio plughw:0,0 should appear once and then stop repeating. The ~3 s reopen storm must be gone, and there should be no keepalive aplay exited line for the Jabra.
  • Speak a wake phrase and see a transcript (heard: ...) in the room.
  • Hear the reply on the Jabra, then confirm the mic reopens once afterwards (one more mic: USB audio line, no storm).
  • pgrep -a aplay while idle should show exactly one /dev/zero aplay, never more, even after several exchanges.

🤖 Generated with Claude Code

Measured on vadelma 26 Sep 2026: the Jabra Speak2 40 (0b0e:ae6b) fails
every arecord with "read error: Input/output error" unless a playback
stream is open on the same card, so listen() reopened the mic every ~3 s
and heard nothing. While the mic is open on a USB device, a silent aplay
(S16_LE 16 kHz 2 ch from /dev/zero) now holds the card's output open.
It starts in _open_mic and stops in the new _close_mic, used on every
close path (reopen, before _speak, shutdown); also stopped before the
wedged-device pkill. BT HFP and default-device paths are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
carwatch-dev Ready Ready Preview Sep 26, 2026 11:54am UTC

Request Review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@ThinkOffApp
ThinkOffApp merged commit d5736cd into main Sep 26, 2026
3 checks passed

This branch was successfully deployed

1 active deployment
Preview — c94b48cd Deployed Sep 26, 2026 by vercel[bot]
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