From b03f865e9e0a9ac3673db3e2db083a1a692d713a Mon Sep 17 00:00:00 2001 From: Petrus Pennanen Date: Sat, 26 Sep 2026 18:17:43 +0300 Subject: [PATCH] Voice: raise a USB speakerphone's playback level before speaking Co-Authored-By: Claude Opus 5.5 --- carwatch/listen.py | 18 ++++++++++++++++++ tests/test_speak_route.py | 12 ++++++++++++ 2 files changed, 30 insertions(+) diff --git a/carwatch/listen.py b/carwatch/listen.py index 4442b09..b2320dd 100644 --- a/carwatch/listen.py +++ b/carwatch/listen.py @@ -402,6 +402,22 @@ def _car_a2dp_mac(): return None +def _usb_speaker_volume(target: str, pct: int = 90) -> None: + """Bring a USB speakerphone's playback level up before an answer. The + Jabra Speak2 40 came up at 53% (-21 dB): aplay exited 0 and petrus heard + nothing across the room (26 Sep). Best effort; control names vary.""" + m = re.match(r"plughw:(\d+),", target or "") + if not m: + return + for control in ("PCM", "Speaker"): + try: + if subprocess.run(["amixer", "-q", "-c", m.group(1), "sset", control, f"{pct}%", "unmute"], + capture_output=True, timeout=5).returncode == 0: + return + except Exception: + return + + def _speak(text: str) -> bool: """Voice a reply through the car's A2DP sink (the brief's music channel), falling back to USB playback, then the headset channels. Call ONLY with @@ -480,6 +496,8 @@ def _speak(text: str) -> bool: pass if bt: time.sleep(1.5) # let a shared headset fall back from HFP mode + else: + _usb_speaker_volume(target) rc = subprocess.run( ["aplay", "--buffer-time=1000000", "-D", target, wav], capture_output=True, timeout=play_timeout).returncode diff --git a/tests/test_speak_route.py b/tests/test_speak_route.py index 2e5a692..2040a9b 100644 --- a/tests/test_speak_route.py +++ b/tests/test_speak_route.py @@ -29,8 +29,10 @@ def setUp(self): def _route(self, a2dp_mac): played = [] + self.calls = [] def run(cmd, **kw): + self.calls.append(cmd[0]) if cmd[0] == "aplay": played.append(cmd[cmd.index("-D") + 1]) return mock.Mock(returncode=0) @@ -50,6 +52,16 @@ def run(cmd, **kw): def test_absent_car_falls_back_to_usb(self): self.assertEqual(self._route(None), ["plughw:0,0"]) + def test_usb_volume_is_raised_before_playback(self): + # 26 Sep: the Jabra sat at 53% and petrus heard nothing. + self._route(None) + self.assertIn("amixer", self.calls) + self.assertLess(self.calls.index("amixer"), self.calls.index("aplay")) + + def test_car_playback_leaves_the_mixer_alone(self): + self._route(CAR) + self.assertNotIn("amixer", self.calls) + def test_connect_timeout_still_falls_back_to_usb(self): # 26 Sep on vadelma: connect to the absent car hung past 10 s and the # TimeoutExpired aborted the whole reply.