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:
parent
436076620c
commit
ce0079e728
239
MANUAL.md
Normal file
239
MANUAL.md
Normal 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.
|
||||
@ -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">
|
||||
</p>
|
||||
|
||||
📖 **[User manual](MANUAL.md)** — every setting in every tab, explained.
|
||||
|
||||
---
|
||||
|
||||
## Inspiration & Credits
|
||||
|
||||
@ -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
|
||||
detection by toggling the `/tmp/wake_muted` flag (external scripts may toggle
|
||||
the same file).
|
||||
- **"Play audio cues" master switch** (Settings → Input → Audio cues, or
|
||||
`[sounds] enabled` in the config): one toggle to silence every start/stop
|
||||
chime, including the hands-free wakeword cues. Defaults to on.
|
||||
- **"Play audio cues" switch** (Settings → Input → Audio cues, or
|
||||
`[sounds] enabled`): on/off for the **manual** (keyboard/hotkey) start/stop
|
||||
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
|
||||
- **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
|
||||
`pthread_join ... failed` / `PaUnixThread_Terminate ... failed` lines to the
|
||||
terminal when a clip ends — that C-library chatter (written straight to fd 2)
|
||||
|
||||
@ -83,6 +83,7 @@ class Config:
|
||||
wakeword_model: str = "okay_computer"
|
||||
wakeword_sound_detected: str = "" # WAV played when the wakeword fires (speak now)
|
||||
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: 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_sound_detected=ww.get("sound_detected", ""),
|
||||
wakeword_sound_done=ww.get("sound_done", ""),
|
||||
wakeword_silence_seconds=float(ww.get("silence_seconds", 2.0)),
|
||||
)
|
||||
|
||||
for entry in data.get("workflow", []):
|
||||
@ -288,6 +290,7 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None:
|
||||
"model": cfg.wakeword_model,
|
||||
"sound_detected": cfg.wakeword_sound_detected,
|
||||
"sound_done": cfg.wakeword_sound_done,
|
||||
"silence_seconds": cfg.wakeword_silence_seconds,
|
||||
},
|
||||
"stt": {"active": cfg.stt_active},
|
||||
"stt_engine": [
|
||||
@ -367,11 +370,11 @@ reject_hallucinations = true
|
||||
strip_trailing_punctuation = false
|
||||
|
||||
[sounds]
|
||||
# enabled = master switch for ALL audio cues (start/stop chimes and the
|
||||
# hands-free wakeword cues below). Set false for completely silent operation.
|
||||
# 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).
|
||||
# Audio cues for MANUAL (keyboard/hotkey) dictation. The hands-free wakeword
|
||||
# cues are separate and independent — see [wakeword] sound_detected/sound_done.
|
||||
# enabled = on/off for these manual cues. "before"/"after" are optional WAV
|
||||
# files; leave empty for the built-in system sound. "before" plays when
|
||||
# recording starts; "after" on any stop (paste, paste+Enter, or auto-stop).
|
||||
enabled = true
|
||||
before = ""
|
||||
after = ""
|
||||
@ -410,11 +413,14 @@ threshold = 0.82 # 0..1 fuzzy-match strictness (higher = stricter)
|
||||
enabled = false
|
||||
uri = "tcp://127.0.0.1:10400"
|
||||
model = "okay_computer"
|
||||
# Optional WAV cues for hands-free use (override the [sounds] cues when the
|
||||
# wakeword triggers): played when the wakeword fires (speak now) and when the
|
||||
# command is captured.
|
||||
# Audio cues for hands-free sessions, independent of [sounds] above: play the
|
||||
# given WAV when the wakeword fires (speak now) and when the 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_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.
|
||||
|
||||
@ -132,11 +132,12 @@ class Daemon:
|
||||
self._vad_started_at = time.time()
|
||||
self._vad_last_speech = time.time()
|
||||
|
||||
silence = max(0.5, self.cfg.wakeword_silence_seconds)
|
||||
def on_level(level):
|
||||
now = time.time()
|
||||
if level > 0.05:
|
||||
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):
|
||||
GLib.idle_add(lambda: self.finish_dictation(send_enter=False))
|
||||
self._vad_stop()
|
||||
@ -156,18 +157,28 @@ class Daemon:
|
||||
sound.play(fallback=sound_name)
|
||||
|
||||
def _play_cue(self, cue: str) -> None:
|
||||
"""Play an audio cue. Hands-free (wakeword) sessions prefer the wakeword
|
||||
sounds, then the general [sounds] cues, then a built-in system sound.
|
||||
The whole feature is gated by the [sounds] master switch."""
|
||||
"""Audio feedback for a dictation session.
|
||||
|
||||
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:
|
||||
return
|
||||
from . import sound
|
||||
if cue == "before":
|
||||
custom = (self.cfg.wakeword_sound_detected if self._session_silent else "") or self.cfg.sound_before
|
||||
sound.play(custom, fallback="device-added")
|
||||
sound.play(self.cfg.sound_before, fallback="device-added")
|
||||
else:
|
||||
custom = (self.cfg.wakeword_sound_done if self._session_silent else "") or self.cfg.sound_after
|
||||
sound.play(custom, fallback="complete")
|
||||
sound.play(self.cfg.sound_after, fallback="complete")
|
||||
|
||||
# -- recording control ----------------------------------------------------
|
||||
def start_dictation(self, workflow: Workflow | None = None, silent: bool = False) -> None:
|
||||
|
||||
@ -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)
|
||||
_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(
|
||||
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(
|
||||
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.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)
|
||||
_labeled(page, "Play audio cues", self.snd_enabled,
|
||||
tooltip="Master switch for every start/stop chime, including the hands-free "
|
||||
"wakeword cues above. Turn off for completely silent operation.")
|
||||
tooltip="On/off for the MANUAL start/stop chimes below (keyboard/hotkey dictation). "
|
||||
"The hands-free wakeword sounds above are separate and always play when set.")
|
||||
self.snd_before = self._sound_field(
|
||||
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(
|
||||
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)
|
||||
lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(150, -1)
|
||||
if tooltip:
|
||||
@ -800,14 +816,14 @@ class SettingsDialog:
|
||||
chooser.set_filename(value)
|
||||
chooser.set_hexpand(True)
|
||||
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)
|
||||
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.set_tooltip_text(clear_tip)
|
||||
clr.connect("clicked", lambda _b, c=chooser: c.unselect_all())
|
||||
row.pack_start(clr, 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_sound_detected = self.ww_snd_detected.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.output = self.gen_output.get_active_text() or "type"
|
||||
c.language = self.gen_lang.get_text().strip()
|
||||
|
||||
@ -43,21 +43,46 @@ def test_on_wakeword_starts_a_silent_session(monkeypatch):
|
||||
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
|
||||
plays = []
|
||||
monkeypatch.setattr(sound_mod, "play", lambda *a, **k: plays.append((a, k)))
|
||||
d = _make_daemon(monkeypatch)
|
||||
d._session_silent = False # manual (keyboard) session
|
||||
|
||||
d.cfg.sounds_enabled = False
|
||||
d._play_cue("before")
|
||||
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._play_cue("before")
|
||||
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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user