diff --git a/carwatch/listen.py b/carwatch/listen.py index 0ab4983..c2a15ba 100644 --- a/carwatch/listen.py +++ b/carwatch/listen.py @@ -264,7 +264,98 @@ def _usb_audio_device(kind: str): return None +# USB speakerphone capture keepalive (petrus, 26 Sep 2026: Jabra Speak2 40 +# heard nothing. On vadelma `arecord -D plughw:0,0 -r 16000 -c 1` failed +# every time with "read error: Input/output error" (hw:0,0 too, no kernel +# messages), and the same arecord recorded fine while an aplay of silence +# held the SAME card's output open. This speakerphone only streams its mic +# while its speaker side is open, so the journal showed "mic: USB audio +# plughw:0,0" every ~3 s: an endless reopen storm, nothing ever heard.) +# So while the mic is open on a USB device, a silent aplay holds that card's +# playback open. It is tied to the mic's lifetime: started in _open_mic, +# stopped by _close_mic everywhere the mic is closed (reopen, before +# _speak, shutdown). Stop-and-restart rather than an ALSA dmix: _speak +# plays straight to plughw on the same card, a hw PCM is exclusive, and a +# dmix would need an asoundrc on the Pi, i.e. deploy-side config this repo +# does not own. The mic is already closed for every reply, so the keepalive +# simply goes down with it and comes back when the mic reopens. +# Not gated on detecting the I/O error: 16 kHz stereo zeros cost nothing, +# a card that captures on its own is unaffected by its output being open, +# and a mic-only USB card (the SF-558) just makes aplay exit at once. +# BT HFP and the default device never get one: their paths are unchanged. +KEEPALIVE_RATE = 16000 # the Jabra's playback rates incl. 16000, 2 ch +KEEPALIVE_CHANNELS = 2 +_keepalive = None + + +def _start_keepalive(capture_dev: str) -> None: + """Play silence on the capture device's own 'plughw:N,M' string, i.e. + the SAME card. Deliberately not _usb_audio_device('playback'): that + picks the first matching card, which with two USB devices may be a + different one than the mic.""" + global _keepalive + _stop_keepalive() + try: + _keepalive = subprocess.Popen( + ["aplay", "-q", "-D", capture_dev, "-f", "S16_LE", + "-r", str(KEEPALIVE_RATE), "-c", str(KEEPALIVE_CHANNELS), + "-t", "raw", "/dev/zero"], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + except Exception as e: + print(f"mic: keepalive not started ({e})", flush=True) + _keepalive = None + return + # Let the output side come up before arecord opens the mic, otherwise + # arecord can still hit the I/O error and die inside the 2 s window + # that listen() treats as a wedged device. + time.sleep(0.3) + if _keepalive.poll() is not None: + # Mic-only card or output busy; capture proceeds as before. + print(f"mic: keepalive aplay exited ({_keepalive.returncode})", + flush=True) + _keepalive = None + + +def _stop_keepalive() -> None: + """Terminate the keepalive aplay and reap it, killing it if terminate + does not stick, so no aplay leaks across a reopen the way a wedged + arecord did on 20.8. Safe to call when none is running.""" + global _keepalive + ka, _keepalive = _keepalive, None + if ka is None: + return + try: + ka.terminate() + ka.wait(timeout=5) + except Exception: + try: + ka.kill() + ka.wait(timeout=5) + except Exception: + pass + + +def _close_mic(proc) -> None: + """Close the mic AND its keepalive. Every place the mic is closed goes + through here so the two cannot drift apart.""" + try: + proc.terminate() + proc.wait(timeout=5) + except Exception: + # A terminate that did not stick leaves the capture device held + # forever and every reopen fails busy - make sure it is dead. + try: + proc.kill() + proc.wait(timeout=5) + except Exception: + pass + _stop_keepalive() + + def _open_mic(): + # Never two keepalives: whatever the previous mic left running goes. + _stop_keepalive() cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(RATE), "-c", str(CHANNELS), "-t", "raw"] # Priority: USB speakerphone, then BT headset SCO (the headset is both @@ -285,6 +376,7 @@ def _open_mic(): if usb: cmd[1:1] = ["-D", usb] print(f"mic: USB audio {usb}", flush=True) + _start_keepalive(usb) elif mac: cmd[1:1] = ["-D", f"bluealsa:DEV={mac},PROFILE=sco"] print(f"mic: bluetooth HFP {mac}", flush=True) @@ -427,15 +519,15 @@ def listen(threshold: float, on_text) -> None: # 20.8. reopen storm after the first USB speak cycle). # This service is the mic's sole legitimate owner, so # clearing every arecord is safe - then breathe. + # Our own keepalive aplay goes first and by handle: a + # blanket pkill of aplay would also hit reply playback. + _stop_keepalive() subprocess.run(["pkill", "-9", "-x", "arecord"], capture_output=True) time.sleep(2) has_mic = _usb_audio_device("capture") or _bt_pcm_mac("hfpag/source") time.sleep(0.3 if has_mic else 5) - try: - proc.terminate() - except Exception: - pass + _close_mic(proc) proc = _open_mic() opened_at = time.time() speech, quiet, in_speech = [], 0, False @@ -465,19 +557,10 @@ def listen(threshold: float, on_text) -> None: # Voice the answer with the mic CLOSED: SCO and # A2DP cannot be live at once on one headset, # then reopen (also discards echo of our own - # voice buffered during playback). - try: - proc.terminate() - proc.wait(timeout=5) - except Exception: - # A terminate that did not stick leaves the - # capture device held forever and every - # reopen fails busy - make sure it is dead. - try: - proc.kill() - proc.wait(timeout=5) - except Exception: - pass + # voice buffered during playback). The USB + # keepalive closes with it so _speak can take + # the speakerphone's exclusive output. + _close_mic(proc) voicestate.set_state("speaking", answer=reply) _speak(reply) # Conversation continues: for FOLLOWUP_S after an @@ -496,7 +579,7 @@ def listen(threshold: float, on_text) -> None: opened_at = time.time() speech = [] finally: - proc.terminate() + _close_mic(proc) def _default_on_text(text: str) -> None: diff --git a/tests/test_usb_mic_keepalive.py b/tests/test_usb_mic_keepalive.py new file mode 100644 index 0000000..bd96aab --- /dev/null +++ b/tests/test_usb_mic_keepalive.py @@ -0,0 +1,241 @@ +"""Jabra Speak2 40 heard nothing (petrus, 26 Sep 2026, measured on vadelma). + +`arecord -D plughw:0,0 -r 16000 -c 1` failed every time with an ALSA +Input/output error, and the same arecord worked while an aplay of silence +held the card's output open. listen.py opened only arecord, so the loop +reopened the mic every ~3 s forever. The fix keeps a silent aplay on the +same card while a USB mic is open; these tests pin that it starts with the +mic, dies with the mic on every close path (reopen, around _speak, +shutdown), and never appears for Bluetooth HFP or the default device. +subprocess is mocked throughout: nothing touches real audio. +""" +import importlib +import os +import sys +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +class _Stop(BaseException): + """Raised from a fake read to end listen()'s endless loop. BaseException + because listen() turns any Exception from a read into a reopen.""" + + +class FakeProc: + def __init__(self, cmd, reads=None): + self.cmd = cmd + self.reads = list(reads or []) + self.terminated = False + self.killed = False + self.returncode = None + self.stdout = self + + # arecord's stdout + def read(self, n): + if not self.reads: + raise _Stop() + r = self.reads.pop(0) + if isinstance(r, Exception): + raise r + return r + + def poll(self): + return self.returncode + + def terminate(self): + self.terminated = True + self.returncode = -15 + + def kill(self): + self.killed = True + self.returncode = -9 + + def wait(self, timeout=None): + return self.returncode + + @property + def alive(self): + return self.returncode is None + + +class KeepaliveBase(unittest.TestCase): + USB = "plughw:0,0" + + def setUp(self): + from carwatch import listen + self.L = importlib.reload(listen) + self.addCleanup(setattr, self.L, "_keepalive", None) + self.procs = [] + self.arecord_reads = [] # one list of reads per arecord opened + self.usb = self.USB + self.bt = None + + def popen(cmd, *a, **kw): + reads = self.arecord_reads.pop(0) if ( + cmd[0] == "arecord" and self.arecord_reads) else [] + p = FakeProc(cmd, reads) + self.procs.append(p) + return p + + patches = [ + mock.patch.object(self.L.subprocess, "Popen", side_effect=popen), + mock.patch.object(self.L.subprocess, "run", + return_value=mock.Mock(returncode=0, stdout="")), + mock.patch.object(self.L.time, "sleep"), + mock.patch.object(self.L, "_usb_audio_device", + side_effect=lambda kind: self.usb), + mock.patch.object(self.L, "_bt_pcm_mac", + side_effect=lambda suffix: self.bt), + mock.patch.object(self.L.voicestate, "set_state"), + mock.patch.object(self.L.voicestate, "armed", return_value=False), + ] + for p in patches: + p.start() + self.addCleanup(p.stop) + os.environ.pop("CARWATCH_MIC", None) + + def arecords(self): + return [p for p in self.procs if p.cmd[0] == "arecord"] + + def keepalives(self): + return [p for p in self.procs if p.cmd[0] == "aplay"] + + +class OpenMic(KeepaliveBase): + + def test_usb_starts_silent_output_on_same_card_before_arecord(self): + self.L._open_mic() + self.assertEqual([p.cmd[0] for p in self.procs], ["aplay", "arecord"]) + ka = self.keepalives()[0] + self.assertEqual(ka.cmd[ka.cmd.index("-D") + 1], self.USB) + self.assertEqual(ka.cmd[ka.cmd.index("-f") + 1], "S16_LE") + self.assertEqual(ka.cmd[ka.cmd.index("-r") + 1], "16000") + self.assertEqual(ka.cmd[ka.cmd.index("-c") + 1], "2") + self.assertEqual(ka.cmd[-1], "/dev/zero") + ar = self.arecords()[0] + self.assertEqual(ar.cmd[ar.cmd.index("-D") + 1], self.USB) + self.assertIs(self.L._keepalive, ka) + + def test_second_open_does_not_leak_first_keepalive(self): + self.L._open_mic() + self.L._open_mic() + first, second = self.keepalives() + self.assertFalse(first.alive) + self.assertTrue(second.alive) + + def test_bluetooth_hfp_gets_no_keepalive(self): + self.usb = None + self.bt = "AA:BB:CC:DD:EE:FF" + self.L._open_mic() + self.assertEqual(self.keepalives(), []) + ar = self.arecords()[0] + self.assertIn("bluealsa:DEV=AA:BB:CC:DD:EE:FF,PROFILE=sco", ar.cmd) + self.assertIsNone(self.L._keepalive) + + def test_bt_preference_with_usb_attached_gets_no_keepalive(self): + self.bt = "AA:BB:CC:DD:EE:FF" + os.environ["CARWATCH_MIC"] = "bt" + self.addCleanup(os.environ.pop, "CARWATCH_MIC", None) + self.L._open_mic() + self.assertEqual(self.keepalives(), []) + + def test_default_device_gets_no_keepalive(self): + self.usb = None + self.L._open_mic() + self.assertEqual(self.keepalives(), []) + self.assertNotIn("-D", self.arecords()[0].cmd) + + def test_mic_only_card_keepalive_exit_is_harmless(self): + # SF-558-style card with no playback: aplay dies at once; the mic + # still opens and no dead handle is kept around. + orig = self.L.subprocess.Popen.side_effect + + def popen(cmd, *a, **kw): + p = orig(cmd, *a, **kw) + if cmd[0] == "aplay": + p.returncode = 1 + return p + self.L.subprocess.Popen.side_effect = popen + self.L._open_mic() + self.assertEqual(len(self.arecords()), 1) + self.assertIsNone(self.L._keepalive) + + +class ListenLoop(KeepaliveBase): + LOUD = (b"\xff\x7f" * (1600)) # int16 max, well above threshold + QUIET = (b"\x00\x00" * (1600)) + + def run_listen(self): + with self.assertRaises(_Stop): + self.L.listen(700, lambda t: None) + + def test_reopen_stops_old_keepalive(self): + # First arecord dies instantly (the Jabra I/O error), second is read + # until the test ends the loop. + self.arecord_reads = [[b""], []] + self.run_listen() + kas = self.keepalives() + self.assertEqual(len(kas), 2) + self.assertFalse(kas[0].alive, "keepalive leaked across the reopen") + self.assertTrue(self.arecords()[0].terminated) + + def test_shutdown_stops_keepalive(self): + self.arecord_reads = [[]] + self.run_listen() + self.assertTrue(all(not k.alive for k in self.keepalives())) + self.assertTrue(all(not a.alive for a in self.arecords())) + self.assertIsNone(self.L._keepalive) + + def test_no_keepalive_while_speaking_and_back_after(self): + seen = {} + + def speak(text): + seen["arecord_alive"] = [a.alive for a in self.arecords()] + seen["keepalive_alive"] = [k.alive for k in self.keepalives()] + return True + + self.arecord_reads = [[self.LOUD] * 4 + [self.QUIET] * 5, []] + with mock.patch.object(self.L, "_speak", side_effect=speak), \ + mock.patch.object(self.L, "handle_utterance", + return_value="reply"): + self.run_listen() + self.assertEqual(seen["arecord_alive"], [False]) + self.assertEqual(seen["keepalive_alive"], [False], + "keepalive held the Jabra output during _speak") + # Mic and keepalive came back after the reply. + self.assertEqual(len(self.keepalives()), 2) + self.assertEqual(len(self.arecords()), 2) + + def test_bluetooth_loop_never_starts_aplay(self): + self.usb = None + self.bt = "AA:BB:CC:DD:EE:FF" + self.arecord_reads = [[b""], []] + self.run_listen() + self.assertEqual(self.keepalives(), []) + self.assertEqual(len(self.arecords()), 2) + + +class StopKeepalive(KeepaliveBase): + + def test_kill_when_terminate_does_not_stick(self): + self.L._start_keepalive(self.USB) + ka = self.L._keepalive + + def stuck(timeout=None): + if not ka.killed: + raise self.L.subprocess.TimeoutExpired("aplay", timeout) + return -9 + ka.wait = stuck + self.L._stop_keepalive() + self.assertTrue(ka.killed) + self.assertIsNone(self.L._keepalive) + + def test_stop_without_keepalive_is_noop(self): + self.L._stop_keepalive() + self.assertEqual(self.procs, []) + + +if __name__ == "__main__": + unittest.main()