From 436076620c914d04c1ccee5735f4affaa1d49105 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 6 Jun 2026 23:45:47 +0200 Subject: [PATCH] audio cues: add master on/off switch; silence PortAudio teardown noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from hands-free testing: - Audio cues had no off-switch: empty sound fields fall back to the freedesktop system chime, so wakeword/recording always made noise. Add a [sounds] enabled master flag (default true) exposed as "Play audio cues" in Settings → Input. When off, _play_cue/_play_sound are no-ops — fully silent operation. - The VAD level meter (sounddevice/PortAudio) leaked harmless thread-teardown errors ("pthread_join ... failed", "PaUnixThread_Terminate ... failed") to the terminal on every clip end. PortAudio writes these straight to fd 2, so wrap the stream open/close in a fd-level stderr suppressor (_quiet_c_stderr). Tests: cue gating respects the master switch (16 passed). Co-Authored-By: Claude Opus 4.8 (1M context) --- linux/CHANGELOG.md | 7 +++++ linux/blitztext/audio.py | 45 ++++++++++++++++++++++++++----- linux/blitztext/config.py | 8 +++++- linux/blitztext/daemon.py | 7 ++++- linux/blitztext/gtksettings.py | 7 ++++- linux/tests/test_daemon_notify.py | 17 ++++++++++++ 6 files changed, 81 insertions(+), 10 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index aaacabd..419ce8d 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -14,8 +14,15 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py). system-tray menu when the wakeword is enabled. It pauses/resumes hands-free detection by toggling the `/tmp/wake_muted` flag (external scripts may toggle the same file). +- **"Play audio cues" master switch** (Settings → Input → Audio cues, or + `[sounds] enabled` in the config): one toggle to silence every start/stop + chime, including the hands-free wakeword cues. Defaults to on. ### Fixed +- **PortAudio/ALSA teardown noise**: the level meter no longer leaks + `pthread_join ... failed` / `PaUnixThread_Terminate ... failed` lines to the + terminal when a clip ends — that C-library chatter (written straight to fd 2) + is now suppressed around the stream open/close. - **Wakeword stuck muted**: a leftover `/tmp/wake_muted` flag silently disabled detection with no in-app way to clear it. The state is now exposed and reversible from the tray, so a stale flag no longer kills hands-free use. The diff --git a/linux/blitztext/audio.py b/linux/blitztext/audio.py index 2a19879..eba045e 100644 --- a/linux/blitztext/audio.py +++ b/linux/blitztext/audio.py @@ -7,11 +7,39 @@ the chosen input and report a 0..1 level to a callback. from __future__ import annotations +import contextlib +import os import shutil import subprocess +import sys import threading +@contextlib.contextmanager +def _quiet_c_stderr(): + """Silence chatter written directly to fd 2 by C libraries. + + PortAudio/ALSA print harmless thread-teardown noise + ("pthread_join ... failed", "PaUnixThread_Terminate ... failed") straight to + the underlying stderr file descriptor, which Python-level redirection can't + catch. We briefly point fd 2 at /dev/null around the offending call. + """ + try: + stderr_fd = sys.stderr.fileno() + except (AttributeError, ValueError, OSError): + yield # No real stderr fd (already captured/redirected) — nothing to do. + return + saved_fd = os.dup(stderr_fd) + devnull_fd = os.open(os.devnull, os.O_WRONLY) + try: + os.dup2(devnull_fd, stderr_fd) + yield + finally: + os.dup2(saved_fd, stderr_fd) + os.close(devnull_fd) + os.close(saved_fd) + + def list_mics() -> list[tuple[str, str]]: """Return [(source_name, friendly_label)] for real input sources. @@ -59,11 +87,12 @@ class LevelMeter: self.on_level(min(1.0, level * 12.0)) try: - self._stream = sd.InputStream( - samplerate=16000, channels=1, dtype="float32", - blocksize=1600, device=self._resolve_device(), callback=_cb, - ) - self._stream.start() + with _quiet_c_stderr(): + self._stream = sd.InputStream( + samplerate=16000, channels=1, dtype="float32", + blocksize=1600, device=self._resolve_device(), callback=_cb, + ) + self._stream.start() return True except Exception: # noqa: BLE001 - device may be busy/unavailable self._stream = None @@ -88,7 +117,9 @@ class LevelMeter: with self._lock: if self._stream is not None: try: - self._stream.stop() - self._stream.close() + # PortAudio/ALSA spews thread-teardown noise to fd 2 here. + with _quiet_c_stderr(): + self._stream.stop() + self._stream.close() finally: self._stream = None diff --git a/linux/blitztext/config.py b/linux/blitztext/config.py index 35fbc6f..d48067e 100644 --- a/linux/blitztext/config.py +++ b/linux/blitztext/config.py @@ -52,6 +52,7 @@ class Config: reject_hallucinations: bool = True strip_trailing_punctuation: bool = False # audio cues (paths to WAV files; "" = built-in system sound) + sounds_enabled: bool = True # master switch for all start/stop/wakeword cues sound_before: str = "" sound_after: str = "" # whisper @@ -169,6 +170,7 @@ def load(path: Path = CONFIG_PATH) -> Config: silence_rms=float(q.get("silence_rms", 150.0)), reject_hallucinations=bool(q.get("reject_hallucinations", True)), strip_trailing_punctuation=bool(q.get("strip_trailing_punctuation", False)), + sounds_enabled=bool(snd.get("enabled", True)), sound_before=snd.get("before", ""), sound_after=snd.get("after", ""), wakeword_enabled=bool(ww.get("enabled", False)), @@ -276,6 +278,7 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None: "strip_trailing_punctuation": cfg.strip_trailing_punctuation, }, "sounds": { + "enabled": cfg.sounds_enabled, "before": cfg.sound_before, "after": cfg.sound_after, }, @@ -364,9 +367,12 @@ reject_hallucinations = true strip_trailing_punctuation = false [sounds] -# Optional WAV files played as audio cues. Leave empty for the built-in system +# enabled = master switch for ALL audio cues (start/stop chimes and the +# hands-free wakeword cues below). Set false for completely silent operation. +# Optional WAV files played as audio cues: leave empty for the built-in system # sound. "before" plays when recording starts; "after" plays on any stop # (stop+paste, stop+paste+Enter, or auto-stop on silence). +enabled = true before = "" after = "" diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py index a5dd20b..cfe055c 100644 --- a/linux/blitztext/daemon.py +++ b/linux/blitztext/daemon.py @@ -150,12 +150,17 @@ class Daemon: self._vad_meter = None def _play_sound(self, sound_name: str) -> None: + if not self.cfg.sounds_enabled: + return from . import sound sound.play(fallback=sound_name) def _play_cue(self, cue: str) -> None: """Play an audio cue. Hands-free (wakeword) sessions prefer the wakeword - sounds, then the general [sounds] cues, then a built-in system sound.""" + sounds, then the general [sounds] cues, then a built-in system sound. + The whole feature is gated by the [sounds] master switch.""" + if not self.cfg.sounds_enabled: + return from . import sound if cue == "before": custom = (self.cfg.wakeword_sound_detected if self._session_silent else "") or self.cfg.sound_before diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py index bdc432a..bda556c 100644 --- a/linux/blitztext/gtksettings.py +++ b/linux/blitztext/gtksettings.py @@ -773,7 +773,11 @@ class SettingsDialog: "Played when your command is captured (silence or stop) — confirms your input was taken.") page.pack_start(Gtk.Separator(), False, False, 8) - page.pack_start(Gtk.Label(label="Audio cues (manual dictation)", xalign=0.0), False, False, 2) + page.pack_start(Gtk.Label(label="Audio cues", xalign=0.0), False, False, 2) + self.snd_enabled = Gtk.Switch(); self.snd_enabled.set_active(self.cfg.sounds_enabled); self.snd_enabled.set_halign(Gtk.Align.START) + _labeled(page, "Play audio cues", self.snd_enabled, + tooltip="Master switch for every start/stop chime, including the hands-free " + "wakeword cues above. Turn off for completely silent operation.") self.snd_before = self._sound_field( page, "Play before", self.cfg.sound_before, "Sound played when recording starts — your confirmation that Blitztext is listening.") @@ -1120,6 +1124,7 @@ class SettingsDialog: c.silence_rms = float(self.q_rms.get_text()) c.reject_hallucinations = self.q_halluc.get_active() c.strip_trailing_punctuation = self.q_strip.get_active() + c.sounds_enabled = self.snd_enabled.get_active() c.sound_before = self.snd_before.get_filename() or "" c.sound_after = self.snd_after.get_filename() or "" c.wakeword_enabled = self.ww_enabled.get_active() diff --git a/linux/tests/test_daemon_notify.py b/linux/tests/test_daemon_notify.py index 6fcf030..ca0474d 100644 --- a/linux/tests/test_daemon_notify.py +++ b/linux/tests/test_daemon_notify.py @@ -43,6 +43,23 @@ def test_on_wakeword_starts_a_silent_session(monkeypatch): assert started.get("wf") is d._route_workflow +def test_audio_cues_master_switch(monkeypatch): + import blitztext.sound as sound_mod + plays = [] + monkeypatch.setattr(sound_mod, "play", lambda *a, **k: plays.append((a, k))) + d = _make_daemon(monkeypatch) + + d.cfg.sounds_enabled = False + d._play_cue("before") + d._play_sound("device-removed") + assert plays == [], "no cue should play when audio cues are disabled" + + d.cfg.sounds_enabled = True + d._play_cue("before") + d._play_sound("device-removed") + assert len(plays) == 2, "cues should play when enabled" + + def test_wakeword_while_busy_does_not_notify(monkeypatch): """The away-from-keyboard "Busy" storm: a detection arriving while the previous clip is still being processed must be ignored silently."""