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
|
silence_rms: float = 150.0
|
||||||
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)
|
||||||
|
sound_before: str = ""
|
||||||
|
sound_after: str = ""
|
||||||
# whisper
|
# whisper
|
||||||
model: str = "small"
|
model: str = "small"
|
||||||
device: str = "auto" # auto | cuda | cpu
|
device: str = "auto" # auto | cuda | cpu
|
||||||
@ -131,6 +134,7 @@ def load(path: Path = CONFIG_PATH) -> Config:
|
|||||||
rt = data.get("routing", {})
|
rt = data.get("routing", {})
|
||||||
inp = data.get("input", {})
|
inp = data.get("input", {})
|
||||||
q = data.get("quality", {})
|
q = data.get("quality", {})
|
||||||
|
snd = data.get("sounds", {})
|
||||||
ww = data.get("wakeword", {})
|
ww = data.get("wakeword", {})
|
||||||
|
|
||||||
cfg = Config(
|
cfg = Config(
|
||||||
@ -163,6 +167,8 @@ 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)),
|
||||||
|
sound_before=snd.get("before", ""),
|
||||||
|
sound_after=snd.get("after", ""),
|
||||||
wakeword_enabled=bool(ww.get("enabled", False)),
|
wakeword_enabled=bool(ww.get("enabled", False)),
|
||||||
wakeword_uri=ww.get("uri", "tcp://127.0.0.1:10400"),
|
wakeword_uri=ww.get("uri", "tcp://127.0.0.1:10400"),
|
||||||
wakeword_model=ww.get("model", "okay_computer"),
|
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,
|
"reject_hallucinations": cfg.reject_hallucinations,
|
||||||
"strip_trailing_punctuation": cfg.strip_trailing_punctuation,
|
"strip_trailing_punctuation": cfg.strip_trailing_punctuation,
|
||||||
},
|
},
|
||||||
|
"sounds": {
|
||||||
|
"before": cfg.sound_before,
|
||||||
|
"after": cfg.sound_after,
|
||||||
|
},
|
||||||
"wakeword": {
|
"wakeword": {
|
||||||
"enabled": cfg.wakeword_enabled,
|
"enabled": cfg.wakeword_enabled,
|
||||||
"uri": cfg.wakeword_uri,
|
"uri": cfg.wakeword_uri,
|
||||||
@ -347,6 +357,13 @@ silence_rms = 150.0 # discard clips quieter than this RMS (0..32767
|
|||||||
reject_hallucinations = true
|
reject_hallucinations = true
|
||||||
strip_trailing_punctuation = false
|
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]
|
[whisper]
|
||||||
model = "small" # tiny | base | small | medium | large-v3, or a local path
|
model = "small" # tiny | base | small | medium | large-v3, or a local path
|
||||||
device = "auto" # auto | cuda | cpu (auto tries cuda, falls back to cpu)
|
device = "auto" # auto | cuda | cpu (auto tries cuda, falls back to cpu)
|
||||||
|
|||||||
@ -134,10 +134,16 @@ 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:
|
||||||
import os, subprocess, shutil
|
from . import sound
|
||||||
path = f"/usr/share/sounds/freedesktop/stereo/{sound_name}.oga"
|
sound.play(fallback=sound_name)
|
||||||
if os.path.exists(path) and shutil.which("pw-play"):
|
|
||||||
subprocess.Popen(["pw-play", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
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 ----------------------------------------------------
|
# -- recording control ----------------------------------------------------
|
||||||
def start_dictation(self, workflow: Workflow | None = None) -> None:
|
def start_dictation(self, workflow: Workflow | None = None) -> None:
|
||||||
@ -185,7 +191,7 @@ class Daemon:
|
|||||||
|
|
||||||
self._emit("recording", wf.name, "Recording…")
|
self._emit("recording", wf.name, "Recording…")
|
||||||
self._notify(f"● {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:
|
def finish_dictation(self, send_enter: bool = False) -> None:
|
||||||
self._vad_stop()
|
self._vad_stop()
|
||||||
@ -213,7 +219,7 @@ class Daemon:
|
|||||||
return
|
return
|
||||||
|
|
||||||
audio_path = rec.stop()
|
audio_path = rec.stop()
|
||||||
self._play_sound("complete")
|
self._play_cue("after")
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=self._process, args=(audio_path, wf, win, send_enter), daemon=True
|
target=self._process, args=(audio_path, wf, win, send_enter), daemon=True
|
||||||
).start()
|
).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)
|
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)
|
_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 ==========================================================
|
# ===== General ==========================================================
|
||||||
def _build_general(self, page: Gtk.Box) -> None:
|
def _build_general(self, page: Gtk.Box) -> None:
|
||||||
self._mics = audio.list_mics()
|
self._mics = audio.list_mics()
|
||||||
@ -1021,6 +1063,8 @@ 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.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_enabled = self.ww_enabled.get_active()
|
||||||
c.wakeword_uri = self.ww_uri.get_text().strip()
|
c.wakeword_uri = self.ww_uri.get_text().strip()
|
||||||
c.wakeword_model = _combo_text(self.ww_model)
|
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