wakeword: independent cues + configurable auto-stop silence; add MANUAL.md

Reported issues from hands-free use:

- Wakeword WAVs didn't play: the new "Play audio cues" master switch
  ([sounds] enabled) also gated the hands-free Sound: detected/captured cues,
  so enabled=false silenced them. The cues live in a separate UI section, so
  this was surprising. Wakeword cues are now independent of that switch: they
  play whenever a file is set, and an empty field means silent (no system-chime
  fallback) — which is also how you turn a hands-free cue off. The master switch
  now governs only the manual (keyboard) before/after chimes.

- Clarified the four sound fields' tooltips/labels (detected/captured = hands-free
  only; before/after = manual only) and the empty-field behaviour.

- New "Silence to stop (s)" setting (Settings → Input → Hands-free, or
  [wakeword] silence_seconds, default 2.0): user-defined trailing-silence
  timeout for hands-free auto-stop (was hard-coded to 2.5 s).

- Add MANUAL.md documenting every setting in every tab; link it from the README.

Tests: cue independence + manual-gating + roundtrip (17 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-07 08:36:35 +02:00
parent 436076620c
commit ce0079e728
7 changed files with 342 additions and 33 deletions

239
MANUAL.md Normal file
View File

@ -0,0 +1,239 @@
# Blitztext — User Manual
A reference for every setting in the Blitztext **Settings** window, tab by tab.
Open Settings from the system-tray menu (**Settings…**) or the control panel. The
window has seven tabs — **Presets · Engines · Input · General · Benchmark · Log ·
About** — and three buttons along the bottom.
> **Where settings are stored:** `~/.config/blitztext/config.toml`
> (or `$XDG_CONFIG_HOME/blitztext/config.toml`). You can edit that file directly;
> the relevant TOML key is noted next to each setting below.
### Saving your changes
| Button | What it does |
|---|---|
| **Close** | Discard and close. Nothing is written. |
| **Save** | Write `config.toml`. A note reminds you that **engine/hotkey changes need a restart** to take effect. |
| **Save & Restart** | Write `config.toml` and immediately relaunch Blitztext (`blitztext tray`) so every change applies. Use this after changing engines, hotkeys, or the wakeword. |
---
## Presets tab
Presets are your dictation **actions**. Each one either types what you say, or
rewrites it through the language model first (e.g. into a polished email). Trigger
a preset by speaking its keyword, or with an optional keyboard shortcut.
Use the dropdown at the top to pick a preset to edit, **+ Add** to create one, or
**Delete** to remove it (you must keep at least one). Each preset maps to a
`[[workflow]]` entry in the config.
| Setting | TOML key | Description |
|---|---|---|
| **Name** | `name` | Short name for the action, shown in the main panel. |
| **Description** | `description` | One line explaining what the preset does (shown in the panel). |
| **Keywords (comma)** | `keywords` | Spoken trigger words, comma-separated. Say one at the **start or end** of your speech to select this preset (fuzzy-matched, e.g. `nicer email, bessere email`). |
| **Hotkey (optional)** | `hotkey` | A direct keyboard shortcut for this preset. Click **Set** and press the combo, or type it (e.g. `<ctrl>+<alt>+e`). Leave blank for keyword-only. |
| **Mode** | `mode` | `transcribe` types your words as-is · `rewrite` sends them to the language model first · `stream` shows live text from a realtime STT engine. |
| **LLM model (opt.)** | `model` | Override the language model for *this preset only*. Blank = use the active LLM engine's model. |
| **Temperature (opt.)** | `temperature` | Creativity of the rewrite, `0``1`. Lower is more predictable. Blank = engine default. |
| **Prompt sent to the LLM** | `prompt` | The instruction used in `rewrite` mode (e.g. "Rewrite this as a polite, professional email"). Ignored in `transcribe`/`stream` mode. |
---
## Engines tab
Engines do the work: the **speech-to-text (STT)** engine turns your voice into
text; the **language model (LLM)** rewrites it. Each engine can run locally or on
a server you specify. A **green dot** means it's reachable, **red** means offline.
The currently selected engine in each dropdown is the **active** one.
### Speech-to-text engine
Buttons: **+ Add** (cloud/OpenAI-style), **+ Stream** (realtime Riva/NIM),
**Delete**, **Test** (records 4 s and transcribes), **Refresh** (re-check status).
Each engine maps to a `[[stt_engine]]` entry; the active one is `[stt] active`.
| Setting | TOML key | Description |
|---|---|---|
| **Name** | `name` | A label for this engine (e.g. "faster-whisper GPU"). |
| **Type** | `type` | `local` (in-process faster-whisper) · `openai` (any OpenAI-compatible `/v1` STT server) · `riva_realtime` (live streaming engine). |
| **URL** | `url` | Server endpoint. Example: `http://localhost:8010/v1` · realtime: `http://localhost:8006/v1`. Ignored for `local`. |
| **Model** | `model` | Model name. For `local`: `tiny`/`base`/`small`/`medium`/`large-v3` or a path. For remote: blank = server default, or pick from the searchable list fetched from the URL. |
| **API key env** | `api_key_env` | *Name of the environment variable* holding the API key (e.g. `GROQ_API_KEY`). Optional. |
**Local engine (faster-whisper) — device & precision** (global, `[whisper]`):
| Setting | TOML key | Description |
|---|---|---|
| **Device** | `device` | `auto` (try CUDA, fall back to CPU) · `cpu` · `cuda`. |
| **Compute type** | `compute_type` | `auto` · `int8` · `float16` · `int8_float16`. Lower precision is faster and uses less memory. |
### Language model (rewrite)
Buttons: **+ Add**, **Delete**, **Refresh**. Each maps to a `[[llm_engine]]`
entry; the active one is `[llm] active`.
| Setting | TOML key | Description |
|---|---|---|
| **Name** | `name` | A label for this LLM (e.g. "Local Qwen"). |
| **Type** | `type` | `local` (a server on this machine) or `cloud`. |
| **Base URL** | `url` | OpenAI-compatible endpoint, e.g. `http://localhost:28080/v1` or `https://api.openai.com/v1`. |
| **Model** | `model` | The model to use; pick from the list once the URL is set. |
| **API key env** | `api_key_env` | Environment-variable name holding the key (e.g. `OPENAI_API_KEY`). Blank for local servers. |
| **Temperature** | `temperature` | Default creativity for rewrites (e.g. `0.3`). Presets can override this. |
---
## Input tab
Controls **how you start and stop** dictating, the noise filter, hands-free
wakeword, and audio cues.
### Input mode & keys
All keys live in the `[input]` section.
| Setting | TOML key | Description |
|---|---|---|
| **Input mode** | `mode` | `modifiers`: hold/press the keys below · `hotkeys`: each preset uses its own shortcut combo (set per preset). |
| **Push-to-talk** | `push_to_talk` | When on, recording lasts only while the Start key is **held** (release to stop). When off, the keys **toggle** recording. |
| **Start** | `start` | Key(s) to start recording. Default `<ctrl>+<cmd>` (Ctrl + Windows key). |
| **Stop + paste** | `stop` | Stop recording and deliver the text. Default `<ctrl>`. |
| **Stop + paste + Enter** | `send` | Stop, deliver, then press Enter (e.g. to send a chat message). Default `<alt>`. |
| **Cancel** | `cancel` | Discard the current recording. Default `<esc>`. |
Click **Set** next to a key field and press the combination to rebind it.
### Quality gate
Filters out clips that aren't real speech before they're transcribed. Keys live
in the `[quality]` section.
| Setting | TOML key | Description |
|---|---|---|
| **Min seconds** | `min_speech_seconds` | Minimum audio length; shorter clips are ignored. Default `0.4`. |
| **Silence RMS** | `silence_rms` | Microphone-volume threshold below which a clip counts as silent and is dropped. Default `150.0`. |
| **Reject hallucinations** | `reject_hallucinations` | Drop STT "ghost" outputs like *"Thank you."* / *"Bye."* that Whisper invents from silence. |
| **Strip trailing punctuation** | `strip_trailing_punctuation` | Remove ending periods from delivered text — handy for code insertion. |
### Hands-free (Wakeword)
Start dictation with a spoken keyword via an external
[Wyoming](https://github.com/rhasspy/wyoming) openWakeWord server. Maps to the
`[wakeword]` section.
| Setting | TOML key | Description |
|---|---|---|
| **Enable wakeword** | `enabled` | Turn hands-free detection on/off. |
| **Wyoming URI** | `uri` | Address of the wakeword server. Default `tcp://127.0.0.1:10400`. The ⟳ button loads the available models from it. |
| **Model name** | `model` | Which wake model to listen for (e.g. `computer`, `okay_computer`). Pick from the list loaded from the server. |
| **Input level** | — | Live mic level bar (read-only) so you can confirm the microphone is being heard. |
| **Test Wakeword** | — | Listens for 10 s and reports whether the wake word was detected. |
| **Silence to stop (s)** | `silence_seconds` | After the wakeword starts recording, end it this many seconds after you stop speaking. Hands-free auto-stop (the wakeword can't be released like a key). Default `2.0`. |
| **Sound: detected** | `sound_detected` | WAV/OGA played the instant the wake word fires and recording starts — your "speak now" cue (**hands-free sessions only**). **Empty = no sound.** Independent of the *Play audio cues* switch. |
| **Sound: captured** | `sound_done` | Played when your spoken command is captured and recording stops (silence/stop) (**hands-free sessions only**). **Empty = no sound.** |
> **Tip:** A hands-free session suppresses desktop notifications, so these sounds
> are its *only* feedback — that's why they're independent of the manual "Play
> audio cues" switch, and why an empty field means silence (not a system chime).
> You can also pause/resume detection from the tray ("Pause wakeword"), which
> toggles the `/tmp/wake_muted` flag.
### Audio cues (manual dictation)
These control the chimes for **manual** (keyboard/hotkey) dictation only. The
hands-free wakeword sounds above are **separate and independent**.
| Setting | TOML key | Description |
|---|---|---|
| **Play audio cues** | `[sounds] enabled` | On/off for the **manual** start/stop chimes below. Does **not** affect the wakeword sounds above. |
| **Play before** | `[sounds] before` | Chime when recording **starts** (manual dictation). Empty = built-in system sound. |
| **Play after** | `[sounds] after` | Chime when recording **stops** (paste, paste+Enter, or auto-stop on silence). Empty = built-in system sound. |
> Each sound row has ▶ (preview) and ⌫ (clear).
>
> **The two pairs differ by trigger *and* by empty-behaviour:**
>
> | | Plays on | Used for | When empty |
> |---|---|---|---|
> | *Sound: detected / captured* | start / stop | **hands-free wakeword** only | **silent** |
> | *Play before / after* | start / stop | **manual** (keyboard) only | **system chime** |
---
## General tab
Microphone, text delivery, language, notifications, and autostart.
| Setting | TOML key | Description |
|---|---|---|
| **Microphone** | `mic` | Which input device Blitztext records from. |
| **Input level** | — | Live level bar (read-only); should move when you speak. |
| **Output** | `output` | `type` types the text key-by-key · `paste` copies it and presses Ctrl+V (faster for long text). |
| **Language hint** | `language` | Spoken-language code (`de`, `en`, …). Blank = auto-detect. |
| **Notifications** | `notify` | Show desktop notifications for recording/transcription status and errors (manual sessions). |
| **Launch on login** | *(autostart file)* | Start Blitztext automatically when you log in (writes a desktop autostart entry, not `config.toml`). |
---
## Benchmark tab
Compare your STT engines for **speed and accuracy** on the same clip. Add an
engine preset (Engines tab) for each model you want to compare. No persistent
settings — it's a one-off tool.
1. **Audio (.wav)** — a recording to transcribe.
2. **Reference (.txt)** — a text file with *exactly* what is said. (Auto-filled if
a matching `*.txt` / `*.reference.txt` sits next to the WAV.)
3. **Run benchmark** — fills the table with one row per engine.
Result columns: **Engine · Model · Device · Time (s) · Accuracy · Output**. A
summary line names the **fastest** and **most accurate** engine.
---
## Log tab
A live activity log — useful to watch a model load/download or to diagnose a
problem (recording, transcription, routing, and wakeword events all appear here).
Press **Copy** to put the log on the clipboard when reporting an issue. No
settings.
---
## About tab
Read-only information:
- **Version** and a link to the source repository
(`github.com/mARTin-B78/blitztext-app-linux`).
- **License: MIT**.
- Sub-tabs with the full **Changelog** and **License** text.
---
## System-tray menu (quick reference)
| Item | What it does |
|---|---|
| **● status** | Current state (Ready / Recording / Transcribing / Error). |
| *Preset names* | Click to trigger that preset. |
| **Pause wakeword** | Reversible toggle to pause/resume hands-free detection (only shown when the wakeword is enabled). |
| **Show panel** | Open the control panel window. |
| **Settings…** | Open this Settings window. |
| **Quit Blitztext** | Exit the app. |
---
## Config-only options
A few behaviours live in `config.toml` without a dedicated tab control:
- **`[routing]`** — voice-keyword routing: `enabled`, `hotkey` (one shortcut to
dictate and let the spoken keyword pick the preset), `default` (preset used when
no keyword matches), and `threshold` (`0``1` fuzzy-match strictness).
- **`timeout`** — network timeout (seconds) for remote STT/LLM requests.
- **`type_delay_ms`** — delay between simulated keystrokes in `type` output mode.

View File

@ -13,6 +13,8 @@ Blitztext is a native Linux dictation tool that captures your voice, transcribes
<img src="Screenshots/tray-menu.png" alt="Blitztext system-tray menu" width="300"> <img src="Screenshots/tray-menu.png" alt="Blitztext system-tray menu" width="300">
</p> </p>
📖 **[User manual](MANUAL.md)** — every setting in every tab, explained.
--- ---
## Inspiration & Credits ## Inspiration & Credits

View File

@ -14,11 +14,20 @@ 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 - **"Play audio cues" switch** (Settings → Input → Audio cues, or
`[sounds] enabled` in the config): one toggle to silence every start/stop `[sounds] enabled`): on/off for the **manual** (keyboard/hotkey) start/stop
chime, including the hands-free wakeword cues. Defaults to on. chimes. Defaults to on. The hands-free wakeword sounds are independent of it.
- **Configurable wakeword auto-stop silence** (Settings → Input → Hands-free →
"Silence to stop (s)", or `[wakeword] silence_seconds`): end a hands-free
recording this many seconds after you stop speaking. Defaults to `2.0`
(previously hard-coded to 2.5 s).
### Fixed ### Fixed
- **Wakeword sounds silenced by the manual cue switch**: the "Play audio cues"
master switch wrongly muted the hands-free *Sound: detected/captured* cues too.
Wakeword cues are now independent — they play whenever a file is set and stay
silent when cleared (no surprise system-chime fallback), regardless of the
manual switch.
- **PortAudio/ALSA teardown noise**: the level meter no longer leaks - **PortAudio/ALSA teardown noise**: the level meter no longer leaks
`pthread_join ... failed` / `PaUnixThread_Terminate ... failed` lines to the `pthread_join ... failed` / `PaUnixThread_Terminate ... failed` lines to the
terminal when a clip ends — that C-library chatter (written straight to fd 2) terminal when a clip ends — that C-library chatter (written straight to fd 2)

View File

@ -83,6 +83,7 @@ class Config:
wakeword_model: str = "okay_computer" wakeword_model: str = "okay_computer"
wakeword_sound_detected: str = "" # WAV played when the wakeword fires (speak now) wakeword_sound_detected: str = "" # WAV played when the wakeword fires (speak now)
wakeword_sound_done: str = "" # WAV played when the command is captured wakeword_sound_done: str = "" # WAV played when the command is captured
wakeword_silence_seconds: float = 2.0 # auto-stop after this much trailing silence
# workflows # workflows
workflows: list[Workflow] = field(default_factory=list) workflows: list[Workflow] = field(default_factory=list)
@ -178,6 +179,7 @@ def load(path: Path = CONFIG_PATH) -> Config:
wakeword_model=ww.get("model", "okay_computer"), wakeword_model=ww.get("model", "okay_computer"),
wakeword_sound_detected=ww.get("sound_detected", ""), wakeword_sound_detected=ww.get("sound_detected", ""),
wakeword_sound_done=ww.get("sound_done", ""), wakeword_sound_done=ww.get("sound_done", ""),
wakeword_silence_seconds=float(ww.get("silence_seconds", 2.0)),
) )
for entry in data.get("workflow", []): for entry in data.get("workflow", []):
@ -288,6 +290,7 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None:
"model": cfg.wakeword_model, "model": cfg.wakeword_model,
"sound_detected": cfg.wakeword_sound_detected, "sound_detected": cfg.wakeword_sound_detected,
"sound_done": cfg.wakeword_sound_done, "sound_done": cfg.wakeword_sound_done,
"silence_seconds": cfg.wakeword_silence_seconds,
}, },
"stt": {"active": cfg.stt_active}, "stt": {"active": cfg.stt_active},
"stt_engine": [ "stt_engine": [
@ -367,11 +370,11 @@ reject_hallucinations = true
strip_trailing_punctuation = false strip_trailing_punctuation = false
[sounds] [sounds]
# enabled = master switch for ALL audio cues (start/stop chimes and the # Audio cues for MANUAL (keyboard/hotkey) dictation. The hands-free wakeword
# hands-free wakeword cues below). Set false for completely silent operation. # cues are separate and independent — see [wakeword] sound_detected/sound_done.
# Optional WAV files played as audio cues: leave empty for the built-in system # enabled = on/off for these manual cues. "before"/"after" are optional WAV
# sound. "before" plays when recording starts; "after" plays on any stop # files; leave empty for the built-in system sound. "before" plays when
# (stop+paste, stop+paste+Enter, or auto-stop on silence). # recording starts; "after" on any stop (paste, paste+Enter, or auto-stop).
enabled = true enabled = true
before = "" before = ""
after = "" after = ""
@ -410,11 +413,14 @@ threshold = 0.82 # 0..1 fuzzy-match strictness (higher = stricter)
enabled = false enabled = false
uri = "tcp://127.0.0.1:10400" uri = "tcp://127.0.0.1:10400"
model = "okay_computer" model = "okay_computer"
# Optional WAV cues for hands-free use (override the [sounds] cues when the # Audio cues for hands-free sessions, independent of [sounds] above: play the
# wakeword triggers): played when the wakeword fires (speak now) and when the # given WAV when the wakeword fires (speak now) and when the command is captured.
# command is captured. # Leave empty for NO sound — these are a hands-free session's only feedback,
# since its desktop notifications are suppressed.
sound_detected = "" sound_detected = ""
sound_done = "" sound_done = ""
# Auto-stop the recording this many seconds after you stop speaking (silence).
silence_seconds = 2.0
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# Speech-to-text engines (presets). The active one is used for transcription. # Speech-to-text engines (presets). The active one is used for transcription.

View File

@ -132,11 +132,12 @@ class Daemon:
self._vad_started_at = time.time() self._vad_started_at = time.time()
self._vad_last_speech = time.time() self._vad_last_speech = time.time()
silence = max(0.5, self.cfg.wakeword_silence_seconds)
def on_level(level): def on_level(level):
now = time.time() now = time.time()
if level > 0.05: if level > 0.05:
self._vad_last_speech = now self._vad_last_speech = now
elif now - self._vad_started_at > 2.0 and now - self._vad_last_speech > 2.5: elif now - self._vad_started_at > 2.0 and now - self._vad_last_speech > silence:
if getattr(self, "is_recording", False): if getattr(self, "is_recording", False):
GLib.idle_add(lambda: self.finish_dictation(send_enter=False)) GLib.idle_add(lambda: self.finish_dictation(send_enter=False))
self._vad_stop() self._vad_stop()
@ -156,18 +157,28 @@ class Daemon:
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 """Audio feedback for a dictation session.
sounds, then the general [sounds] cues, then a built-in system sound.
The whole feature is gated by the [sounds] master switch.""" Hands-free (wakeword) sessions play only their own dedicated cue, or
nothing when it is unset they are independent of the manual 'Play audio
cues' switch, because the sound is the *only* feedback a hands-free
session gets (its notifications are suppressed). Manual sessions use the
[sounds] cues, gated by that switch, and fall back to a built-in system
sound when no file is configured."""
from . import sound
if self._session_silent:
# Hands-free: the chosen wakeword sound, or silence. No fallback, so
# clearing the field is how you turn the cue off.
path = self.cfg.wakeword_sound_detected if cue == "before" else self.cfg.wakeword_sound_done
if path:
sound.play(path)
return
if not self.cfg.sounds_enabled: if not self.cfg.sounds_enabled:
return return
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 sound.play(self.cfg.sound_before, fallback="device-added")
sound.play(custom, fallback="device-added")
else: else:
custom = (self.cfg.wakeword_sound_done if self._session_silent else "") or self.cfg.sound_after sound.play(self.cfg.sound_after, fallback="complete")
sound.play(custom, fallback="complete")
# -- recording control ---------------------------------------------------- # -- recording control ----------------------------------------------------
def start_dictation(self, workflow: Workflow | None = None, silent: bool = False) -> None: def start_dictation(self, workflow: Workflow | None = None, silent: bool = False) -> None:

View File

@ -765,27 +765,43 @@ 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)
self.ww_silence = _labeled(page, "Silence to stop (s)", _entry(self.cfg.wakeword_silence_seconds),
tooltip="After the wakeword starts recording, end it this many seconds "
"after you stop speaking. Hands-free auto-stop — the wakeword "
"can't be released like a key. Default 2.0.")
self.ww_snd_detected = self._sound_field( self.ww_snd_detected = self._sound_field(
page, "Sound: detected", self.cfg.wakeword_sound_detected, page, "Sound: detected", self.cfg.wakeword_sound_detected,
"Played right after the wakeword is recognised — your cue that Blitztext is listening, speak now.") "HANDS-FREE ONLY. Plays the instant the wake word is recognised and recording starts "
"— your speak now cue. (Keyboard/hotkey dictation ignores this and uses Play before.)",
empty_note="Leave empty for no sound. These wakeword cues are independent of the "
"Play audio cues switch below.",
clear_tip="Clear — no sound")
self.ww_snd_done = self._sound_field( self.ww_snd_done = self._sound_field(
page, "Sound: captured", self.cfg.wakeword_sound_done, page, "Sound: captured", self.cfg.wakeword_sound_done,
"Played when your command is captured (silence or stop) — confirms your input was taken.") "HANDS-FREE ONLY. Plays when your spoken command is captured and recording stops "
"(on silence or stop). (Keyboard/hotkey dictation ignores this and uses Play after.)",
empty_note="Leave empty for no sound.",
clear_tip="Clear — no sound")
page.pack_start(Gtk.Separator(), False, False, 8) page.pack_start(Gtk.Separator(), False, False, 8)
page.pack_start(Gtk.Label(label="Audio cues", xalign=0.0), False, False, 2) page.pack_start(Gtk.Label(label="Audio cues (manual dictation)", 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) 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, _labeled(page, "Play audio cues", self.snd_enabled,
tooltip="Master switch for every start/stop chime, including the hands-free " tooltip="On/off for the MANUAL start/stop chimes below (keyboard/hotkey dictation). "
"wakeword cues above. Turn off for completely silent operation.") "The hands-free wakeword sounds above are separate and always play when set.")
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.") "MANUAL (keyboard/hotkey) dictation only. Plays when recording starts. "
"(Hands-free sessions use Sound: detected instead.)")
self.snd_after = self._sound_field( self.snd_after = self._sound_field(
page, "Play after", self.cfg.sound_after, page, "Play after", self.cfg.sound_after,
"Sound played when recording stops (on paste, paste+Enter, or auto-stop on silence).") "MANUAL (keyboard/hotkey) dictation only. Plays when recording stops "
"(paste, paste+Enter, or auto-stop on silence). (Hands-free uses Sound: captured instead.)")
def _sound_field(self, page: Gtk.Box, label: str, value: str, tooltip: str = "") -> Gtk.FileChooserButton: def _sound_field(self, page: Gtk.Box, label: str, value: str, tooltip: str = "",
empty_note: str = "Leave empty to use the built-in system sound.",
clear_tip: str = "Clear — use the built-in system sound") -> Gtk.FileChooserButton:
row = Gtk.Box(spacing=10); row.set_margin_top(3); row.set_margin_bottom(3) 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) lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(150, -1)
if tooltip: if tooltip:
@ -800,14 +816,14 @@ class SettingsDialog:
chooser.set_filename(value) chooser.set_filename(value)
chooser.set_hexpand(True) chooser.set_hexpand(True)
if tooltip: if tooltip:
chooser.set_tooltip_text(tooltip + " Leave empty to use the built-in system sound.") chooser.set_tooltip_text(f"{tooltip} {empty_note}")
row.pack_start(chooser, True, True, 0) row.pack_start(chooser, True, True, 0)
play = Gtk.Button.new_from_icon_name("media-playback-start-symbolic", Gtk.IconSize.BUTTON) play = Gtk.Button.new_from_icon_name("media-playback-start-symbolic", Gtk.IconSize.BUTTON)
play.set_tooltip_text("Play this sound now") play.set_tooltip_text("Play this sound now")
play.connect("clicked", lambda _b, c=chooser: self._play_sound_file(c.get_filename())) play.connect("clicked", lambda _b, c=chooser: self._play_sound_file(c.get_filename()))
row.pack_start(play, False, False, 0) row.pack_start(play, False, False, 0)
clr = Gtk.Button.new_from_icon_name("edit-clear-symbolic", Gtk.IconSize.BUTTON) 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.set_tooltip_text(clear_tip)
clr.connect("clicked", lambda _b, c=chooser: c.unselect_all()) clr.connect("clicked", lambda _b, c=chooser: c.unselect_all())
row.pack_start(clr, False, False, 0) row.pack_start(clr, False, False, 0)
page.pack_start(row, False, False, 0) page.pack_start(row, False, False, 0)
@ -1132,6 +1148,7 @@ class SettingsDialog:
c.wakeword_model = _combo_text(self.ww_model) c.wakeword_model = _combo_text(self.ww_model)
c.wakeword_sound_detected = self.ww_snd_detected.get_filename() or "" c.wakeword_sound_detected = self.ww_snd_detected.get_filename() or ""
c.wakeword_sound_done = self.ww_snd_done.get_filename() or "" c.wakeword_sound_done = self.ww_snd_done.get_filename() or ""
c.wakeword_silence_seconds = float(self.ww_silence.get_text())
c.mic = self._selected_mic_name() c.mic = self._selected_mic_name()
c.output = self.gen_output.get_active_text() or "type" c.output = self.gen_output.get_active_text() or "type"
c.language = self.gen_lang.get_text().strip() c.language = self.gen_lang.get_text().strip()

View File

@ -43,21 +43,46 @@ 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): def test_manual_cues_gated_by_master_switch(monkeypatch):
import blitztext.sound as sound_mod import blitztext.sound as sound_mod
plays = [] plays = []
monkeypatch.setattr(sound_mod, "play", lambda *a, **k: plays.append((a, k))) monkeypatch.setattr(sound_mod, "play", lambda *a, **k: plays.append((a, k)))
d = _make_daemon(monkeypatch) d = _make_daemon(monkeypatch)
d._session_silent = False # manual (keyboard) session
d.cfg.sounds_enabled = False d.cfg.sounds_enabled = False
d._play_cue("before") d._play_cue("before")
d._play_sound("device-removed") d._play_sound("device-removed")
assert plays == [], "no cue should play when audio cues are disabled" assert plays == [], "manual cues should be silent when 'Play audio cues' is off"
d.cfg.sounds_enabled = True d.cfg.sounds_enabled = True
d._play_cue("before") d._play_cue("before")
d._play_sound("device-removed") d._play_sound("device-removed")
assert len(plays) == 2, "cues should play when enabled" assert len(plays) == 2, "manual cues should play when enabled"
def test_wakeword_cues_independent_of_master_switch(monkeypatch):
"""Regression: the manual 'Play audio cues' switch must NOT silence the
hands-free wakeword sounds (the bug where enabled=false killed the beeps)."""
import blitztext.sound as sound_mod
plays = []
monkeypatch.setattr(sound_mod, "play", lambda *a, **k: plays.append((a, k)))
d = _make_daemon(monkeypatch)
d._session_silent = True # hands-free session
d.cfg.sounds_enabled = False # manual cues off
d.cfg.wakeword_sound_detected = "/snd/beep_start.wav"
d.cfg.wakeword_sound_done = "/snd/beep_stop.wav"
d._play_cue("before")
d._play_cue("after")
assert [p[0][0] for p in plays] == ["/snd/beep_start.wav", "/snd/beep_stop.wav"], \
"wakeword cues must play regardless of the manual master switch"
# Empty wakeword sound = silent (no system-sound fallback).
plays.clear()
d.cfg.wakeword_sound_detected = ""
d._play_cue("before")
assert plays == [], "an unset wakeword cue should be silent, not fall back to a system sound"
def test_wakeword_while_busy_does_not_notify(monkeypatch): def test_wakeword_while_busy_does_not_notify(monkeypatch):