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:
parent
f6cd376b9f
commit
209b4ef259
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
38
linux/blitztext/sound.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user