Add custom WAV audio cues for recording start/stop

New sound.py plays a user WAV (or a built-in system sound) without blocking.
Config gains [sounds] before/after paths. The daemon plays the "before" cue when
recording starts and the "after" cue on every stop (stop+paste, stop+paste+Enter,
or auto-stop on silence — all funnel through finish_dictation). Input tab gets an
Audio cues section: file pickers with play-test and clear-to-default buttons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-06 09:40:09 +02:00
parent f6cd376b9f
commit 209b4ef259
4 changed files with 111 additions and 6 deletions

View File

@ -51,6 +51,9 @@ class Config:
silence_rms: float = 150.0
reject_hallucinations: bool = True
strip_trailing_punctuation: bool = False
# audio cues (paths to WAV files; "" = built-in system sound)
sound_before: str = ""
sound_after: str = ""
# whisper
model: str = "small"
device: str = "auto" # auto | cuda | cpu
@ -131,6 +134,7 @@ def load(path: Path = CONFIG_PATH) -> Config:
rt = data.get("routing", {})
inp = data.get("input", {})
q = data.get("quality", {})
snd = data.get("sounds", {})
ww = data.get("wakeword", {})
cfg = Config(
@ -163,6 +167,8 @@ 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)),
sound_before=snd.get("before", ""),
sound_after=snd.get("after", ""),
wakeword_enabled=bool(ww.get("enabled", False)),
wakeword_uri=ww.get("uri", "tcp://127.0.0.1:10400"),
wakeword_model=ww.get("model", "okay_computer"),
@ -265,6 +271,10 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None:
"reject_hallucinations": cfg.reject_hallucinations,
"strip_trailing_punctuation": cfg.strip_trailing_punctuation,
},
"sounds": {
"before": cfg.sound_before,
"after": cfg.sound_after,
},
"wakeword": {
"enabled": cfg.wakeword_enabled,
"uri": cfg.wakeword_uri,
@ -347,6 +357,13 @@ silence_rms = 150.0 # discard clips quieter than this RMS (0..32767
reject_hallucinations = true
strip_trailing_punctuation = false
[sounds]
# 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).
before = ""
after = ""
[whisper]
model = "small" # tiny | base | small | medium | large-v3, or a local path
device = "auto" # auto | cuda | cpu (auto tries cuda, falls back to cpu)

View File

@ -134,10 +134,16 @@ class Daemon:
self._vad_meter = None
def _play_sound(self, sound_name: str) -> None:
import os, subprocess, shutil
path = f"/usr/share/sounds/freedesktop/stereo/{sound_name}.oga"
if os.path.exists(path) and shutil.which("pw-play"):
subprocess.Popen(["pw-play", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
from . import sound
sound.play(fallback=sound_name)
def _play_cue(self, cue: str) -> None:
"""Play the user's WAV for 'before'/'after', else a built-in system sound."""
from . import sound
if cue == "before":
sound.play(self.cfg.sound_before, fallback="device-added")
else:
sound.play(self.cfg.sound_after, fallback="complete")
# -- recording control ----------------------------------------------------
def start_dictation(self, workflow: Workflow | None = None) -> None:
@ -185,7 +191,7 @@ class Daemon:
self._emit("recording", wf.name, "Recording…")
self._notify(f"{wf.name}", "Recording…")
self._play_sound("device-added")
self._play_cue("before")
def finish_dictation(self, send_enter: bool = False) -> None:
self._vad_stop()
@ -213,7 +219,7 @@ class Daemon:
return
audio_path = rec.stop()
self._play_sound("complete")
self._play_cue("after")
threading.Thread(
target=self._process, args=(audio_path, wf, win, send_enter), daemon=True
).start()

View File

@ -728,6 +728,48 @@ class SettingsDialog:
box = Gtk.Box(spacing=10); box.pack_start(self.ww_test_btn, False, False, 0); box.pack_start(self.ww_test_lbl, False, False, 0)
_labeled(page, "", box)
page.pack_start(Gtk.Separator(), False, False, 8)
page.pack_start(Gtk.Label(label="Audio cues", xalign=0.0), False, False, 2)
self.snd_before = self._sound_field(
page, "Play before", self.cfg.sound_before,
"Sound played when recording starts — your confirmation that Blitztext is listening.")
self.snd_after = self._sound_field(
page, "Play after", self.cfg.sound_after,
"Sound played when recording stops (on paste, paste+Enter, or auto-stop on silence).")
def _sound_field(self, page: Gtk.Box, label: str, value: str, tooltip: str = "") -> Gtk.FileChooserButton:
row = Gtk.Box(spacing=10); row.set_margin_top(3); row.set_margin_bottom(3)
lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(150, -1)
if tooltip:
lbl.set_tooltip_text(tooltip)
row.pack_start(lbl, False, False, 0)
chooser = Gtk.FileChooserButton(title=label, action=Gtk.FileChooserAction.OPEN)
af = Gtk.FileFilter(); af.set_name("Audio")
for pat in ("*.wav", "*.oga", "*.ogg", "*.flac"):
af.add_pattern(pat)
chooser.add_filter(af)
if value:
chooser.set_filename(value)
chooser.set_hexpand(True)
if tooltip:
chooser.set_tooltip_text(tooltip + " Leave empty to use the built-in system sound.")
row.pack_start(chooser, True, True, 0)
play = Gtk.Button.new_from_icon_name("media-playback-start-symbolic", Gtk.IconSize.BUTTON)
play.set_tooltip_text("Play this sound now")
play.connect("clicked", lambda _b, c=chooser: self._play_sound_file(c.get_filename()))
row.pack_start(play, False, False, 0)
clr = Gtk.Button.new_from_icon_name("edit-clear-symbolic", Gtk.IconSize.BUTTON)
clr.set_tooltip_text("Clear — use the built-in system sound")
clr.connect("clicked", lambda _b, c=chooser: c.unselect_all())
row.pack_start(clr, False, False, 0)
page.pack_start(row, False, False, 0)
return chooser
def _play_sound_file(self, path) -> None:
from . import sound
if path:
sound.play(path)
# ===== General ==========================================================
def _build_general(self, page: Gtk.Box) -> None:
self._mics = audio.list_mics()
@ -1021,6 +1063,8 @@ 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.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()
c.wakeword_uri = self.ww_uri.get_text().strip()
c.wakeword_model = _combo_text(self.ww_model)

38
linux/blitztext/sound.py Normal file
View File

@ -0,0 +1,38 @@
"""Play short audio cues (a user WAV, or a built-in system sound) without blocking."""
from __future__ import annotations
import os
import shutil
import subprocess
_FREEDESKTOP = "/usr/share/sounds/freedesktop/stereo/{name}.oga"
_PLAYERS = ("pw-play", "paplay", "aplay")
def play(path: str = "", *, fallback: str | None = None) -> None:
"""Play `path` (a WAV/OGA file); if unset/missing, play the freedesktop
`fallback` system sound. Returns immediately (fire-and-forget)."""
target = ""
if path:
expanded = os.path.expanduser(path)
if os.path.exists(expanded):
target = expanded
if not target and fallback:
fd = _FREEDESKTOP.format(name=fallback)
if os.path.exists(fd):
target = fd
if not target:
return
# aplay only handles WAV; pw-play/paplay handle WAV + OGA, so try them first.
for player in _PLAYERS:
if not shutil.which(player):
continue
if player == "aplay" and not target.lower().endswith(".wav"):
continue
try:
subprocess.Popen([player, target], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return
except OSError:
continue