diff --git a/MANUAL.md b/MANUAL.md new file mode 100644 index 0000000..a09a295 --- /dev/null +++ b/MANUAL.md @@ -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. `++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 + Windows key). | +| **Stop + paste** | `stop` | Stop recording and deliver the text. Default ``. | +| **Stop + paste + Enter** | `send` | Stop, deliver, then press Enter (e.g. to send a chat message). Default ``. | +| **Cancel** | `cancel` | Discard the current recording. Default ``. | + +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. diff --git a/README.md b/README.md index 58e78f4..a8d3be3 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Blitztext is a native Linux dictation tool that captures your voice, transcribes Blitztext system-tray menu

+📖 **[User manual](MANUAL.md)** — every setting in every tab, explained. + --- ## Inspiration & Credits diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 419ce8d..b3e4c24 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -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) diff --git a/linux/blitztext/config.py b/linux/blitztext/config.py index d48067e..be848e4 100644 --- a/linux/blitztext/config.py +++ b/linux/blitztext/config.py @@ -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. diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py index cfe055c..f69fa78 100644 --- a/linux/blitztext/daemon.py +++ b/linux/blitztext/daemon.py @@ -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: diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py index bda556c..e656f42 100644 --- a/linux/blitztext/gtksettings.py +++ b/linux/blitztext/gtksettings.py @@ -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() diff --git a/linux/tests/test_daemon_notify.py b/linux/tests/test_daemon_notify.py index ca0474d..777455c 100644 --- a/linux/tests/test_daemon_notify.py +++ b/linux/tests/test_daemon_notify.py @@ -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):