Compare commits

...

10 Commits

Author SHA1 Message Date
Hermes Agent
f678d7d20f security: add path validation for user-configured sound files
Some checks failed
CI / Test Linux app (push) Has been cancelled
Adds validate_sound_path() that checks file type, allowed directories, and known audio extensions. Updates play() to use validation before passing paths to audio players.
2026-06-15 07:38:05 +00:00
8f056e8cf0 feat: cancel button during transcription and rewriting (v2.03.41)
- overlay: show × button in busy state (transcribing/rewriting), not only
  while recording — updates hit-region, draw call, and label layout
- daemon: cancel_dictation() now handles _busy via threading.Event
  (_abort_event); _process() clears the event at start, checks after STT
  returns and after LLM rewrite completes, skipping delivery if set
- llm: chat() and _read_stream() accept abort_event; streaming loop breaks
  immediately when the event is set so cancellation is near-instant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:57:41 +02:00
4915884316 chore: ignore raw screenshot source folders
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:46:26 +02:00
e053719c59 docs: screenshots, README and MANUAL overhaul (v2.03.40)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:26:19 +02:00
4b8b9db43e docs: add screenshots to README and MANUAL, rename screenshot files
- Renamed 21 raw screenshots to meaningful filenames under Screenshots/
- Updated README.md and linux/README.md: added organized screenshot
  sections (Main panel & overlay, Settings pages, Setup wizard) using
  new filenames with click-to-enlarge links
- Updated MANUAL.md: rewrote intro to reflect new sidebar navigation
  (Presets · General · Keyboard · Wakeword · STT Engines · LLM Engines ·
  Benchmark — STT · Benchmark — Wakeword · Log · Manual · About);
  renamed section headers to match; added screenshot at each section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:22:34 +02:00
a9c08a54c8 fix: eliminate remaining horizontal scrollbars, split kw rows, fix WW benchmark layout (v2.03.39)
Scrollbar root causes:
- infobox max_width_chars 72→58 (72 chars × 9px/char > 677px content area)
- engine selector combos (stt/llm/ww) lacked CellRendererText ellipsize;
  long engine names drove combo natural width; add _ellipsize_combo() helper
  (ellipsize=END, max-width-chars=28) applied to _combo() and all 3 engine combos
- _STT_TYPES/_LLM_TYPES/_DEVICE_OPTIONS/_COMPUTE_OPTIONS labels shortened to ≤27 chars

UX:
- _kw_shortcut_row: split into two ListBox rows (keywords / shortcut)
- Benchmark WW: move Run button to top of results pane, paned 390→340px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:01:22 +02:00
125bd17713 fix: entry natural width causes scrollbars in STT Engines and Wakeword (v2.03.38)
Gtk.Entry reports its placeholder text width as natural width. Without
set_width_chars(1), entries can't shrink below that, so pages with long
placeholders (URL fields, keyword rows) ended up wider than the dialog.

Added set_width_chars(1) to _entry(), _url_field(), ModelPicker,
_kw_shortcut_row, and _sound_field. Also added set_max_width_chars(50)
to the stt_result wrapping label for the same reason.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 15:28:44 +02:00
0500b24900 fix: horizontal scrollbars and empty STT Engines page in settings (v2.03.37)
- Guard _refresh_status() with hasattr checks so opening STT Engines
  before LLM Engines page is built no longer crashes the builder silently
- ww_status label: add max_width_chars(30) + ellipsize END so long model
  lists don't widen the Wakeword page
- Benchmark STT sel_sw: NEVER→AUTOMATIC horizontal policy so wide engine
  names scroll internally instead of propagating to the dialog
- _combo()/_type_combo(): set_size_request(10,-1) so ComboBoxText widgets
  (e.g. long microphone names) can shrink below natural width

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 15:16:49 +02:00
8181ad75f9 feat(2.03.36): split STT Engines / LLM Engines into separate sidebar pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:54:19 +02:00
79d6020fc6 fix(2.03.35): settings pages no longer widen the dialog
_switch_row description labels had set_line_wrap(True) but no
set_max_width_chars, so GTK computed their natural width as the full
un-wrapped text (~700px for 87-char descriptions). With NEVER horizontal
policy on the page ScrolledWindow this propagated to the dialog, making
Keyboard/Wakeword/Engines/Benchmark pages 1000–1360px wide.

Fix: add set_max_width_chars(50) to description labels, and change the
page SW horizontal policy from NEVER to AUTOMATIC as a safety net.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:52:28 +02:00
32 changed files with 644 additions and 194 deletions

5
.gitignore vendored
View File

@ -33,3 +33,8 @@ node_modules/
# Agent workspace
jules/
# Raw screenshot source folders (published screenshots stay in Screenshots/)
Screenshots/Settings - new/
Screenshots/Settings-old/
Screenshots/Welcome Setup/

210
MANUAL.md
View File

@ -1,10 +1,11 @@
# Blitztext — User Manual
A reference for every setting in the Blitztext **Settings** window, tab by tab.
A reference for every setting in the Blitztext **Settings** window, page by page.
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.
sidebar lists all pages: **Presets · General · Keyboard · Wakeword · STT Engines ·
LLM Engines · Benchmark — STT · Benchmark — Wakeword · Log · Manual · About**.
Three buttons run along the top: **Save**, **Save & Restart**, and **✕ Close**.
> **Where settings are stored:** `~/.config/blitztext/config.toml`
> (or `$XDG_CONFIG_HOME/blitztext/config.toml`). You can edit that file directly;
@ -20,7 +21,11 @@ About** — and three buttons along the bottom.
---
## Presets tab
## Presets
<p align="center">
<a href="Screenshots/settings-presets.png"><img src="Screenshots/settings-presets.png" alt="Presets page" width="100%"></a>
</p>
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
@ -44,54 +49,35 @@ Use the dropdown at the top to pick a preset to edit, **+ Add** to create one, o
---
## Engines tab
## General
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.
<p align="center">
<a href="Screenshots/settings-general.png"><img src="Screenshots/settings-general.png" alt="General page" width="100%"></a>
</p>
### 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`.
Microphone, text delivery, language, notifications, the on-screen overlay, and
autostart.
| 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. |
| **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). |
| **Announce matched preset** | `notify_routing` | After a voice command, pop a notification showing which preset (and spoken keyword) matched — shown **even for hands-free** sessions, with the preset's emoji. Only fires on a real match. |
| **Visual overlay** | `overlay_enabled` | Show a translucent bubble at the cursor while you dictate — a pulsing **microphone**, a **live waveform** of your mic level, and the **recognised text** (word-by-word with a streaming engine, or the final result as a brief confirmation). The tail points at where the text lands, and it gives **hands-free** sessions visible feedback. Click-through; never takes focus. *(X11 only.)* |
| **Launch on login** | *(autostart file)* | Start Blitztext automatically when you log in (writes a desktop autostart entry, not `config.toml`). |
---
## Input tab
## Keyboard
Controls **how you start and stop** dictating, the noise filter, hands-free
wakeword, and audio cues.
<p align="center">
<a href="Screenshots/settings-keyboard.png"><img src="Screenshots/settings-keyboard.png" alt="Keyboard page" width="100%"></a>
</p>
Controls **how you start and stop** dictating with the keyboard, the noise filter, and audio cues.
### Input mode & keys
@ -120,7 +106,26 @@ in the `[quality]` section.
| **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)
### Audio cues (manual dictation)
These control the chimes for **manual** (keyboard/hotkey) dictation only. The
hands-free wakeword sounds are **separate and independent** (see Wakeword page).
| Setting | TOML key | Description |
|---|---|---|
| **Play audio cues** | `[sounds] enabled` | On/off for the **manual** start/stop chimes below. Does **not** affect the wakeword sounds. |
| **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).
---
## Wakeword
<p align="center">
<a href="Screenshots/settings-wakeword.png"><img src="Screenshots/settings-wakeword.png" alt="Wakeword page" width="100%"></a>
</p>
Start dictation with a spoken keyword via an external
[Wyoming](https://github.com/rhasspy/wyoming) openWakeWord server. Maps to the
@ -143,20 +148,7 @@ Start dictation with a spoken keyword via an external
> 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:**
> **The two sound pairs differ by trigger *and* by empty-behaviour:**
>
> | | Plays on | Used for | When empty |
> |---|---|---|---|
@ -165,28 +157,69 @@ hands-free wakeword sounds above are **separate and independent**.
---
## General tab
## STT Engines
Microphone, text delivery, language, notifications, the on-screen overlay, and
autostart.
<p align="center">
<a href="Screenshots/settings-stt-engines.png"><img src="Screenshots/settings-stt-engines.png" alt="STT Engines page" width="100%"></a>
</p>
The **speech-to-text** engine turns your voice into text. Each engine can run
locally or on a server. A **green dot** means it's reachable, **red** means
offline. The active engine is the one selected in the top dropdown.
Buttons: **+ Add** (batch/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 |
|---|---|---|
| **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). |
| **Announce matched preset** | `notify_routing` | After a voice command, pop a notification showing which preset (and spoken keyword) matched — shown **even for hands-free** sessions, with the preset's emoji. Only fires on a real match. |
| **Visual overlay** | `overlay_enabled` | Show a translucent bubble at the cursor while you dictate — a pulsing **microphone**, a **live waveform** of your mic level, and the **recognised text** (word-by-word with a streaming engine, or the final result as a brief confirmation). The tail points at where the text lands, and it gives **hands-free** sessions visible feedback. Click-through; never takes focus. *(X11 only.)* |
| **Launch on login** | *(autostart file)* | Start Blitztext automatically when you log in (writes a desktop autostart entry, not `config.toml`). |
| **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. |
---
## Benchmark tab
## LLM Engines
<p align="center">
<a href="Screenshots/settings-llm-engines.png"><img src="Screenshots/settings-llm-engines.png" alt="LLM Engines page" width="100%"></a>
</p>
The **language model** rewrites your dictated text (e.g. into a polished email).
Each engine can be a local LAN server or a cloud service. The active engine is
the one selected in the top dropdown.
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. |
---
## Benchmark — STT
<p align="center">
<a href="Screenshots/settings-benchmark-stt.png"><img src="Screenshots/settings-benchmark-stt.png" alt="Benchmark — STT page" width="100%"></a>
</p>
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
engine in the STT Engines page for each model you want to compare. No persistent
settings — it's a one-off tool.
1. **Audio (.wav)** — a recording to transcribe.
@ -213,7 +246,24 @@ A summary line names the **fastest** and **most accurate** engine. Click any col
---
## Log tab
## Benchmark — Wakeword
<p align="center">
<a href="Screenshots/settings-benchmark-wakeword.png"><img src="Screenshots/settings-benchmark-wakeword.png" alt="Benchmark — Wakeword page" width="100%"></a>
</p>
Stress-test your wakeword detection by generating speech with a TTS server and
checking whether the wake word fires correctly. Reports **recall** (how often it
fires when it should) and **false-fire rate** (how often it fires on non-wake
speech) across multiple synthetic voices.
---
## Log
<p align="center">
<a href="Screenshots/settings-log.png"><img src="Screenshots/settings-log.png" alt="Log page" width="100%"></a>
</p>
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).
@ -227,7 +277,21 @@ problem (recording, transcription, routing, and wakeword events all appear here)
---
## About tab
## Manual
<p align="center">
<a href="Screenshots/settings-manual.png"><img src="Screenshots/settings-manual.png" alt="Manual page" width="100%"></a>
</p>
Inline copy of this manual, readable without leaving the app.
---
## About
<p align="center">
<a href="Screenshots/settings-about.png"><img src="Screenshots/settings-about.png" alt="About page" width="100%"></a>
</p>
Read-only information:

135
README.md
View File

@ -8,9 +8,13 @@ Blitztext is a native Linux dictation tool that captures your voice, transcribes
> No hosted backend — bring your own models and endpoints.
<p align="center">
<img src="Screenshots/panel.png" alt="Blitztext control panel" width="380">
<img src="Screenshots/main-panel.png" alt="Blitztext control panel" width="360">
</p>
<p align="center">
<img src="Screenshots/overlay-listening.png" alt="On-screen overlay while listening" width="360">
&nbsp;&nbsp;
<img src="Screenshots/tray-menu.png" alt="Blitztext system-tray menu" width="300">
<img src="Screenshots/overlay-result.png" alt="On-screen overlay showing transcription result" width="360">
</p>
📖 **[User manual](MANUAL.md)** — every setting in every tab, explained.
@ -65,47 +69,124 @@ Stream: hotkey → mic PCM chunks → Riva/NIM WebSocket → live words typed
## Screenshots
Everything is configured in the GTK **Settings** window — every tab has tooltips
and screen-reader (ATK) support. Click any image to open it full size.
Everything is configured in the GTK **Settings** window — the sidebar gives quick
access to every page. All controls have tooltips and screen-reader (ATK) support.
Click any image to open it full size.
### Main panel & overlay
<p align="center">
<a href="Screenshots/settings-presets.png"><img src="Screenshots/settings-presets.png" alt="Presets settings tab" width="100%"></a><br>
<em><b>Presets</b> — your dictation actions. Each preset is either a plain transcription or an LLM rewrite, and carries its own spoken keyword(s) for voice routing, an optional global hotkey, and a custom rewrite prompt.</em>
<a href="Screenshots/main-panel.png"><img src="Screenshots/main-panel.png" alt="Blitztext main panel" width="46%"></a>
&nbsp;
<a href="Screenshots/overlay-listening.png"><img src="Screenshots/overlay-listening.png" alt="Overlay — listening" width="46%"></a>
</p>
<p align="center">
<em><b>Left:</b> Control panel listing all presets with icons, descriptions, and hotkeys.</em>
&nbsp;&nbsp;
<em><b>Right:</b> On-screen overlay showing the live waveform while listening.</em>
</p>
<p align="center">
<a href="Screenshots/settings-engines.png"><img src="Screenshots/settings-engines.png" alt="Engines settings tab" width="100%"></a><br>
<em><b>Engines</b> — your speech-to-text and language-model back-ends, local or remote. Add and rename engines, watch live online/offline status, and pick models from a searchable list fetched straight from the endpoint.</em>
<a href="Screenshots/overlay-result.png"><img src="Screenshots/overlay-result.png" alt="Overlay — transcription result" width="46%"></a>
</p>
<p align="center">
<em>Overlay after transcription — preset name and recognised text appear at the cursor.</em>
</p>
### Settings — General & Input
<p align="center">
<a href="Screenshots/settings-presets.png"><img src="Screenshots/settings-presets.png" alt="Presets page" width="48%"></a>
&nbsp;
<a href="Screenshots/settings-general.png"><img src="Screenshots/settings-general.png" alt="General page" width="48%"></a>
</p>
<p align="center">
<em><b>Presets</b> — dictation actions with keywords, hotkeys, LLM mode, and custom prompts.</em>
&nbsp;&nbsp;
<em><b>General</b> — microphone, output mode, language hint, notifications, overlay, autostart.</em>
</p>
<p align="center">
<a href="Screenshots/settings-input.png"><img src="Screenshots/settings-input.png" alt="Input settings tab" width="100%"></a><br>
<em><b>Input</b> — how you start and stop dictation: the modifier-key scheme (Ctrl+Win / Ctrl / Alt / Esc) or custom hotkeys, plus the silence-based auto-stop (VAD), the quality gate, and audio cues.</em>
<a href="Screenshots/settings-keyboard.png"><img src="Screenshots/settings-keyboard.png" alt="Keyboard page" width="48%"></a>
&nbsp;
<a href="Screenshots/settings-wakeword.png"><img src="Screenshots/settings-wakeword.png" alt="Wakeword page" width="48%"></a>
</p>
<p align="center">
<em><b>Keyboard</b> — modifier-key scheme or direct hotkeys, quality gate, audio cues.</em>
&nbsp;&nbsp;
<em><b>Wakeword</b> — hands-free dictation via a Wyoming/openWakeWord server, with live level meter and model picker.</em>
</p>
### Settings — Engines
<p align="center">
<a href="Screenshots/settings-stt-engines.png"><img src="Screenshots/settings-stt-engines.png" alt="STT Engines page" width="48%"></a>
&nbsp;
<a href="Screenshots/settings-llm-engines.png"><img src="Screenshots/settings-llm-engines.png" alt="LLM Engines page" width="48%"></a>
</p>
<p align="center">
<em><b>STT Engines</b> — speech-to-text back-ends (local faster-whisper, OpenAI-compatible server, or Riva realtime), with green/red status dot and Test button.</em>
&nbsp;&nbsp;
<em><b>LLM Engines</b> — language-model back-ends for text rewriting (LAN server or cloud service).</em>
</p>
### Settings — Benchmark
<p align="center">
<a href="Screenshots/settings-benchmark-stt.png"><img src="Screenshots/settings-benchmark-stt.png" alt="Benchmark — STT page" width="48%"></a>
&nbsp;
<a href="Screenshots/settings-benchmark-wakeword.png"><img src="Screenshots/settings-benchmark-wakeword.png" alt="Benchmark — Wakeword page" width="48%"></a>
</p>
<p align="center">
<em><b>Benchmark — STT</b> — compare every configured engine against a reference WAV + transcript; results table shows speed, accuracy, device, and language support.</em>
&nbsp;&nbsp;
<em><b>Benchmark — Wakeword</b> — stress-test wakeword detection using a TTS server to synthesise wake phrases in random voices, reporting recall and false-fire rates.</em>
</p>
### Settings — Log & About
<p align="center">
<a href="Screenshots/settings-log.png"><img src="Screenshots/settings-log.png" alt="Log page" width="48%"></a>
&nbsp;
<a href="Screenshots/settings-about.png"><img src="Screenshots/settings-about.png" alt="About page" width="48%"></a>
</p>
<p align="center">
<em><b>Log</b> — live activity log for recording, transcription, routing, and wakeword events.</em>
&nbsp;&nbsp;
<em><b>About</b> — version, source link, inline changelog, and licence.</em>
</p>
### Setup Wizard
The first-run wizard guides you through the essentials in a few steps.
<p align="center">
<a href="Screenshots/setup-welcome.png"><img src="Screenshots/setup-welcome.png" alt="Setup — Welcome" width="32%"></a>
&nbsp;
<a href="Screenshots/setup-trigger.png"><img src="Screenshots/setup-trigger.png" alt="Setup — Trigger mode" width="32%"></a>
&nbsp;
<a href="Screenshots/setup-shortcuts.png"><img src="Screenshots/setup-shortcuts.png" alt="Setup — Keyboard shortcuts" width="32%"></a>
</p>
<p align="center">
<em>Welcome &nbsp;·&nbsp; Choose trigger mode (keyboard / wakeword / both) &nbsp;·&nbsp; Set keyboard shortcuts</em>
</p>
<p align="center">
<a href="Screenshots/wakeword.png"><img src="Screenshots/wakeword.png" alt="Wakeword (hands-free) settings" width="100%"></a><br>
<em><b>Wakeword (hands-free)</b> — point Blitztext at a Wyoming/openWakeWord server, choose a wake model, and test the connection live so a spoken keyword starts dictation with no keys at all.</em>
<a href="Screenshots/setup-voice.png"><img src="Screenshots/setup-voice.png" alt="Setup — Voice activation" width="32%"></a>
&nbsp;
<a href="Screenshots/setup-stt.png"><img src="Screenshots/setup-stt.png" alt="Setup — STT engine" width="32%"></a>
&nbsp;
<a href="Screenshots/setup-ai.png"><img src="Screenshots/setup-ai.png" alt="Setup — AI rewriting" width="32%"></a>
</p>
<p align="center">
<em>Voice activation (wakeword server) &nbsp;·&nbsp; Choose STT engine &nbsp;·&nbsp; Optional AI text rewriting</em>
</p>
<p align="center">
<a href="Screenshots/settings-general.png"><img src="Screenshots/settings-general.png" alt="General settings tab" width="100%"></a><br>
<em><b>General</b> — core preferences: microphone with a live level meter, output mode (type vs. paste), language hint, type delay, the on-screen dictation overlay, and autostart on login.</em>
<a href="Screenshots/setup-done.png"><img src="Screenshots/setup-done.png" alt="Setup — All done" width="32%"></a>
</p>
<p align="center">
<a href="Screenshots/settings-benchmark.png"><img src="Screenshots/settings-benchmark.png" alt="Benchmark settings tab" width="100%"></a><br>
<em><b>Benchmark</b> — compare every configured STT engine against a reference WAV + transcript to find the fastest and most accurate, with a Device column (CPU / GPU / remote).</em>
</p>
<p align="center">
<a href="Screenshots/settings-log.png"><img src="Screenshots/settings-log.png" alt="Log settings tab" width="100%"></a><br>
<em><b>Log</b> — the in-app log buffer: a live view of recording, transcription, routing, and wakeword events for quick troubleshooting.</em>
</p>
<p align="center">
<a href="Screenshots/settings-about.png"><img src="Screenshots/settings-about.png" alt="About settings tab" width="100%"></a><br>
<em><b>About</b> — version, source link, changelog, and licence.</em>
<em>Summary screen — ready to dictate.</em>
</p>
---

BIN
Screenshots/main-panel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

BIN
Screenshots/setup-ai.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
Screenshots/setup-done.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
Screenshots/setup-stt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
Screenshots/setup-voice.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -9,6 +9,114 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py).
## [Unreleased]
## [2.03.41] - 2026-06-10
### Added
- **Cancel button shown during transcription and rewriting.** The `×` button in
the top-right corner of the overlay is now visible and clickable in the
`busy` state (Transcribing… / Rewriting…), not only while recording.
Clicking it during transcription discards the result once the STT call
returns. Clicking it during a rewrite breaks out of the LLM stream
immediately — the partial text is discarded and nothing is typed.
## [2.03.40] - 2026-06-10
### Changed
- **Documentation overhaul with screenshots.** Renamed 21 raw screenshots to
meaningful filenames (`main-panel.png`, `overlay-listening.png`,
`settings-presets.png`, `settings-general.png`, `settings-keyboard.png`,
`settings-wakeword.png`, `settings-stt-engines.png`,
`settings-llm-engines.png`, `settings-benchmark-stt.png`,
`settings-benchmark-wakeword.png`, `settings-log.png`,
`settings-manual.png`, `settings-about.png`, and 7 setup-wizard screens).
- **README.md / linux/README.md** updated with organized screenshot sections
(Main panel & overlay, Settings — General & Input, Settings — Engines,
Settings — Benchmark, Settings — Log & About, Setup wizard) using
click-to-enlarge image links.
- **MANUAL.md** rewritten to match the new sidebar navigation: intro updated;
section headers renamed (Engines tab → STT Engines / LLM Engines; Input tab
→ Keyboard + Wakeword; Benchmark tab → Benchmark — STT / Benchmark —
Wakeword); screenshot added at the top of every section including Manual and
About pages.
## [2.03.39] - 2026-06-10
### Fixed
- **Remaining horizontal scrollbars eliminated.** Root causes: (1) infobox
`max_width_chars` was 72 — at typical system font sizes (9 px/char) this
produced a natural width wider than the ~677 px content area; reduced to 58.
(2) Engine-selector combos (`stt_combo`, `llm_combo`, `ww_combo`) and the
`_combo()` helper had no constraint on CellRendererText width — long engine
names or ALSA device names drove the combo's natural width to 300500 px.
Fixed by adding `_ellipsize_combo()` (sets `ellipsize=END` and
`max-width-chars=28` on the internal CellRendererText) to all combos.
(3) `_STT_TYPES`, `_LLM_TYPES`, `_DEVICE_OPTIONS`, `_COMPUTE_OPTIONS`
labels were 4052 characters; shortened to ≤27 chars.
### Changed
- **Wakeword Cancel/Send word rows split into two rows.** Keywords and
keyboard shortcut are now on separate lines inside the card, avoiding the
cramped single-row layout.
- **Benchmark — Wakeword: "Run wakeword benchmark" button moved** from the
bottom of the settings pane to the top of the results pane; pane divider
adjusted from 390 → 340 px. The button is now always visible without
scrolling and sits logically above the results it produces.
## [2.03.38] - 2026-06-10
### Fixed
- **STT Engines and Wakeword no longer have horizontal scrollbars.** Root
cause: `Gtk.Entry` widgets compute natural width from placeholder text
(e.g. `"http://localhost:8010/v1 · realtime: http://localhost:8006/v1"`
≈ 500 px). Without `set_width_chars(1)` the entry cannot shrink below its
natural width even when placed in an expanding container. Added
`set_width_chars(1)` to all entry-creating helpers: `_entry()`, `_url_field()`,
`ModelPicker`, `_kw_shortcut_row`, and `_sound_field`. Also added
`set_max_width_chars(50)` to the `stt_result` wrapping label.
## [2.03.37] - 2026-06-10
### Fixed
- **STT Engines page no longer appears empty.** `_refresh_status()` called
`_stt_commit()` / `_llm_commit()` and accessed `stt_dot` / `llm_dot`
unconditionally; if the STT page was opened before the LLM page was built
(lazy), the builder crashed silently with `AttributeError`. Added `hasattr`
guards so each section is only committed / updated when its widgets exist.
- **Wakeword page no longer causes horizontal scrollbar.** The `ww_status`
label (showing model list like "7 models loaded: okay_nabu, hey_jarvis…")
had no width limit and expanded the page. Added `set_max_width_chars(30)`
and `set_ellipsize(END)`.
- **Benchmark STT engine list no longer causes horizontal scrollbar.**
`sel_sw` used `NEVER` horizontal policy, propagating long engine-name labels
(~800 px) up through the paned. Changed to `AUTOMATIC` so content scrolls
internally.
- **General page and LLM Engines no longer cause horizontal scrollbar.**
`_combo()` and `_type_combo()` lacked `set_size_request(10, -1)`, so
ComboBoxText widgets (e.g. long microphone device names) could not shrink
below their natural width. Added the size request to both helpers.
## [2.03.36] - 2026-06-10
### Changed
- **STT Engines split from LLM Engines.** The single "STT Engines" sidebar
page is now two pages — "STT Engines" (speech-to-text configuration) and
"LLM Engines" (language model / rewrite configuration) — each with its
own infobox. The underlying `_stt_section()` and `_llm_section()` methods
are unchanged.
## [2.03.35] - 2026-06-10
### Fixed
- **Settings pages no longer widen the dialog.** `_switch_row` description
labels had `set_line_wrap(True)` but no `set_max_width_chars`, so GTK
computed their natural width as the full un-wrapped text (87 chars × ~8 px
= ~700 px). With `NEVER` horizontal policy on the page `ScrolledWindow`,
that propagated directly to the dialog width, making Keyboard, Wakeword,
STT Engines, and Benchmark pages ~10001360 px wide. Fixed by adding
`set_max_width_chars(50)` (≈ 375 px) to description labels, and changed the
page `ScrolledWindow` horizontal policy from `NEVER` to `AUTOMATIC` as a
safety net for any other wide widget.
## [2.03.34] - 2026-06-10
### Fixed

View File

@ -13,9 +13,13 @@ experiment along those lines was moved out to
the optional rewrite step calls out to an LLM.
<p align="center">
<img src="../Screenshots/panel.png" alt="Blitztext control panel" width="380">
<img src="../Screenshots/main-panel.png" alt="Blitztext control panel" width="360">
</p>
<p align="center">
<img src="../Screenshots/overlay-listening.png" alt="On-screen overlay while listening" width="360">
&nbsp;&nbsp;
<img src="../Screenshots/tray-menu.png" alt="Blitztext system-tray menu" width="300">
<img src="../Screenshots/overlay-result.png" alt="On-screen overlay showing transcription result" width="360">
</p>
## Inspiration
@ -61,47 +65,90 @@ visible feedback. Tune the anchor with `[general] overlay_anchor`. X11 only.
## Screenshots
Everything is configured in the **Settings** window — every tab has tooltips and
screen-reader (ATK) support. Click any image to open it full size.
Everything is configured in the **Settings** window — the sidebar gives quick
access to every page. Click any image to open it full size.
### Main panel & overlay
<p align="center">
<a href="../Screenshots/settings-presets.png"><img src="../Screenshots/settings-presets.png" alt="Presets settings tab" width="100%"></a><br>
<em><b>Presets</b> — your dictation actions. Each preset is either a plain transcription or an LLM rewrite, and carries its own spoken keyword(s) for voice routing, an optional global hotkey, and a custom rewrite prompt.</em>
<a href="../Screenshots/main-panel.png"><img src="../Screenshots/main-panel.png" alt="Blitztext main panel" width="46%"></a>
&nbsp;
<a href="../Screenshots/overlay-listening.png"><img src="../Screenshots/overlay-listening.png" alt="Overlay — listening" width="46%"></a>
</p>
<p align="center">
<em><b>Left:</b> Control panel listing all presets with icons, descriptions, and hotkeys.</em>
&nbsp;&nbsp;
<em><b>Right:</b> On-screen overlay showing the live waveform while listening.</em>
</p>
<p align="center">
<a href="../Screenshots/settings-engines.png"><img src="../Screenshots/settings-engines.png" alt="Engines settings tab" width="100%"></a><br>
<em><b>Engines</b> — your speech-to-text and language-model back-ends, local or remote. Add and rename engines, watch live online/offline status, and pick models from a searchable list fetched straight from the endpoint.</em>
<a href="../Screenshots/overlay-result.png"><img src="../Screenshots/overlay-result.png" alt="Overlay — transcription result" width="46%"></a>
</p>
<p align="center">
<em>Overlay after transcription — preset name and recognised text appear at the cursor.</em>
</p>
### Settings — General & Input
<p align="center">
<a href="../Screenshots/settings-presets.png"><img src="../Screenshots/settings-presets.png" alt="Presets page" width="48%"></a>
&nbsp;
<a href="../Screenshots/settings-general.png"><img src="../Screenshots/settings-general.png" alt="General page" width="48%"></a>
</p>
<p align="center">
<em><b>Presets</b> — dictation actions with keywords, hotkeys, LLM mode, and custom prompts.</em>
&nbsp;&nbsp;
<em><b>General</b> — microphone, output mode, language hint, notifications, overlay, autostart.</em>
</p>
<p align="center">
<a href="../Screenshots/settings-input.png"><img src="../Screenshots/settings-input.png" alt="Input settings tab" width="100%"></a><br>
<em><b>Input</b> — how you start and stop dictation: the modifier-key scheme (Ctrl+Win / Ctrl / Alt / Esc) or custom hotkeys, plus the silence-based auto-stop (VAD), the quality gate, and audio cues.</em>
<a href="../Screenshots/settings-keyboard.png"><img src="../Screenshots/settings-keyboard.png" alt="Keyboard page" width="48%"></a>
&nbsp;
<a href="../Screenshots/settings-wakeword.png"><img src="../Screenshots/settings-wakeword.png" alt="Wakeword page" width="48%"></a>
</p>
<p align="center">
<em><b>Keyboard</b> — modifier-key scheme or direct hotkeys, quality gate, audio cues.</em>
&nbsp;&nbsp;
<em><b>Wakeword</b> — hands-free dictation via a Wyoming/openWakeWord server, with live level meter and model picker.</em>
</p>
<p align="center">
<a href="../Screenshots/wakeword.png"><img src="../Screenshots/wakeword.png" alt="Wakeword (hands-free) settings" width="100%"></a><br>
<em><b>Wakeword (hands-free)</b> — point Blitztext at a Wyoming/openWakeWord server, choose a wake model, and test the connection live so a spoken keyword starts dictation with no keys at all.</em>
</p>
### Settings — Engines
<p align="center">
<a href="../Screenshots/settings-general.png"><img src="../Screenshots/settings-general.png" alt="General settings tab" width="100%"></a><br>
<em><b>General</b> — core preferences: microphone with a live level meter, output mode (type vs. paste), language hint, type delay, the on-screen dictation overlay, and autostart on login.</em>
<a href="../Screenshots/settings-stt-engines.png"><img src="../Screenshots/settings-stt-engines.png" alt="STT Engines page" width="48%"></a>
&nbsp;
<a href="../Screenshots/settings-llm-engines.png"><img src="../Screenshots/settings-llm-engines.png" alt="LLM Engines page" width="48%"></a>
</p>
<p align="center">
<em><b>STT Engines</b> — speech-to-text back-ends (local faster-whisper, OpenAI-compatible server, or Riva realtime), with green/red status dot and Test button.</em>
&nbsp;&nbsp;
<em><b>LLM Engines</b> — language-model back-ends for text rewriting (LAN server or cloud service).</em>
</p>
<p align="center">
<a href="../Screenshots/settings-benchmark.png"><img src="../Screenshots/settings-benchmark.png" alt="Benchmark settings tab" width="100%"></a><br>
<em><b>Benchmark</b> — compare every configured STT engine against a reference WAV + transcript. The results table shows <b>time</b>, <b>accuracy</b> (WER), <b>device</b> (CPU / GPU / remote), <b>language support</b>, and <b>RAM usage</b> (MB loaded during first run — useful for sizing local models). Also includes a <b>wakeword benchmark</b>: point it at any OpenAI-compatible TTS server (Kokoro, XTTS, …), and it synthesizes your wake phrase in random voices, streams it to your wyoming-openwakeword server, and reports recall + false-fires per voice.</em>
</p>
### Settings — Benchmark
<p align="center">
<a href="../Screenshots/settings-log.png"><img src="../Screenshots/settings-log.png" alt="Log settings tab" width="100%"></a><br>
<em><b>Log</b> — the in-app log buffer: a live view of recording, transcription, routing, and wakeword events for quick troubleshooting.</em>
<a href="../Screenshots/settings-benchmark-stt.png"><img src="../Screenshots/settings-benchmark-stt.png" alt="Benchmark — STT page" width="48%"></a>
&nbsp;
<a href="../Screenshots/settings-benchmark-wakeword.png"><img src="../Screenshots/settings-benchmark-wakeword.png" alt="Benchmark — Wakeword page" width="48%"></a>
</p>
<p align="center">
<em><b>Benchmark — STT</b> — compare engines against a reference WAV + transcript; table shows speed, accuracy, device, and language support.</em>
&nbsp;&nbsp;
<em><b>Benchmark — Wakeword</b> — stress-test wakeword detection via a TTS server, reporting recall and false-fire rates per voice.</em>
</p>
### Settings — Log & About
<p align="center">
<a href="../Screenshots/settings-about.png"><img src="../Screenshots/settings-about.png" alt="About settings tab" width="100%"></a><br>
<em><b>About</b> — version, source link, changelog, and licence.</em>
<a href="../Screenshots/settings-log.png"><img src="../Screenshots/settings-log.png" alt="Log page" width="48%"></a>
&nbsp;
<a href="../Screenshots/settings-about.png"><img src="../Screenshots/settings-about.png" alt="About page" width="48%"></a>
</p>
<p align="center">
<em><b>Log</b> — live activity log for recording, transcription, routing, and wakeword events.</em>
&nbsp;&nbsp;
<em><b>About</b> — version, source link, inline changelog, and licence.</em>
</p>
## Requirements

View File

@ -6,4 +6,4 @@ counterpart to the macOS Blitztext menu bar app: it runs natively on the host
(not in a container) so it can type into any application via xdotool.
"""
__version__ = "2.03.34"
__version__ = "2.03.41"

View File

@ -163,6 +163,7 @@ class Daemon:
self._active_workflow: Workflow | None = None
self._target_window: str | None = None
self._busy = False
self._abort_event = threading.Event()
self._prepared = False
self._listener = None
# Synthetic preset used by the voice-routing hotkey.
@ -520,13 +521,19 @@ class Daemon:
self._active_workflow = None
self._stream_segment_text = ""
rec = None
else:
busy = False
elif self._recording is not None:
streamer = None
if self._recording is None:
return
rec = self._recording
self._recording = None
self._active_workflow = None
busy = False
elif self._busy:
streamer = None
rec = None
busy = True
else:
return
if streamer is not None:
streamer.stop()
self._ov_meter_stop()
@ -534,6 +541,11 @@ class Daemon:
self._notify("Cancelled", "Streaming stopped.", "low")
self._play_sound("device-removed")
return
if busy:
self._abort_event.set()
self._emit("idle", None, "Cancelled")
self._play_sound("device-removed")
return
rec.discard()
self._emit("idle", None, "Cancelled")
self._notify("Cancelled", "Recording discarded.", "low")
@ -608,6 +620,7 @@ class Daemon:
# -- worker ---------------------------------------------------------------
def _process(self, audio_path, workflow: Workflow, window_id, send_enter: bool = False) -> None:
self._abort_event.clear()
label = workflow.name
try:
# Quality gate: drop silent / too-short clips before we even transcribe.
@ -630,6 +643,9 @@ class Daemon:
local_transcriber=self.transcriber,
timeout=self.cfg.timeout,
)
if self._abort_event.is_set():
log(f"{label}: cancelled during transcription.")
return
text = quality.clean(text, strip_trailing_punctuation=self.cfg.strip_trailing_punctuation)
text = quality.expand_spoken_punctuation(text)
@ -747,6 +763,7 @@ class Daemon:
temperature=target.temperature,
timeout=self.cfg.timeout,
on_token=on_token,
abort_event=self._abort_event,
)
except LLMError as exc:
self._emit("error", label, str(exc))
@ -763,6 +780,10 @@ class Daemon:
self.text_cb(f"{msg}")
return
if self._abort_event.is_set():
log(f"{label}: cancelled during rewrite.")
return
# Sanity-check: reject responses that are >80 % whitespace —
# a model that's cold-starting or misconfigured sometimes streams
# spaces or blank lines instead of real output.

View File

@ -40,24 +40,24 @@ GREEN, RED, GREY = "#34c759", "#ff3b30", "#b8b8be"
# (stored_value, display_label) pairs used by _type_combo / _type_key
_STT_TYPES: list[tuple[str, str]] = [
("local", "Internal — faster-whisper, runs inside the app"),
("openai", "Server — OpenAI-compatible API (LAN or cloud)"),
("riva_realtime", "Realtime — NVIDIA Riva / NIM streaming"),
("local", "Internal (faster-whisper)"),
("openai", "Server (OpenAI-compatible)"),
("riva_realtime", "Realtime (NVIDIA Riva)"),
]
_LLM_TYPES: list[tuple[str, str]] = [
("local", "LAN server — runs on your machine or local network"),
("cloud", "Cloud service — OpenAI, Groq, OpenRouter, …"),
("local", "LAN server"),
("cloud", "Cloud service"),
]
_DEVICE_OPTIONS: list[tuple[str, str]] = [
("auto", "Auto (try GPU / CUDA first, fall back to CPU)"),
("auto", "Auto (GPU → CPU)"),
("cpu", "CPU"),
("cuda", "GPU (CUDA)"),
("cuda", "GPU (CUDA)"),
]
_COMPUTE_OPTIONS: list[tuple[str, str]] = [
("auto", "Auto"),
("int8", "int8 — fast, less memory"),
("float16", "float16 — accurate, needs more VRAM"),
("int8_float16", "int8_float16 — balanced"),
("int8", "int8"),
("float16", "float16"),
("int8_float16", "int8_float16"),
]
# (name, url, api_key_env, type_key, default_model) — quickstart templates
@ -267,6 +267,7 @@ def _switch_row(parent, label: str, switch: Gtk.Switch, description: str = "",
desc = Gtk.Label(label=description, xalign=0.0)
desc.set_line_wrap(True)
desc.set_max_width_chars(50) # bound natural width so NEVER-policy SW doesn't grow the dialog
desc.get_style_context().add_class("dim-label")
row.pack_start(desc, True, True, 0)
@ -305,7 +306,7 @@ def _infobox(parent: Gtk.Box, text: str) -> Gtk.Box:
icon.set_margin_start(6); icon.set_margin_end(2)
box.pack_start(icon, False, False, 0)
lbl = Gtk.Label(label=text, xalign=0.0)
lbl.set_line_wrap(True); lbl.set_xalign(0.0); lbl.set_max_width_chars(72)
lbl.set_line_wrap(True); lbl.set_xalign(0.0); lbl.set_max_width_chars(58)
lbl.set_margin_top(8); lbl.set_margin_bottom(8); lbl.set_margin_end(8)
box.pack_start(lbl, True, True, 0)
acc = box.get_accessible()
@ -357,6 +358,7 @@ def _lb_add(lb: Gtk.ListBox, widget: Gtk.Widget) -> None:
def _entry(text="", placeholder="") -> Gtk.Entry:
e = Gtk.Entry(); e.set_text(str(text)); e.set_hexpand(True)
e.set_width_chars(1)
if placeholder:
e.set_placeholder_text(placeholder)
return e
@ -368,6 +370,13 @@ def _block_scroll(combo: Gtk.ComboBoxText) -> Gtk.ComboBoxText:
return combo
def _ellipsize_combo(combo: Gtk.ComboBoxText, max_chars: int = 28) -> None:
"""Limit a ComboBoxText's natural width so long item labels don't widen the dialog."""
for r in combo.get_cells():
r.set_property("ellipsize", Pango.EllipsizeMode.END)
r.set_property("max-width-chars", max_chars)
def _combo(options, active=None) -> Gtk.ComboBoxText:
c = _block_scroll(Gtk.ComboBoxText())
for o in options:
@ -376,6 +385,8 @@ def _combo(options, active=None) -> Gtk.ComboBoxText:
c.set_active(options.index(active))
elif options:
c.set_active(0)
c.set_size_request(10, -1)
_ellipsize_combo(c)
return c
@ -406,6 +417,7 @@ def _type_combo(types: list[tuple[str, str]], stored: str = "") -> Gtk.ComboBoxT
c.append_text(label)
idx = next((i for i, (k, _) in enumerate(types) if k == stored), 0)
c.set_active(idx)
c.set_size_request(10, -1)
return c
@ -421,7 +433,7 @@ class ModelPicker(Gtk.Box):
def __init__(self, placeholder: str = ""):
super().__init__(spacing=4)
self.entry = Gtk.Entry(); self.entry.set_hexpand(True)
self.entry = Gtk.Entry(); self.entry.set_hexpand(True); self.entry.set_width_chars(1)
if placeholder:
self.entry.set_placeholder_text(placeholder)
self.pack_start(self.entry, True, True, 0)
@ -507,10 +519,10 @@ def _url_field(parent: Gtk.Box, label: str, placeholder: str, on_reload,
row.pack_start(lbl, False, False, 0)
if dot is not None:
row.pack_start(dot, False, False, 0) # connection dot beside the field (left), like Engines
e = Gtk.Entry(); e.set_hexpand(True)
e = Gtk.Entry(); e.set_hexpand(True); e.set_width_chars(1)
if placeholder:
e.set_placeholder_text(placeholder)
lbl.set_mnemonic_widget(e)
atk = e.get_accessible()
if atk and label:
@ -882,7 +894,7 @@ class SettingsDialog:
def _stack_page(title: str) -> Gtk.Box:
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
sw = Gtk.ScrolledWindow()
sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.ALWAYS)
sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.ALWAYS)
sw.set_overlay_scrolling(False)
outer.pack_start(sw, True, True, 0)
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
@ -911,7 +923,8 @@ class SettingsDialog:
_reg("General", "General", "preferences-system-symbolic", self._build_general)
_reg("Input", "Keyboard", "input-keyboard-symbolic", self._build_keyboard)
_reg(None, "Wakeword", "audio-input-microphone-symbolic", self._build_wakeword)
_reg("Engines", "STT Engines", "network-server-symbolic", self._build_engines)
_reg("Engines", "STT Engines", "network-server-symbolic", self._build_stt_engines)
_reg(None, "LLM Engines", "applications-science-symbolic", self._build_llm_engines)
_reg("Benchmark", "Benchmark — STT", "utilities-system-monitor-symbolic", self._build_benchmark_stt)
_reg(None, "Benchmark — Wakeword", "audio-input-microphone-symbolic", self._build_benchmark_ww)
_reg("System", "Log", "text-x-generic-symbolic", self._build_log)
@ -1258,14 +1271,20 @@ class SettingsDialog:
pop.show_all()
search_sw.set_visible(False) # hide search results until user types
# ===== Engines ==========================================================
def _build_engines(self, page: Gtk.Box) -> None:
_infobox(page, "Engines do the work. The speech-to-text engine turns your voice into "
"text; the language model rewrites it. Each can run locally or on "
"a server you enter. A green dot means it is reachable, red means offline.")
_section_title(page, "Speech-to-text engine", margin_top=4, icon="audio-input-microphone-symbolic")
# ===== STT Engines ======================================================
def _build_stt_engines(self, page: Gtk.Box) -> None:
_infobox(page, "The speech-to-text engine transcribes your voice. It can run locally "
"(faster-whisper, built-in) or connect to a remote OpenAI-compatible API "
"or NVIDIA Riva stream. A green dot means the server is reachable.")
page.pack_start(self._stt_section(), False, False, 0)
_section_title(page, "Language model (rewrite)", icon="applications-science-symbolic")
self._refresh_status()
# ===== LLM Engines ======================================================
def _build_llm_engines(self, page: Gtk.Box) -> None:
_infobox(page, "The language model rewrites your transcribed text — for example into a "
"polished email or a code comment. It is optional: leave it disabled if "
"you just want plain transcription. Connect to any OpenAI-compatible LAN "
"server or cloud service.")
page.pack_start(self._llm_section(), False, False, 0)
self._refresh_status()
@ -1276,6 +1295,8 @@ class SettingsDialog:
# ── Selector bar ──────────────────────────────────────────────────────
bar = Gtk.Box(spacing=6)
self.stt_combo = _block_scroll(Gtk.ComboBoxText())
self.stt_combo.set_size_request(10, -1)
_ellipsize_combo(self.stt_combo)
for e in self.cfg.stt_engines:
self.stt_combo.append_text(e.name)
self.stt_combo.set_active(self._index_of(self.cfg.stt_engines, self.cfg.stt_active))
@ -1346,6 +1367,7 @@ class SettingsDialog:
test_row.pack_start(stt_test_btn, False, False, 0)
self.stt_result = Gtk.Label(xalign=0.0)
self.stt_result.set_line_wrap(True)
self.stt_result.set_max_width_chars(50)
self.stt_result.set_selectable(True)
self.stt_result.set_valign(Gtk.Align.START)
test_row.pack_start(self.stt_result, True, True, 0)
@ -1366,6 +1388,8 @@ class SettingsDialog:
# ── Selector bar ──────────────────────────────────────────────────────
bar = Gtk.Box(spacing=6)
self.llm_combo = _block_scroll(Gtk.ComboBoxText())
self.llm_combo.set_size_request(10, -1)
_ellipsize_combo(self.llm_combo)
for e in self.cfg.llm_engines:
self.llm_combo.append_text(e.name)
self.llm_combo.set_active(self._index_of(self.cfg.llm_engines, self.cfg.llm_active))
@ -1667,15 +1691,19 @@ class SettingsDialog:
# -- status dots (threaded) ---
def _refresh_status(self) -> None:
s = self.cfg.stt_engines[self._stt_idx] if 0 <= self._stt_idx < len(self.cfg.stt_engines) else None
self._stt_commit()
if hasattr(self, "stt_combo"):
self._stt_commit()
l = self.cfg.llm_engines[self._llm_idx] if 0 <= self._llm_idx < len(self.cfg.llm_engines) else None
self._llm_commit()
if hasattr(self, "llm_combo"):
self._llm_commit()
def check():
sc = GREEN if (s and stt.status(s)) else RED if s else GREY
lc = GREEN if (l and llm.status(l)) else RED if l else GREY
GLib.idle_add(self.stt_dot.set_markup, _dot(sc))
GLib.idle_add(self.llm_dot.set_markup, _dot(lc))
if hasattr(self, "stt_dot"):
GLib.idle_add(self.stt_dot.set_markup, _dot(sc))
if hasattr(self, "llm_dot"):
GLib.idle_add(self.llm_dot.set_markup, _dot(lc))
threading.Thread(target=check, daemon=True).start()
def _probe_dot(self, dot: Gtk.Label, uri: str, fallback_port: int) -> None:
@ -1716,46 +1744,49 @@ class SettingsDialog:
def _kw_shortcut_row(self, lb, label: str, kw_value: str, kw_placeholder: str,
key_value: str, *, tooltip_kw: str = "", tooltip_key: str = "",
width: int = 150):
"""Combined row: [label][keywords entry] | [shortcut label][shortcut entry][Set]."""
row_box = Gtk.Box(spacing=8)
row_box.set_margin_top(6); row_box.set_margin_bottom(6)
row_box.set_margin_start(12); row_box.set_margin_end(8)
"""Two rows: keywords on top, shortcut underneath."""
# Row 1 — keywords
row1 = Gtk.Box(spacing=8)
row1.set_margin_top(6); row1.set_margin_bottom(2)
row1.set_margin_start(12); row1.set_margin_end(8)
lbl = Gtk.Label(label=label, xalign=0.0)
lbl.set_size_request(width, -1)
row_box.pack_start(lbl, False, False, 0)
if tooltip_kw:
lbl.set_tooltip_text(tooltip_kw)
row1.pack_start(lbl, False, False, 0)
kw_entry = Gtk.Entry()
kw_entry.set_text(kw_value)
kw_entry.set_placeholder_text(kw_placeholder)
kw_entry.set_hexpand(True)
kw_entry.set_width_chars(1)
if tooltip_kw:
kw_entry.set_tooltip_text(tooltip_kw)
lbl.set_tooltip_text(tooltip_kw)
row_box.pack_start(kw_entry, True, True, 0)
sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)
sep.set_margin_start(4); sep.set_margin_end(4)
row_box.pack_start(sep, False, False, 0)
row1.pack_start(kw_entry, True, True, 0)
_lb_add(lb, row1)
# Row 2 — shortcut (indented under the label to align with the entry above)
row2 = Gtk.Box(spacing=8)
row2.set_margin_top(2); row2.set_margin_bottom(6)
row2.set_margin_start(12); row2.set_margin_end(8)
indent = Gtk.Box(); indent.set_size_request(width, -1)
row2.pack_start(indent, False, False, 0)
key_lbl = Gtk.Label(label="Shortcut", xalign=0.0)
key_lbl.get_style_context().add_class("dim-label")
row_box.pack_start(key_lbl, False, False, 0)
if tooltip_key:
key_lbl.set_tooltip_text(tooltip_key)
row2.pack_start(key_lbl, False, False, 0)
key_entry = Gtk.Entry()
key_entry.set_text(key_value)
key_entry.set_placeholder_text("<esc>")
key_entry.set_size_request(100, -1)
key_entry.set_size_request(150, -1)
if tooltip_key:
key_entry.set_tooltip_text(tooltip_key)
key_lbl.set_tooltip_text(tooltip_key)
row_box.pack_start(key_entry, False, False, 0)
row2.pack_start(key_entry, False, False, 0)
set_btn = Gtk.Button(label="Set")
set_btn.connect("clicked", lambda _b, e=key_entry: self._bind_key(e))
row_box.pack_start(set_btn, False, False, 0)
row2.pack_start(set_btn, False, False, 0)
_lb_add(lb, row2)
_lb_add(lb, row_box)
return kw_entry, key_entry
def _key_field(self, page: Gtk.Box, label: str, value: str, placeholder: str = "", width: int = 150) -> Gtk.Entry:
@ -1894,7 +1925,10 @@ class SettingsDialog:
# ── Engine selector bar ───────────────────────────────────────────────
ww_bar = Gtk.Box(spacing=6); ww_bar.set_margin_top(6)
self.ww_combo = _block_scroll(Gtk.ComboBoxText()); self.ww_combo.set_hexpand(True)
self.ww_combo = _block_scroll(Gtk.ComboBoxText())
self.ww_combo.set_hexpand(True)
self.ww_combo.set_size_request(10, -1)
_ellipsize_combo(self.ww_combo)
for e in self.cfg.wakeword_engines:
self.ww_combo.append_text(e.name)
active_idx = next((i for i, e in enumerate(self.cfg.wakeword_engines)
@ -1913,6 +1947,8 @@ class SettingsDialog:
b = Gtk.Button(label=label); b.set_tooltip_text(tip); b.connect("clicked", cb)
ww_bar.pack_end(b, False, False, 0)
self.ww_status = Gtk.Label(xalign=0.0)
self.ww_status.set_max_width_chars(30)
self.ww_status.set_ellipsize(Pango.EllipsizeMode.END)
self.ww_status.get_style_context().add_class("dim-label")
ww_bar.pack_end(self.ww_status, False, False, 4)
page.pack_start(ww_bar, False, False, 2)
@ -1961,6 +1997,7 @@ class SettingsDialog:
path_entry = Gtk.Entry()
path_entry.set_hexpand(True)
path_entry.set_width_chars(1)
path_entry.set_editable(False)
path_entry.set_placeholder_text(empty_note)
if value:
@ -2352,7 +2389,7 @@ class SettingsDialog:
page.pack_start(sel_hdr, False, False, 0)
sel_sw = Gtk.ScrolledWindow()
sel_sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
sel_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
sel_sw.set_min_content_height(80)
sel_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
sel_list.set_margin_start(4); sel_list.set_margin_end(4)
@ -2580,10 +2617,6 @@ class SettingsDialog:
tooltip="How many wakeword utterances to synthesize and test (filler-only "
"utterances for false-fire checking are added on top).")
wrun = Gtk.Button(label="Run wakeword benchmark"); wrun.connect("clicked", self._run_wakeword_bench)
wrun.set_halign(Gtk.Align.START)
ctrl.pack_start(wrun, False, False, 6)
# ---- Results pane (bottom half of the Paned) ----
# col 8 = foreground colour (not displayed)
self.wwb_store = Gtk.ListStore(str, str, str, str, str, str, str, str, str)
@ -2638,7 +2671,11 @@ class SettingsDialog:
tb.pack_start(_copy_btn, False, False, 0)
tb.pack_start(_save_btn, False, False, 0)
wrun = Gtk.Button(label="Run wakeword benchmark"); wrun.connect("clicked", self._run_wakeword_bench)
wrun.set_halign(Gtk.Align.START)
results_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
results_box.pack_start(wrun, False, False, 4)
results_box.pack_start(tb, False, False, 0)
results_box.pack_start(ww_sw, True, True, 0)
results_box.pack_start(self.wwb_summary, False, False, 0)
@ -2656,7 +2693,7 @@ class SettingsDialog:
ww_paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
ww_paned.pack1(ctrl_sw, resize=True, shrink=True)
ww_paned.pack2(results_box, resize=True, shrink=False)
ww_paned.set_position(390)
ww_paned.set_position(340)
page.pack_start(ww_paned, True, True, 4)
def _run_bench(self, _b) -> None:

View File

@ -50,6 +50,7 @@ def chat(
temperature: float | None = None,
timeout: int = 45,
on_token: Callable[[str], None] | None = None,
abort_event=None,
) -> str:
"""Run a chat completion and return the full text.
@ -82,7 +83,7 @@ def chat(
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
if stream:
content = _read_stream(resp, on_token)
content = _read_stream(resp, on_token, abort_event)
else:
body = json.loads(resp.read().decode("utf-8"))
content = body["choices"][0]["message"]["content"]
@ -107,12 +108,14 @@ def chat(
return content
def _read_stream(resp, on_token: Callable[[str], None]) -> str:
def _read_stream(resp, on_token: Callable[[str], None], abort_event=None) -> str:
"""Parse an OpenAI-style SSE stream, returning the accumulated content and
feeding each delta to ``on_token``. Tolerant of keep-alive blanks and the
trailing ``[DONE]`` sentinel."""
parts: list[str] = []
for raw in resp:
if abort_event and abort_event.is_set():
break
line = raw.decode("utf-8", "replace").strip()
if not line or not line.startswith("data:"):
continue

View File

@ -129,7 +129,7 @@ class Overlay:
gdkwin = self._win.get_window()
if gdkwin is None:
return
if self._on_cancel_cb and self._state in ("recording", "streaming"):
if self._on_cancel_cb and self._state in ("recording", "streaming", "busy"):
x, y, w, h = self._cancel_btn_rect
r = cairo.Region(cairo.RectangleInt(int(x), int(y), int(w), int(h)))
else:
@ -403,14 +403,14 @@ class Overlay:
wf_w = w - _PAD - wf_x
self._draw_wave(cr, wf_x, body_top + _PAD, wf_w, _HEADER_H)
# × cancel button (top-right corner, recording/streaming only).
if self._on_cancel_cb and self._state in ("recording", "streaming"):
# × cancel button (top-right corner, recording/streaming/busy).
if self._on_cancel_cb and self._state in ("recording", "streaming", "busy"):
self._draw_cancel_btn(cr, body_top)
# Phase label by the waveform — only when there's no preset banner and no text.
# Shift left to leave room for the × button.
label_right = (w - _PAD - _CANCEL_BTN_R * 2 - 6
if self._on_cancel_cb and self._state in ("recording", "streaming")
if self._on_cancel_cb and self._state in ("recording", "streaming", "busy")
else w - _PAD)
if self._phase_label and not self._text and not self._preset_name:
self._draw_label(cr, label_right, body_top + _PAD + 12, self._phase_label)

94
linux/blitztext/sound.py Normal file → Executable file
View File

@ -1,10 +1,16 @@
"""Play short audio cues (a user WAV, or a built-in system sound) without blocking."""
"""Play short audio cues (a user WAV, or a built-in system sound) without blocking.
Path safety: all user-configured sound file paths are validated before use
to prevent path-traversal, device-file access, and symlink attacks.
"""
from __future__ import annotations
import os
import shutil
import stat
import subprocess
from pathlib import Path
_FREEDESKTOP = "/usr/share/sounds/freedesktop/stereo/{name}.oga"
@ -19,17 +25,95 @@ _PLAYERS: list[tuple[str, list[str], bool]] = [
]
_NATIVE_EXTS = {".wav", ".oga", ".ogg", ".flac"}
# Allowed audio file extensions for user-configured sound paths.
_ALLOWED_AUDIO_EXTS = {".wav", ".mp3", ".ogg", ".oga", ".flac",
".m4a", ".aac", ".aif", ".aiff", ".opus"}
# Directories that user-configured sound paths are allowed to resolve into.
_ALLOWED_PARENTS: tuple[str, ...] = (
str(Path.home()),
"/usr/share/sounds",
"/opt/blitztext",
)
def validate_sound_path(path: str) -> str | None:
"""Validate a user-configured sound file path.
Returns the resolved absolute path if the file is safe to play,
or ``None`` if the path is empty, does not exist, or fails any
security check.
Checks performed:
1. Empty / whitespace-only paths are rejected.
2. ``~user`` is expanded via ``expanduser``.
3. The path is resolved to an absolute canonical path (``realpath``),
which eliminates symlinks and ``..`` components.
4. The resolved path must be a regular file (not a device, FIFO,
directory, or socket).
5. The resolved path must reside under one of the allowed parent
directories (user home, system sounds, or the Blitztext install
prefix).
6. The file extension must be a known audio format.
"""
if not path or not path.strip():
return None
expanded = os.path.expanduser(path.strip())
if not expanded:
return None
try:
resolved = Path(expanded).resolve(strict=False)
except (OSError, RuntimeError, ValueError):
return None
# Must exist and be a regular file.
if not resolved.exists():
return None
try:
mode = resolved.stat().st_mode
except OSError:
return None
if not stat.S_ISREG(mode):
return None
# Must be under an allowed parent directory.
resolved_str = str(resolved)
allowed = False
for parent in _ALLOWED_PARENTS:
try:
common = os.path.commonpath([resolved_str, parent])
if common == parent:
allowed = True
break
except ValueError:
continue
if not allowed:
return None
# Extension must be a known audio format.
ext = resolved.suffix.lower()
if ext not in _ALLOWED_AUDIO_EXTS:
return None
return resolved_str
def play(path: str = "", *, fallback: str | None = None) -> "subprocess.Popen | None":
"""Play `path` (WAV/MP3/OGG/FLAC/…); fallback to a freedesktop system sound.
User-supplied paths are validated via :func:`validate_sound_path` before
being passed to any audio player. Invalid or unsafe paths are silently
ignored (the fallback sound is still attempted).
Returns the Popen object so callers can terminate a preview, or None.
"""
target = ""
if path:
expanded = os.path.expanduser(path)
if os.path.exists(expanded):
target = expanded
safe = validate_sound_path(path)
if safe is not None:
target = safe
if not target and fallback:
fd = _FREEDESKTOP.format(name=fallback)
if os.path.exists(fd):
@ -53,4 +137,4 @@ def play(path: str = "", *, fallback: str | None = None) -> "subprocess.Popen |
return proc
except OSError:
continue
return None
return None