Compare commits

..

No commits in common. "f678d7d20ff3d6c25134983ac69a51e68f8eff2d" and "1f3bbf26688e0328cdb63197c4391a5cc37eae17" have entirely different histories.

32 changed files with 193 additions and 643 deletions

5
.gitignore vendored
View File

@ -33,8 +33,3 @@ 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,11 +1,10 @@
# Blitztext — User Manual
A reference for every setting in the Blitztext **Settings** window, page by page.
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
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**.
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;
@ -21,11 +20,7 @@ Three buttons run along the top: **Save**, **Save & Restart**, and **✕ Close**
---
## Presets
<p align="center">
<a href="Screenshots/settings-presets.png"><img src="Screenshots/settings-presets.png" alt="Presets page" width="100%"></a>
</p>
## 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
@ -49,35 +44,54 @@ Use the dropdown at the top to pick a preset to edit, **+ Add** to create one, o
---
## General
## Engines tab
<p align="center">
<a href="Screenshots/settings-general.png"><img src="Screenshots/settings-general.png" alt="General page" width="100%"></a>
</p>
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.
Microphone, text delivery, language, notifications, the on-screen overlay, and
autostart.
### 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 |
|---|---|---|
| **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. |
### 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. |
---
## Keyboard
## Input tab
<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.
Controls **how you start and stop** dictating, the noise filter, hands-free
wakeword, and audio cues.
### Input mode & keys
@ -106,26 +120,7 @@ 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. |
### 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>
### Hands-free (Wakeword)
Start dictation with a spoken keyword via an external
[Wyoming](https://github.com/rhasspy/wyoming) openWakeWord server. Maps to the
@ -148,7 +143,20 @@ 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.
> **The two sound pairs differ by trigger *and* by empty-behaviour:**
### 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 |
> |---|---|---|---|
@ -157,69 +165,28 @@ Start dictation with a spoken keyword via an external
---
## STT Engines
## General tab
<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`.
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. |
| **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`). |
---
## 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>
## Benchmark tab
Compare your STT engines for **speed and accuracy** on the same clip. Add an
engine in the STT Engines page for each model you want to compare. No persistent
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.
@ -246,24 +213,7 @@ A summary line names the **fastest** and **most accurate** engine. Click any col
---
## 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>
## 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).
@ -277,21 +227,7 @@ problem (recording, transcription, routing, and wakeword events all appear here)
---
## 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>
## About tab
Read-only information:

135
README.md
View File

@ -8,13 +8,9 @@ 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/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">
<img src="Screenshots/panel.png" alt="Blitztext control panel" width="380">
&nbsp;&nbsp;
<img src="Screenshots/overlay-result.png" alt="On-screen overlay showing transcription result" width="360">
<img src="Screenshots/tray-menu.png" alt="Blitztext system-tray menu" width="300">
</p>
📖 **[User manual](MANUAL.md)** — every setting in every tab, explained.
@ -69,124 +65,47 @@ Stream: hotkey → mic PCM chunks → Riva/NIM WebSocket → live words typed
## Screenshots
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
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.
<p align="center">
<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>
<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>
</p>
<p align="center">
<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>
<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>
</p>
<p align="center">
<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>
<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>
</p>
<p align="center">
<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>
<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>
<p align="center">
<a href="Screenshots/setup-done.png"><img src="Screenshots/setup-done.png" alt="Setup — All done" width="32%"></a>
<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>
</p>
<p align="center">
<em>Summary screen — ready to dictate.</em>
<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>
</p>
---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

View File

@ -9,114 +9,6 @@ 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,13 +13,9 @@ experiment along those lines was moved out to
the optional rewrite step calls out to an LLM.
<p align="center">
<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">
<img src="../Screenshots/panel.png" alt="Blitztext control panel" width="380">
&nbsp;&nbsp;
<img src="../Screenshots/overlay-result.png" alt="On-screen overlay showing transcription result" width="360">
<img src="../Screenshots/tray-menu.png" alt="Blitztext system-tray menu" width="300">
</p>
## Inspiration
@ -65,90 +61,47 @@ visible feedback. Tune the anchor with `[general] overlay_anchor`. X11 only.
## Screenshots
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
Everything is configured in the **Settings** window — every tab has tooltips and
screen-reader (ATK) support. Click any image to open it full size.
<p align="center">
<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>
<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>
</p>
<p align="center">
<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>
<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>
</p>
<p align="center">
<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>
<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>
</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>
<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 — 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 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>
<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>
</p>
### Settings — Log & About
<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>
<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>
<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">
<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>
<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>
</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.41"
__version__ = "2.03.34"

View File

@ -163,7 +163,6 @@ 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.
@ -521,19 +520,13 @@ class Daemon:
self._active_workflow = None
self._stream_segment_text = ""
rec = None
busy = False
elif self._recording is not None:
else:
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()
@ -541,11 +534,6 @@ 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")
@ -620,7 +608,6 @@ 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.
@ -643,9 +630,6 @@ 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)
@ -763,7 +747,6 @@ 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))
@ -780,10 +763,6 @@ 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)"),
("openai", "Server (OpenAI-compatible)"),
("riva_realtime", "Realtime (NVIDIA Riva)"),
("local", "Internal — faster-whisper, runs inside the app"),
("openai", "Server — OpenAI-compatible API (LAN or cloud)"),
("riva_realtime", "Realtime — NVIDIA Riva / NIM streaming"),
]
_LLM_TYPES: list[tuple[str, str]] = [
("local", "LAN server"),
("cloud", "Cloud service"),
("local", "LAN server — runs on your machine or local network"),
("cloud", "Cloud service — OpenAI, Groq, OpenRouter, …"),
]
_DEVICE_OPTIONS: list[tuple[str, str]] = [
("auto", "Auto (GPU → CPU)"),
("auto", "Auto (try GPU / CUDA first, fall back to CPU)"),
("cpu", "CPU"),
("cuda", "GPU (CUDA)"),
("cuda", "GPU (CUDA)"),
]
_COMPUTE_OPTIONS: list[tuple[str, str]] = [
("auto", "Auto"),
("int8", "int8"),
("float16", "float16"),
("int8_float16", "int8_float16"),
("int8", "int8 — fast, less memory"),
("float16", "float16 — accurate, needs more VRAM"),
("int8_float16", "int8_float16 — balanced"),
]
# (name, url, api_key_env, type_key, default_model) — quickstart templates
@ -267,7 +267,6 @@ 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)
@ -306,7 +305,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(58)
lbl.set_line_wrap(True); lbl.set_xalign(0.0); lbl.set_max_width_chars(72)
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()
@ -358,7 +357,6 @@ 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
@ -370,13 +368,6 @@ 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:
@ -385,8 +376,6 @@ 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
@ -417,7 +406,6 @@ 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
@ -433,7 +421,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.set_width_chars(1)
self.entry = Gtk.Entry(); self.entry.set_hexpand(True)
if placeholder:
self.entry.set_placeholder_text(placeholder)
self.pack_start(self.entry, True, True, 0)
@ -519,10 +507,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.set_width_chars(1)
e = Gtk.Entry(); e.set_hexpand(True)
if placeholder:
e.set_placeholder_text(placeholder)
lbl.set_mnemonic_widget(e)
atk = e.get_accessible()
if atk and label:
@ -894,7 +882,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.AUTOMATIC, Gtk.PolicyType.ALWAYS)
sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.ALWAYS)
sw.set_overlay_scrolling(False)
outer.pack_start(sw, True, True, 0)
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
@ -923,8 +911,7 @@ 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_stt_engines)
_reg(None, "LLM Engines", "applications-science-symbolic", self._build_llm_engines)
_reg("Engines", "STT Engines", "network-server-symbolic", self._build_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)
@ -1271,20 +1258,14 @@ class SettingsDialog:
pop.show_all()
search_sw.set_visible(False) # hide search results until user types
# ===== 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.")
# ===== 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")
page.pack_start(self._stt_section(), False, False, 0)
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.")
_section_title(page, "Language model (rewrite)", icon="applications-science-symbolic")
page.pack_start(self._llm_section(), False, False, 0)
self._refresh_status()
@ -1295,8 +1276,6 @@ 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))
@ -1367,7 +1346,6 @@ 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)
@ -1388,8 +1366,6 @@ 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))
@ -1691,19 +1667,15 @@ 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
if hasattr(self, "stt_combo"):
self._stt_commit()
self._stt_commit()
l = self.cfg.llm_engines[self._llm_idx] if 0 <= self._llm_idx < len(self.cfg.llm_engines) else None
if hasattr(self, "llm_combo"):
self._llm_commit()
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
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))
GLib.idle_add(self.stt_dot.set_markup, _dot(sc))
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:
@ -1744,49 +1716,46 @@ 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):
"""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)
"""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)
lbl = Gtk.Label(label=label, xalign=0.0)
lbl.set_size_request(width, -1)
if tooltip_kw:
lbl.set_tooltip_text(tooltip_kw)
row1.pack_start(lbl, False, False, 0)
row_box.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)
row1.pack_start(kw_entry, True, True, 0)
_lb_add(lb, row1)
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)
# 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")
if tooltip_key:
key_lbl.set_tooltip_text(tooltip_key)
row2.pack_start(key_lbl, False, False, 0)
row_box.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(150, -1)
key_entry.set_size_request(100, -1)
if tooltip_key:
key_entry.set_tooltip_text(tooltip_key)
row2.pack_start(key_entry, False, False, 0)
key_lbl.set_tooltip_text(tooltip_key)
row_box.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))
row2.pack_start(set_btn, False, False, 0)
_lb_add(lb, row2)
row_box.pack_start(set_btn, False, False, 0)
_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:
@ -1925,10 +1894,7 @@ 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.set_size_request(10, -1)
_ellipsize_combo(self.ww_combo)
self.ww_combo = _block_scroll(Gtk.ComboBoxText()); self.ww_combo.set_hexpand(True)
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)
@ -1947,8 +1913,6 @@ 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)
@ -1997,7 +1961,6 @@ 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:
@ -2389,7 +2352,7 @@ class SettingsDialog:
page.pack_start(sel_hdr, False, False, 0)
sel_sw = Gtk.ScrolledWindow()
sel_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
sel_sw.set_policy(Gtk.PolicyType.NEVER, 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)
@ -2617,6 +2580,10 @@ 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)
@ -2671,11 +2638,7 @@ 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)
@ -2693,7 +2656,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(340)
ww_paned.set_position(390)
page.pack_start(ww_paned, True, True, 4)
def _run_bench(self, _b) -> None:

View File

@ -50,7 +50,6 @@ 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.
@ -83,7 +82,7 @@ def chat(
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
if stream:
content = _read_stream(resp, on_token, abort_event)
content = _read_stream(resp, on_token)
else:
body = json.loads(resp.read().decode("utf-8"))
content = body["choices"][0]["message"]["content"]
@ -108,14 +107,12 @@ def chat(
return content
def _read_stream(resp, on_token: Callable[[str], None], abort_event=None) -> str:
def _read_stream(resp, on_token: Callable[[str], 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", "busy"):
if self._on_cancel_cb and self._state in ("recording", "streaming"):
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/busy).
if self._on_cancel_cb and self._state in ("recording", "streaming", "busy"):
# × cancel button (top-right corner, recording/streaming only).
if self._on_cancel_cb and self._state in ("recording", "streaming"):
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", "busy")
if self._on_cancel_cb and self._state in ("recording", "streaming")
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 Executable file → Normal file
View File

@ -1,16 +1,10 @@
"""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.
"""
"""Play short audio cues (a user WAV, or a built-in system sound) without blocking."""
from __future__ import annotations
import os
import shutil
import stat
import subprocess
from pathlib import Path
_FREEDESKTOP = "/usr/share/sounds/freedesktop/stereo/{name}.oga"
@ -25,95 +19,17 @@ _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:
safe = validate_sound_path(path)
if safe is not None:
target = safe
expanded = os.path.expanduser(path)
if os.path.exists(expanded):
target = expanded
if not target and fallback:
fd = _FREEDESKTOP.format(name=fallback)
if os.path.exists(fd):
@ -137,4 +53,4 @@ def play(path: str = "", *, fallback: str | None = None) -> "subprocess.Popen |
return proc
except OSError:
continue
return None
return None