From 209b4ef2592f83958c927691f14f62aa98c085b0 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 6 Jun 2026 09:40:09 +0200 Subject: [PATCH] Add custom WAV audio cues for recording start/stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- linux/blitztext/config.py | 17 +++++++++++++ linux/blitztext/daemon.py | 18 +++++++++----- linux/blitztext/gtksettings.py | 44 ++++++++++++++++++++++++++++++++++ linux/blitztext/sound.py | 38 +++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 linux/blitztext/sound.py diff --git a/linux/blitztext/config.py b/linux/blitztext/config.py index 186fac7..a17f50c 100644 --- a/linux/blitztext/config.py +++ b/linux/blitztext/config.py @@ -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) diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py index 1b0d42b..14cc3e2 100644 --- a/linux/blitztext/daemon.py +++ b/linux/blitztext/daemon.py @@ -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() diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py index bcc6888..6785c6a 100644 --- a/linux/blitztext/gtksettings.py +++ b/linux/blitztext/gtksettings.py @@ -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) diff --git a/linux/blitztext/sound.py b/linux/blitztext/sound.py new file mode 100644 index 0000000..65b6981 --- /dev/null +++ b/linux/blitztext/sound.py @@ -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