audio cues: add master on/off switch; silence PortAudio teardown noise

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) <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-06 23:45:47 +02:00
parent 11796fe02b
commit 436076620c
6 changed files with 81 additions and 10 deletions

View File

@ -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 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 detection by toggling the `/tmp/wake_muted` flag (external scripts may toggle
the same file). 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 ### 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 - **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 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 reversible from the tray, so a stale flag no longer kills hands-free use. The

View File

@ -7,11 +7,39 @@ the chosen input and report a 0..1 level to a callback.
from __future__ import annotations from __future__ import annotations
import contextlib
import os
import shutil import shutil
import subprocess import subprocess
import sys
import threading 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]]: def list_mics() -> list[tuple[str, str]]:
"""Return [(source_name, friendly_label)] for real input sources. """Return [(source_name, friendly_label)] for real input sources.
@ -59,6 +87,7 @@ class LevelMeter:
self.on_level(min(1.0, level * 12.0)) self.on_level(min(1.0, level * 12.0))
try: try:
with _quiet_c_stderr():
self._stream = sd.InputStream( self._stream = sd.InputStream(
samplerate=16000, channels=1, dtype="float32", samplerate=16000, channels=1, dtype="float32",
blocksize=1600, device=self._resolve_device(), callback=_cb, blocksize=1600, device=self._resolve_device(), callback=_cb,
@ -88,6 +117,8 @@ class LevelMeter:
with self._lock: with self._lock:
if self._stream is not None: if self._stream is not None:
try: try:
# PortAudio/ALSA spews thread-teardown noise to fd 2 here.
with _quiet_c_stderr():
self._stream.stop() self._stream.stop()
self._stream.close() self._stream.close()
finally: finally:

View File

@ -52,6 +52,7 @@ class Config:
reject_hallucinations: bool = True reject_hallucinations: bool = True
strip_trailing_punctuation: bool = False strip_trailing_punctuation: bool = False
# audio cues (paths to WAV files; "" = built-in system sound) # 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_before: str = ""
sound_after: str = "" sound_after: str = ""
# whisper # whisper
@ -169,6 +170,7 @@ def load(path: Path = CONFIG_PATH) -> Config:
silence_rms=float(q.get("silence_rms", 150.0)), silence_rms=float(q.get("silence_rms", 150.0)),
reject_hallucinations=bool(q.get("reject_hallucinations", True)), reject_hallucinations=bool(q.get("reject_hallucinations", True)),
strip_trailing_punctuation=bool(q.get("strip_trailing_punctuation", False)), strip_trailing_punctuation=bool(q.get("strip_trailing_punctuation", False)),
sounds_enabled=bool(snd.get("enabled", True)),
sound_before=snd.get("before", ""), sound_before=snd.get("before", ""),
sound_after=snd.get("after", ""), sound_after=snd.get("after", ""),
wakeword_enabled=bool(ww.get("enabled", False)), 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, "strip_trailing_punctuation": cfg.strip_trailing_punctuation,
}, },
"sounds": { "sounds": {
"enabled": cfg.sounds_enabled,
"before": cfg.sound_before, "before": cfg.sound_before,
"after": cfg.sound_after, "after": cfg.sound_after,
}, },
@ -364,9 +367,12 @@ reject_hallucinations = true
strip_trailing_punctuation = false strip_trailing_punctuation = false
[sounds] [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 # sound. "before" plays when recording starts; "after" plays on any stop
# (stop+paste, stop+paste+Enter, or auto-stop on silence). # (stop+paste, stop+paste+Enter, or auto-stop on silence).
enabled = true
before = "" before = ""
after = "" after = ""

View File

@ -150,12 +150,17 @@ class Daemon:
self._vad_meter = None self._vad_meter = None
def _play_sound(self, sound_name: str) -> None: def _play_sound(self, sound_name: str) -> None:
if not self.cfg.sounds_enabled:
return
from . import sound from . import sound
sound.play(fallback=sound_name) sound.play(fallback=sound_name)
def _play_cue(self, cue: str) -> None: def _play_cue(self, cue: str) -> None:
"""Play an audio cue. Hands-free (wakeword) sessions prefer the wakeword """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 from . import sound
if cue == "before": if cue == "before":
custom = (self.cfg.wakeword_sound_detected if self._session_silent else "") or self.cfg.sound_before custom = (self.cfg.wakeword_sound_detected if self._session_silent else "") or self.cfg.sound_before

View File

@ -773,7 +773,11 @@ class SettingsDialog:
"Played when your command is captured (silence or stop) — confirms your input was taken.") "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.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( self.snd_before = self._sound_field(
page, "Play before", self.cfg.sound_before, page, "Play before", self.cfg.sound_before,
"Sound played when recording starts — your confirmation that Blitztext is listening.") "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.silence_rms = float(self.q_rms.get_text())
c.reject_hallucinations = self.q_halluc.get_active() c.reject_hallucinations = self.q_halluc.get_active()
c.strip_trailing_punctuation = self.q_strip.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_before = self.snd_before.get_filename() or ""
c.sound_after = self.snd_after.get_filename() or "" c.sound_after = self.snd_after.get_filename() or ""
c.wakeword_enabled = self.ww_enabled.get_active() c.wakeword_enabled = self.ww_enabled.get_active()

View File

@ -43,6 +43,23 @@ def test_on_wakeword_starts_a_silent_session(monkeypatch):
assert started.get("wf") is d._route_workflow 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): def test_wakeword_while_busy_does_not_notify(monkeypatch):
"""The away-from-keyboard "Busy" storm: a detection arriving while the """The away-from-keyboard "Busy" storm: a detection arriving while the
previous clip is still being processed must be ignored silently.""" previous clip is still being processed must be ignored silently."""