Compare commits
10 Commits
1f3bbf2668
...
f678d7d20f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f678d7d20f | ||
| 8f056e8cf0 | |||
| 4915884316 | |||
| e053719c59 | |||
| 4b8b9db43e | |||
| a9c08a54c8 | |||
| 125bd17713 | |||
| 0500b24900 | |||
| 8181ad75f9 | |||
| 79d6020fc6 |
5
.gitignore
vendored
@ -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
@ -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
@ -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">
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<a href="Screenshots/setup-trigger.png"><img src="Screenshots/setup-trigger.png" alt="Setup — Trigger mode" width="32%"></a>
|
||||
|
||||
<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 · Choose trigger mode (keyboard / wakeword / both) · 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>
|
||||
|
||||
<a href="Screenshots/setup-stt.png"><img src="Screenshots/setup-stt.png" alt="Setup — STT engine" width="32%"></a>
|
||||
|
||||
<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) · Choose STT engine · 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
|
After Width: | Height: | Size: 54 KiB |
BIN
Screenshots/overlay-listening.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
Screenshots/overlay-result.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 202 KiB |
BIN
Screenshots/settings-benchmark-stt.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
Screenshots/settings-benchmark-wakeword.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 118 KiB |
BIN
Screenshots/settings-keyboard.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
Screenshots/settings-llm-engines.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 106 KiB |
BIN
Screenshots/settings-manual.png
Normal file
|
After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 118 KiB |
BIN
Screenshots/settings-stt-engines.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
Screenshots/settings-wakeword.png
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
Screenshots/setup-ai.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
Screenshots/setup-done.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
Screenshots/setup-shortcuts.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
Screenshots/setup-stt.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
Screenshots/setup-trigger.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
Screenshots/setup-voice.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
Screenshots/setup-welcome.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
@ -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 300–500 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 40–52 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 ~1000–1360 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
|
||||
|
||||
@ -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">
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<em><b>About</b> — version, source link, inline changelog, and licence.</em>
|
||||
</p>
|
||||
|
||||
## Requirements
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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
|
||||