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:
parent
11796fe02b
commit
436076620c
@ -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
|
||||||
|
|||||||
@ -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,11 +87,12 @@ class LevelMeter:
|
|||||||
self.on_level(min(1.0, level * 12.0))
|
self.on_level(min(1.0, level * 12.0))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._stream = sd.InputStream(
|
with _quiet_c_stderr():
|
||||||
samplerate=16000, channels=1, dtype="float32",
|
self._stream = sd.InputStream(
|
||||||
blocksize=1600, device=self._resolve_device(), callback=_cb,
|
samplerate=16000, channels=1, dtype="float32",
|
||||||
)
|
blocksize=1600, device=self._resolve_device(), callback=_cb,
|
||||||
self._stream.start()
|
)
|
||||||
|
self._stream.start()
|
||||||
return True
|
return True
|
||||||
except Exception: # noqa: BLE001 - device may be busy/unavailable
|
except Exception: # noqa: BLE001 - device may be busy/unavailable
|
||||||
self._stream = None
|
self._stream = None
|
||||||
@ -88,7 +117,9 @@ class LevelMeter:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
if self._stream is not None:
|
if self._stream is not None:
|
||||||
try:
|
try:
|
||||||
self._stream.stop()
|
# PortAudio/ALSA spews thread-teardown noise to fd 2 here.
|
||||||
self._stream.close()
|
with _quiet_c_stderr():
|
||||||
|
self._stream.stop()
|
||||||
|
self._stream.close()
|
||||||
finally:
|
finally:
|
||||||
self._stream = None
|
self._stream = None
|
||||||
|
|||||||
@ -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 = ""
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user