Compare commits
No commits in common. "main" and "v1.5.1" have entirely different histories.
8
.gitignore
vendored
@ -30,11 +30,3 @@ Secrets.swift
|
|||||||
|
|
||||||
# Tooling
|
# Tooling
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Agent workspace
|
|
||||||
jules/
|
|
||||||
|
|
||||||
# Raw screenshot source folders (published screenshots stay in Screenshots/)
|
|
||||||
Screenshots/Settings - new/
|
|
||||||
Screenshots/Settings-old/
|
|
||||||
Screenshots/Welcome Setup/
|
|
||||||
|
|||||||
237
MANUAL.md
@ -1,11 +1,10 @@
|
|||||||
# Blitztext — User Manual
|
# 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
|
Open Settings from the system-tray menu (**Settings…**) or the control panel. The
|
||||||
sidebar lists all pages: **Presets · General · Keyboard · Wakeword · STT Engines ·
|
window has seven tabs — **Presets · Engines · Input · General · Benchmark · Log ·
|
||||||
LLM Engines · Benchmark — STT · Benchmark — Wakeword · Log · Manual · About**.
|
About** — and three buttons along the bottom.
|
||||||
Three buttons run along the top: **Save**, **Save & Restart**, and **✕ Close**.
|
|
||||||
|
|
||||||
> **Where settings are stored:** `~/.config/blitztext/config.toml`
|
> **Where settings are stored:** `~/.config/blitztext/config.toml`
|
||||||
> (or `$XDG_CONFIG_HOME/blitztext/config.toml`). You can edit that file directly;
|
> (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
|
## Presets tab
|
||||||
|
|
||||||
<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
|
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
|
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">
|
Engines do the work: the **speech-to-text (STT)** engine turns your voice into
|
||||||
<a href="Screenshots/settings-general.png"><img src="Screenshots/settings-general.png" alt="General page" width="100%"></a>
|
text; the **language model (LLM)** rewrites it. Each engine can run locally or on
|
||||||
</p>
|
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
|
### Speech-to-text engine
|
||||||
autostart.
|
|
||||||
|
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 |
|
| Setting | TOML key | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Microphone** | `mic` | Which input device Blitztext records from. |
|
| **Name** | `name` | A label for this engine (e.g. "faster-whisper GPU"). |
|
||||||
| **Input level** | — | Live level bar (read-only); should move when you speak. |
|
| **Type** | `type` | `local` (in-process faster-whisper) · `openai` (any OpenAI-compatible `/v1` STT server) · `riva_realtime` (live streaming engine). |
|
||||||
| **Output** | `output` | `type` types the text key-by-key · `paste` copies it and presses Ctrl+V (faster for long text). |
|
| **URL** | `url` | Server endpoint. Example: `http://localhost:8010/v1` · realtime: `http://localhost:8006/v1`. Ignored for `local`. |
|
||||||
| **Language hint** | `language` | Spoken-language code (`de`, `en`, …). Blank = auto-detect. |
|
| **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. |
|
||||||
| **Notifications** | `notify` | Show desktop notifications for recording/transcription status and errors (manual sessions). |
|
| **API key env** | `api_key_env` | *Name of the environment variable* holding the API key (e.g. `GROQ_API_KEY`). Optional. |
|
||||||
| **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.)* |
|
**Local engine (faster-whisper) — device & precision** (global, `[whisper]`):
|
||||||
| **Launch on login** | *(autostart file)* | Start Blitztext automatically when you log in (writes a desktop autostart entry, not `config.toml`). |
|
|
||||||
|
| 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">
|
Controls **how you start and stop** dictating, the noise filter, hands-free
|
||||||
<a href="Screenshots/settings-keyboard.png"><img src="Screenshots/settings-keyboard.png" alt="Keyboard page" width="100%"></a>
|
wakeword, and audio cues.
|
||||||
</p>
|
|
||||||
|
|
||||||
Controls **how you start and stop** dictating with the keyboard, the noise filter, and audio cues.
|
|
||||||
|
|
||||||
### Input mode & keys
|
### 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. |
|
| **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. |
|
| **Strip trailing punctuation** | `strip_trailing_punctuation` | Remove ending periods from delivered text — handy for code insertion. |
|
||||||
|
|
||||||
### Audio cues (manual dictation)
|
### Hands-free (Wakeword)
|
||||||
|
|
||||||
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
|
Start dictation with a spoken keyword via an external
|
||||||
[Wyoming](https://github.com/rhasspy/wyoming) openWakeWord server. Maps to the
|
[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
|
> You can also pause/resume detection from the tray ("Pause wakeword"), which
|
||||||
> toggles the `/tmp/wake_muted` flag.
|
> 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 |
|
> | | 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">
|
Microphone, text delivery, language, notifications, the on-screen overlay, and
|
||||||
<a href="Screenshots/settings-stt-engines.png"><img src="Screenshots/settings-stt-engines.png" alt="STT Engines page" width="100%"></a>
|
autostart.
|
||||||
</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 |
|
| Setting | TOML key | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Name** | `name` | A label for this engine (e.g. "faster-whisper GPU"). |
|
| **Microphone** | `mic` | Which input device Blitztext records from. |
|
||||||
| **Type** | `type` | `local` (in-process faster-whisper) · `openai` (any OpenAI-compatible `/v1` STT server) · `riva_realtime` (live streaming engine). |
|
| **Input level** | — | Live level bar (read-only); should move when you speak. |
|
||||||
| **URL** | `url` | Server endpoint. Example: `http://localhost:8010/v1` · realtime: `http://localhost:8006/v1`. Ignored for `local`. |
|
| **Output** | `output` | `type` types the text key-by-key · `paste` copies it and presses Ctrl+V (faster for long text). |
|
||||||
| **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. |
|
| **Language hint** | `language` | Spoken-language code (`de`, `en`, …). Blank = auto-detect. |
|
||||||
| **API key env** | `api_key_env` | *Name of the environment variable* holding the API key (e.g. `GROQ_API_KEY`). Optional. |
|
| **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. |
|
||||||
**Local engine (faster-whisper) — device & precision** (global, `[whisper]`):
|
| **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`). |
|
||||||
| 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. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## LLM Engines
|
## Benchmark tab
|
||||||
|
|
||||||
<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
|
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.
|
settings — it's a one-off tool.
|
||||||
|
|
||||||
1. **Audio (.wav)** — a recording to transcribe.
|
1. **Audio (.wav)** — a recording to transcribe.
|
||||||
@ -227,71 +194,21 @@ settings — it's a one-off tool.
|
|||||||
a matching `*.txt` / `*.reference.txt` sits next to the WAV.)
|
a matching `*.txt` / `*.reference.txt` sits next to the WAV.)
|
||||||
3. **Run benchmark** — fills the table with one row per engine.
|
3. **Run benchmark** — fills the table with one row per engine.
|
||||||
|
|
||||||
Result columns:
|
Result columns: **Engine · Model · Device · Time (s) · Accuracy · Output**. A
|
||||||
|
summary line names the **fastest** and **most accurate** engine.
|
||||||
| Column | Description |
|
|
||||||
|---|---|
|
|
||||||
| **Engine** | Engine preset name |
|
|
||||||
| **URL** | Server address (blank for local) |
|
|
||||||
| **Model** | Model name used |
|
|
||||||
| **Device** | `CPU`, `CUDA`, or `remote` |
|
|
||||||
| **Best for** | `Short clips` · `Short / medium` · `Long / batch` · `Streaming` |
|
|
||||||
| **Lang** | Supported languages from the server's `/v1/models` (`—` if unknown) |
|
|
||||||
| **Time (s)** | Wall-clock seconds for this transcription |
|
|
||||||
| **Accuracy** | `1 − WER` × 100 %. 100 % = word-perfect, case-sensitive |
|
|
||||||
| **RAM (MB)** | RSS increase while the engine ran. Captures model load cost on first run. Remote engines show `—`. |
|
|
||||||
| **Output** | Transcribed text (hover for full error on failure) |
|
|
||||||
|
|
||||||
A summary line names the **fastest** and **most accurate** engine. Click any column header to sort.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Benchmark — Wakeword
|
## Log tab
|
||||||
|
|
||||||
<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
|
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).
|
problem (recording, transcription, routing, and wakeword events all appear here).
|
||||||
|
Press **Copy** to put the log on the clipboard when reporting an issue. No
|
||||||
| Control | Description |
|
settings.
|
||||||
|---|---|
|
|
||||||
| **Level** dropdown | Filter by severity: **Verbose** (all), **Info** (default), **Warning**, **Error**. Switch to Warning or Error to cut noise when troubleshooting. |
|
|
||||||
| **Copy** | Put the log on the clipboard when reporting an issue. |
|
|
||||||
| **Clear** | Discard all current log entries. |
|
|
||||||
| **Auto-scroll** | Keep the view scrolled to the latest entry. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Manual
|
## About tab
|
||||||
|
|
||||||
<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:
|
Read-only information:
|
||||||
|
|
||||||
|
|||||||
145
README.md
@ -4,17 +4,13 @@
|
|||||||
|
|
||||||
Blitztext is a native Linux dictation tool that captures your voice, transcribes it locally with [faster-whisper](https://github.com/SYSTRAN/faster-whisper), optionally rewrites the text through an LLM, and types the result directly into whatever application has focus. Think macOS Dictation, but open-source, extensible, and designed for power users who want full control over their speech-to-text pipeline.
|
Blitztext is a native Linux dictation tool that captures your voice, transcribes it locally with [faster-whisper](https://github.com/SYSTRAN/faster-whisper), optionally rewrites the text through an LLM, and types the result directly into whatever application has focus. Think macOS Dictation, but open-source, extensible, and designed for power users who want full control over their speech-to-text pipeline.
|
||||||
|
|
||||||
> **Status:** Experimental open-source Linux/X11 desktop app (v1.7.0).
|
> **Status:** Experimental open-source Linux/X11 desktop app (v1.5.1).
|
||||||
> No hosted backend — bring your own models and endpoints.
|
> No hosted backend — bring your own models and endpoints.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="Screenshots/main-panel.png" alt="Blitztext control panel" width="360">
|
<img src="Screenshots/panel.png" alt="Blitztext control panel" width="380">
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="Screenshots/overlay-listening.png" alt="On-screen overlay while listening" width="360">
|
|
||||||
|
|
||||||
<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>
|
</p>
|
||||||
|
|
||||||
📖 **[User manual](MANUAL.md)** — every setting in every tab, explained.
|
📖 **[User manual](MANUAL.md)** — every setting in every tab, explained.
|
||||||
@ -59,134 +55,56 @@ Stream: hotkey → mic PCM chunks → Riva/NIM WebSocket → live words typed
|
|||||||
- **Fully local STT.** Batch transcription via `faster-whisper` never leaves your machine. No cloud account needed for basic dictation.
|
- **Fully local STT.** Batch transcription via `faster-whisper` never leaves your machine. No cloud account needed for basic dictation.
|
||||||
- **Pluggable engines.** Configure multiple STT and LLM backends as named presets — local `faster-whisper`, remote OpenAI-compatible batch endpoints, Riva/NIM realtime WebSocket servers, and any OpenAI-compatible chat API (OpenAI, vLLM, llama-swap, Ollama, LM Studio, Groq, OpenRouter).
|
- **Pluggable engines.** Configure multiple STT and LLM backends as named presets — local `faster-whisper`, remote OpenAI-compatible batch endpoints, Riva/NIM realtime WebSocket servers, and any OpenAI-compatible chat API (OpenAI, vLLM, llama-swap, Ollama, LM Studio, Groq, OpenRouter).
|
||||||
- **Voice-keyword routing.** One hotkey, multiple workflows. Say "nicer email" at the start or end of your speech and the email-rewrite preset activates automatically (fuzzy-matched, ASR-tolerant).
|
- **Voice-keyword routing.** One hotkey, multiple workflows. Say "nicer email" at the start or end of your speech and the email-rewrite preset activates automatically (fuzzy-matched, ASR-tolerant).
|
||||||
- **Spoken cancel.** Say "abbrechen" (or "cancel") at the start or end of a clip and the whole dictation is discarded — never routed, rewritten, or typed. The rescue for an accidentally triggered (e.g. wakeword) recording. Configurable in Settings; empty list disables it.
|
|
||||||
- **Quality gate.** Silent clips, too-short recordings, and Whisper hallucinations ("Thank you.", "Untertitel…") are caught and rejected before they reach your text field.
|
- **Quality gate.** Silent clips, too-short recordings, and Whisper hallucinations ("Thank you.", "Untertitel…") are caught and rejected before they reach your text field.
|
||||||
- **Realtime streaming.** Connect a Riva/NIM realtime STT server and see stable words typed live as you speak.
|
- **Realtime streaming.** Connect a Riva/NIM realtime STT server and see stable words typed live as you speak.
|
||||||
- **On-screen overlay at the cursor.** The moment you start dictating — by hotkey *or* wakeword — a translucent bubble pops up at the cursor with a pulsing microphone, a live waveform of your mic level, and the recognised text. When a voice keyword routes to a preset it shows that preset's icon, name, and the matched keyword on a banner (instead of a desktop notification), and streams the LLM rewrite into the bubble token-by-token so you watch it write. Its tail points at the text caret (via accessibility) and finally gives hands-free wakeword sessions visible feedback. Click-through, never steals focus; toggle in Settings → General.
|
- **On-screen overlay at the cursor.** The moment you start dictating — by hotkey *or* wakeword — a translucent bubble pops up at the cursor with a pulsing microphone, a live waveform of your mic level, and the recognised text (live with a streaming engine, or as a brief confirmation otherwise). Its tail points at the text caret (via accessibility) and finally gives hands-free wakeword sessions visible feedback. Click-through, never steals focus; toggle in Settings → General.
|
||||||
- **Built-in benchmarking.** Compare all your configured STT engines against a reference WAV + transcript to find the fastest and most accurate.
|
- **Built-in benchmarking.** Compare all your configured STT engines against a reference WAV + transcript to find the fastest and most accurate.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
Everything is configured in the GTK **Settings** window — the sidebar gives quick
|
Everything is configured in the GTK **Settings** window — every tab has tooltips
|
||||||
access to every page. All controls have tooltips and screen-reader (ATK) support.
|
and screen-reader (ATK) support. Click any image to open it full size.
|
||||||
Click any image to open it full size.
|
|
||||||
|
|
||||||
### Main panel & overlay
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="Screenshots/main-panel.png"><img src="Screenshots/main-panel.png" alt="Blitztext main panel" width="46%"></a>
|
<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/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>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="Screenshots/overlay-result.png"><img src="Screenshots/overlay-result.png" alt="Overlay — transcription result" width="46%"></a>
|
<a href="Screenshots/settings-engines.png"><img src="Screenshots/settings-engines.png" alt="Engines settings tab" width="100%"></a><br>
|
||||||
</p>
|
<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 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>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="Screenshots/settings-keyboard.png"><img src="Screenshots/settings-keyboard.png" alt="Keyboard page" width="48%"></a>
|
<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-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>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="Screenshots/setup-voice.png"><img src="Screenshots/setup-voice.png" alt="Setup — Voice activation" width="32%"></a>
|
<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-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>
|
||||||
|
|
||||||
<p align="center">
|
<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>
|
||||||
|
|
||||||
<p align="center">
|
<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>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -323,10 +241,6 @@ Now try with voice routing:
|
|||||||
1. Press `Ctrl+Alt+Space` → say **"nicer email** hey john can you send me the report"
|
1. Press `Ctrl+Alt+Space` → say **"nicer email** hey john can you send me the report"
|
||||||
2. Press `Ctrl` — Blitztext detects the keyword, runs the "Nicer email" rewrite, and types a polished email.
|
2. Press `Ctrl` — Blitztext detects the keyword, runs the "Nicer email" rewrite, and types a polished email.
|
||||||
|
|
||||||
### 4b. Cancel by voice
|
|
||||||
|
|
||||||
Started a recording by accident (or changed your mind)? Just say **"abbrechen"** (or **"cancel"**) at the start or end of what you say. The whole clip is discarded — nothing is transcribed onward, routed, rewritten, or typed, and the overlay briefly shows *✗ Abgebrochen*. This is especially handy with the hands-free wakeword, where a stray trigger could otherwise type ambient speech. Tune the words under **Settings → Mic/Cues → "Cancel words"** (or `[routing] cancel_keywords`); clear the list to switch it off.
|
|
||||||
|
|
||||||
### 5. Explore Settings
|
### 5. Explore Settings
|
||||||
|
|
||||||
Click the ⚙️ gear icon in the panel header, or right-click the tray → **Settings…**
|
Click the ⚙️ gear icon in the panel header, or right-click the tray → **Settings…**
|
||||||
@ -378,7 +292,6 @@ With the default `modifiers` input mode:
|
|||||||
| `Ctrl` | Stop → transcribe → type |
|
| `Ctrl` | Stop → transcribe → type |
|
||||||
| `Alt` | Stop → transcribe → type → press Enter |
|
| `Alt` | Stop → transcribe → type → press Enter |
|
||||||
| `Esc` | Cancel (discard recording) |
|
| `Esc` | Cancel (discard recording) |
|
||||||
| say *"abbrechen"* / *"cancel"* | Cancel by voice — discard the clip (works hands-free too) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 38 KiB |
@ -9,648 +9,6 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
|
||||||
- **Settings window no longer grows wide or tall when switching pages.**
|
|
||||||
`Stack.set_homogeneous(True)` was causing the Stack to request the maximum
|
|
||||||
natural size of all its children (both width and height), so pages like
|
|
||||||
Benchmark — STT (wide TreeView) inflated the dialog to 1000+ px wide for
|
|
||||||
every other page. Reverted to `False`; the dialog now stays at its default
|
|
||||||
860 × 700 and each page scrolls if its content is taller than the window.
|
|
||||||
Also removed the erroneous `NEVER/NEVER` ScrolledWindow policy on the two
|
|
||||||
benchmark pages that was propagating natural TreeView width to the Stack.
|
|
||||||
|
|
||||||
## [2.03.33] - 2026-06-10
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Settings window no longer resizes when switching pages.** Added
|
|
||||||
`Gtk.Stack.set_homogeneous(True)` so the dialog always allocates the
|
|
||||||
maximum page height, preventing the window from growing or shrinking
|
|
||||||
as pages are visited.
|
|
||||||
- **Benchmark — Wakeword: "Engines to test" section no longer hidden.**
|
|
||||||
The paned divider position was raised from 260 to 390 px so all TTS
|
|
||||||
config fields, the engine checkboxes, wakeword model selector, sample
|
|
||||||
count, and run button are fully visible without scrolling. Both
|
|
||||||
benchmark pages also disable the page-level `ScrolledWindow` so the
|
|
||||||
paned correctly fills the viewport height rather than expanding
|
|
||||||
past it.
|
|
||||||
|
|
||||||
## [2.03.32] - 2026-06-10
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Settings dialog navigation redesigned.** Replaced the `Gtk.Notebook` tab bar
|
|
||||||
with a 170 px left sidebar (`Gtk.ListBox` of flat buttons with section headers)
|
|
||||||
and a `Gtk.Stack` for the content area. The dialog is now 860 × 700 px by default.
|
|
||||||
Lazy-loading is preserved: each page is built only on first visit.
|
|
||||||
- **Input page split into Keyboard and Wakeword.** The former "Input" tab is now
|
|
||||||
two separate pages — "Keyboard" (input mode, hotkeys, quality gate, audio cues)
|
|
||||||
and "Wakeword" (enable switch, mic level, test button, silence timeout, cancel/send
|
|
||||||
words, engine preset selector, engine config card, wakeword sound cues). Either
|
|
||||||
page may be visited first; the mic level-meter starts on whichever is opened.
|
|
||||||
- **Benchmark page split into Benchmark — STT and Benchmark — Wakeword.** The
|
|
||||||
single "Benchmark" tab is now two dedicated pages. All field names and collect
|
|
||||||
logic are unchanged.
|
|
||||||
## [2.03.31] - 2026-06-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **First-run setup wizard.** A paged GTK dialog (`setup_wizard.py`) guides
|
|
||||||
new users through five steps: trigger method (keyboard / wakeword / both),
|
|
||||||
keyboard shortcut assignment (with live key capture), wakeword server
|
|
||||||
configuration (with connection test), speech-to-text engine selection
|
|
||||||
(local Whisper model size or remote API), and optional AI rewriting (LLM
|
|
||||||
endpoint + model). Navigation has Back, Next, and Skip buttons. The wizard
|
|
||||||
shows automatically on first launch (before the daemon starts) and can be
|
|
||||||
reopened via the "Setup Wizard…" button in the Settings header bar.
|
|
||||||
Completing the wizard sets `setup_complete = true` in the config so it
|
|
||||||
does not reappear.
|
|
||||||
|
|
||||||
## [2.03.30] - 2026-06-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Dedicated wakeword models for cancel and send.** Two new optional fields in
|
|
||||||
the wakeword settings card — "Cancel model" and "Send model" — let you assign
|
|
||||||
a specific wakeword model (e.g. a custom "stop" or "send it" ONNX model) to
|
|
||||||
each action. When configured, a `WakewordActionListener` opens a second
|
|
||||||
Wyoming connection during recording and fires the action the instant the model
|
|
||||||
triggers — no Whisper pass, no silence timer. The Whisper-based cancel watcher
|
|
||||||
from v2.03.29 remains as a fallback when no cancel wakeword model is set.
|
|
||||||
Model dropdowns are populated from the same server fetch as the trigger model.
|
|
||||||
|
|
||||||
## [2.03.29] - 2026-06-10
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Cancel keywords now fire immediately during wakeword recording.** A
|
|
||||||
real-time `_CancelWatcher` accumulates raw PCM from the VAD level-meter,
|
|
||||||
then every ~0.6 s of new audio runs a fast `beam_size=1` local transcription
|
|
||||||
pass to check for cancel keywords. When one is found it calls
|
|
||||||
`cancel_dictation()` instantly — no waiting for the silence timer or a full
|
|
||||||
transcription of the whole clip. Falls back to the existing post-transcription
|
|
||||||
check if no local transcriber is loaded or no cancel keywords are configured.
|
|
||||||
|
|
||||||
## [2.03.28] - 2026-06-10
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Dropdowns no longer change on accidental scroll.** All `ComboBoxText`
|
|
||||||
widgets in the settings dialog now swallow scroll events so hovering over
|
|
||||||
a combo and scrolling doesn't silently change the selected value.
|
|
||||||
|
|
||||||
## [2.03.27] - 2026-06-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Overlay × cancel button.** A small × button appears in the top-right corner
|
|
||||||
of the on-screen waveform HUD while recording or streaming. Clicking it
|
|
||||||
cancels the current dictation. The rest of the overlay remains fully
|
|
||||||
click-through; only the button area receives pointer events.
|
|
||||||
- **Cancel/Send keyboard shortcuts in Wakeword tab.** The "Cancel words" and
|
|
||||||
"Send words" rows in the Input → Wakeword section now include an inline
|
|
||||||
shortcut entry + "Set" button, so you can configure `key_cancel` /
|
|
||||||
`key_send` right next to the spoken-word equivalents without visiting the
|
|
||||||
keyboard-mode card.
|
|
||||||
|
|
||||||
## [2.03.26] - 2026-06-10
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Cancel key now works during wakeword-triggered recording.** Previously the
|
|
||||||
`ModifierScheme` state machine stayed in "idle" when the wakeword fired
|
|
||||||
(it bypasses the key-press path), so the cancel hotkey was silently ignored.
|
|
||||||
It now checks `daemon.is_recording` as a fallback so it fires regardless of
|
|
||||||
how recording started.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **"✕ Cancel recording" in the tray menu.** Always visible; grayed out when
|
|
||||||
idle, enabled as soon as recording starts (wakeword or manual). The primary
|
|
||||||
escape hatch when the wakeword fires on audiobook / TV audio and spoken
|
|
||||||
cancel words can't be heard over the background audio.
|
|
||||||
|
|
||||||
## [2.03.25] - 2026-06-10
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Smart Save — no restart popup anymore.** "Save" now diffs the changed
|
|
||||||
settings against what requires a daemon restart. If only safe settings
|
|
||||||
changed (language, sounds, LLM prompt, keywords, overlay, …) it shows
|
|
||||||
"✓ Settings applied" inline in the header bar for 4 s and closes the
|
|
||||||
dialog. If restart-required fields changed (STT engine, hotkeys,
|
|
||||||
microphone, wakeword server) it shows "⚠ Saved — restart needed for: …"
|
|
||||||
and highlights "Save & Restart" so you can act on it. No modal popups.
|
|
||||||
|
|
||||||
## [2.03.24] - 2026-06-10
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Input level meter works without visiting General first.** The level meter
|
|
||||||
was only started inside `_build_general()` and referenced `mic_level`
|
|
||||||
unconditionally. If Input was opened first the meter never started. Now
|
|
||||||
`_build_input()` also starts the meter when it isn't running yet, and both
|
|
||||||
level bars (`mic_level` in General and `ww_mic_level` in Input) are updated
|
|
||||||
defensively via `hasattr` so either tab can be visited in any order.
|
|
||||||
|
|
||||||
## [2.03.23] - 2026-06-10
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Wakeword results table actually resizable.** The controls pane is now
|
|
||||||
wrapped in a ScrolledWindow with `shrink=True`, so dragging the divider
|
|
||||||
upward collapses the controls and expands the table freely.
|
|
||||||
|
|
||||||
## [2.03.22] - 2026-06-10
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Wakeword benchmark uses a split pane.** The TTS config / engine selector
|
|
||||||
controls sit in the top pane; the results table sits in the bottom pane.
|
|
||||||
Drag the divider to give the table as much vertical space as needed.
|
|
||||||
|
|
||||||
## [2.03.21] - 2026-06-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Wakeword results table: sortable columns.** Click any column header to sort
|
|
||||||
ascending/descending. Numeric columns (Detected, Total, Recall %, False fires,
|
|
||||||
Time) sort numerically.
|
|
||||||
- **Wakeword results table: CSV export.** "Copy as CSV" copies the table to the
|
|
||||||
clipboard; "Save CSV…" opens a file chooser to write a `.csv` file.
|
|
||||||
|
|
||||||
## [2.03.20] - 2026-06-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Wakeword benchmark results table.** Results are now shown in a TreeView
|
|
||||||
with one row per engine per voice: Engine | Wakeword | Voice | Detected |
|
|
||||||
Total | Recall % | False fires | Time. Rows are colour-coded green/orange/red
|
|
||||||
by recall. An aggregate "ALL (N voices)" row is appended per engine.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Section header icons now vertically centred with the headline text.**
|
|
||||||
The `.bt-section` CSS class was inadvertently applied to the icon widget,
|
|
||||||
giving it a 14 px top margin and pushing it down. The image no longer
|
|
||||||
receives that class; a `set_pixel_size(14)` pin ensures consistent sizing.
|
|
||||||
|
|
||||||
## [2.03.19] - 2026-06-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Wakeword engine checkboxes in benchmark.** A row of checkboxes above the
|
|
||||||
"Run wakeword benchmark" button lets you pick which engines to include.
|
|
||||||
All are checked by default.
|
|
||||||
- **Wakeword model selector in benchmark.** A "Wakeword" combo lets you
|
|
||||||
override which wakeword phrase (model) to test. Leave empty for the default
|
|
||||||
(each engine uses its own configured model). Pick a specific model (e.g.
|
|
||||||
`okay_computer`) to test that phrase on every selected engine.
|
|
||||||
|
|
||||||
## [2.03.18] - 2026-06-10
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **TTS model dropdown no longer floods with voice names.** Servers like Kokoro
|
|
||||||
expose each voice as a `/models` entry. The ⟳ button now detects this case
|
|
||||||
and skips filling the model combo, prompting the user to type the model id
|
|
||||||
manually (e.g. `kokoro`). The status line shows "type model id manually" as
|
|
||||||
a hint.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Wakeword benchmark runs across all engines and shows per-engine results.**
|
|
||||||
Previously a callback signature mismatch caused the benchmark to crash when
|
|
||||||
more than one engine was configured. Now progress shows `[1/3] engine name`,
|
|
||||||
and the results panel lists Recall / False fires / time per engine.
|
|
||||||
|
|
||||||
## [2.03.17] - 2026-06-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Wakeword model fetch feedback.** The ⟳ button now shows a status line while
|
|
||||||
connecting; after loading it reports how many models were found (with their
|
|
||||||
names) or "Unreachable" if the server is down.
|
|
||||||
- **Wakeword Quickstart covers all four ports.** The Quickstart menu now lists
|
|
||||||
presets for ports 10400–10403, plus `hey_jarvis` and `alexa` variants.
|
|
||||||
- **Wakeword info box.** An info banner explains how wyoming-openwakeword works,
|
|
||||||
where to put model files, and lists the common built-in models.
|
|
||||||
|
|
||||||
## [2.03.16] - 2026-06-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **MP3/OGG/FLAC support for sound cues.** The sound file picker now accepts
|
|
||||||
WAV, MP3, OGG, FLAC, M4A, AAC, AIFF, and Opus. Playback uses `ffplay` or
|
|
||||||
`gst-play-1.0` as a universal fallback when the native `pw-play`/`paplay`
|
|
||||||
can't handle the format.
|
|
||||||
- **Browse dialog with auto-preview.** The 📁 browse button opens a
|
|
||||||
`FileChooserDialog`; selecting a file auto-plays it so you can hear it before
|
|
||||||
confirming. The ▶ play button still works on the current selection.
|
|
||||||
|
|
||||||
## [2.03.15] - 2026-06-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Wakeword engine CRUD.** The wakeword server section now has the same full
|
|
||||||
management UI as STT engines: a named-preset selector combo, + Add, Quickstart
|
|
||||||
(with 4 common wyoming-openwakeword templates), ⟳ reload, and Delete. Existing
|
|
||||||
users are migrated: their `wakeword_uri` / `wakeword_model` become the first
|
|
||||||
preset automatically.
|
|
||||||
|
|
||||||
## [2.03.14] - 2026-06-10
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Engines tab.** Removed the "Internal engine — device & precision" section
|
|
||||||
header. The Device and Compute type fields already only appear when a local
|
|
||||||
engine type is selected; the separate header was redundant.
|
|
||||||
|
|
||||||
## [2.03.13] - 2026-06-10
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Settings header bar.** Save and Save & Restart moved from the bottom button
|
|
||||||
bar into the title bar (GTK HeaderBar). The X button closes without saving.
|
|
||||||
Bottom button row removed.
|
|
||||||
- **Section icon alignment.** Icons in section headers are now vertically
|
|
||||||
centred with the label text (`SMALL_TOOLBAR` size, `valign=CENTER`).
|
|
||||||
|
|
||||||
## [2.03.12] - 2026-06-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Icons in settings.** All tab labels (Presets, Engines, Input, General,
|
|
||||||
Benchmark, Log, Manual, About) and every section header inside each tab now
|
|
||||||
show a small GTK symbolic icon, making the layout easier to scan.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Resize grip position.** The grip indicator now appears correctly at the
|
|
||||||
bottom-right corner below the notebook, not misplaced in the tab bar.
|
|
||||||
|
|
||||||
## [2.03.11] - 2026-06-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Resize grip indicator.** A dotted SE-corner grip is drawn over the
|
|
||||||
bottom-right of the settings window so users discover it is resizable.
|
|
||||||
|
|
||||||
## [2.03.10] - 2026-06-09
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Server RAM probe.** Prometheus `/metrics` is almost always at the server
|
|
||||||
root (`http://host:port/metrics`), not under `/v1`. Now tries the root URL
|
|
||||||
first before falling back to the API base path.
|
|
||||||
|
|
||||||
## [2.03.09] - 2026-06-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Server RAM in benchmark.** For remote/Docker STT engines the benchmark now
|
|
||||||
probes the server's Prometheus `/metrics` endpoint for
|
|
||||||
`process_resident_memory_bytes` (standard Python/Go exporter) or
|
|
||||||
`container_memory_rss` (cAdvisor) and shows the server-side RSS in MB in the
|
|
||||||
RAM column. Falls back to `server` when the endpoint is not exposed.
|
|
||||||
|
|
||||||
## [2.03.08] - 2026-06-09
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Engines tab layout.** The "Internal engine — device & precision" section is
|
|
||||||
now hidden when a remote (Server) or streaming engine type is selected —
|
|
||||||
removing the confusing whitespace gap and irrelevant device controls for
|
|
||||||
non-local engines.
|
|
||||||
|
|
||||||
## [2.03.01] - 2026-06-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **RAM usage column in benchmark.** The results table now shows a **RAM (MB)**
|
|
||||||
column — the increase in process RSS while the transcription ran. For local
|
|
||||||
models this captures the memory cost of loading the model on first use; for
|
|
||||||
remote engines it shows `—` (work happens server-side). Values are measured via
|
|
||||||
`/proc/self/status` (VmRSS), so they reflect actual resident memory, not
|
|
||||||
virtual address space.
|
|
||||||
|
|
||||||
## [2.03.00] - 2026-06-09
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **"Not responding" / system instability on Save.** `_collect()` was calling
|
|
||||||
`socket.create_connection()` *synchronously* on the GTK main thread when
|
|
||||||
wakeword is enabled — freezing the UI for up to 1.5 s (longer if DNS is slow).
|
|
||||||
The check is now done on a daemon thread and the result is logged instead of
|
|
||||||
blocking the save path.
|
|
||||||
- **GTK thread-safety crash in wakeword model load.** `_ww_load()` read
|
|
||||||
`self.ww_uri.get_text()` from inside a background thread — unsafe. The URI is
|
|
||||||
now captured on the main thread before the thread is spawned.
|
|
||||||
- **HTTP 404 with WhisperX and other non-standard endpoints.** The remote
|
|
||||||
transcription call always appended `/audio/transcriptions` to the base URL, but
|
|
||||||
services like WhisperX use `/transcribe` as the full path. The URL path is now
|
|
||||||
inspected: if it is anything other than empty / `/v1` / `/v1.0`, the URL is
|
|
||||||
used as the complete endpoint with nothing appended — so
|
|
||||||
`http://host:8081/transcribe` works out of the box.
|
|
||||||
- **Log levels.** `logbuffer` now stores `(timestamp, level, message)` tuples and
|
|
||||||
accepts a `level=` keyword (`DEBUG` / `INFO` / `WARNING` / `ERROR`). The Log
|
|
||||||
tab gains a **Level** dropdown (Verbose · Info · Warning · Error) that filters
|
|
||||||
the displayed entries live. Wakeword and socket errors are now tagged
|
|
||||||
`WARNING`; library records are forwarded at their native level.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Wakeword server preset dropdown** (Input → Hands-free wakeword). A
|
|
||||||
**Server preset** combo lists all configured wakeword server engines by name.
|
|
||||||
Picking one auto-fills the URI and model fields and re-probes reachability.
|
|
||||||
The selection is persisted as `wakeword_active` in config.
|
|
||||||
|
|
||||||
## [2.02.03] - 2026-06-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Wakeword server preset dropdown in Input tab.
|
|
||||||
|
|
||||||
## [2.02.02] - 2026-06-09
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- License tab now renders with markdown styling.
|
|
||||||
- Benchmark pane minimum height (320 px, `shrink=False`) prevents the engine
|
|
||||||
list or results table from collapsing to zero when the window is small.
|
|
||||||
|
|
||||||
## [2.02.01] - 2026-06-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Last benchmark time and accuracy shown on the selected STT engine in the
|
|
||||||
Engines tab. Persisted to config so it survives restarts.
|
|
||||||
|
|
||||||
## [2.02.00] - 2026-06-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Language metadata in benchmark.** The engine checkbox list shows supported
|
|
||||||
language codes next to each engine (fetched async). Filter box searches by
|
|
||||||
language code. Results table has a **Lang** column. Data comes from the
|
|
||||||
`/v1/models` `language` field (faster-whisper-server) or NVIDIA NIM `/metadata`.
|
|
||||||
|
|
||||||
## [1.9.5] - 2026-06-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Emoji picker search.** A search field at the top of the emoji picker filters
|
|
||||||
all categories in real time using Unicode character names (e.g. "fire", "dog",
|
|
||||||
"heart"). Typing hides the category bar and shows matching results; clearing
|
|
||||||
restores the category view.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Manual tab now shows content.** `MANUAL.md` is copied next to the package
|
|
||||||
module so the Manual tab finds it in both venv and deb installs.
|
|
||||||
- **Info banner no longer bright blue.** The `.bt-infobox` background now uses
|
|
||||||
a neutral 5 % tint of the foreground colour instead of the theme accent
|
|
||||||
colour, so text stays readable on any theme.
|
|
||||||
|
|
||||||
## [1.9.4] - 2026-06-09
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Settings UI completely redesigned.** All six settings tabs (Presets, Engines,
|
|
||||||
Input, General, Input, General) now use a card-based layout following GTK3 best
|
|
||||||
practices: related fields are grouped inside visually distinct cards with bold
|
|
||||||
section titles. CSS is injected at start-up to give cards a consistent rounded
|
|
||||||
border (`boxed-list` + `bt-card`) and a styled info banner at the top of each
|
|
||||||
tab.
|
|
||||||
- **Dialog is larger (740×700 px) and every tab scrolls.** The notebook pages
|
|
||||||
now wrap their content in a `Gtk.ScrolledWindow` so no fields are ever clipped,
|
|
||||||
even on small screens.
|
|
||||||
- **Engines toolbar reorganised.** Creation actions (+ Add, + Stream, Quickstart)
|
|
||||||
are left-aligned; destructive/status actions (Delete, Test, ⟳) are
|
|
||||||
right-aligned via `pack_end`, making the bar scannable at a glance.
|
|
||||||
- **Section titles replace plain separators.** The old `Gtk.Separator` +
|
|
||||||
unstyled `Gtk.Label` pattern is gone; every section now has a small, dimmed,
|
|
||||||
bold all-caps header rendered with markup.
|
|
||||||
- **Cleaner section names.** "WW - Wakeword (Hands-free)" → "Hands-free
|
|
||||||
wakeword"; "Audio cues (manual dictation)" → "Audio cues (keyboard / hotkey
|
|
||||||
dictation)"; "Local engine … device & precision" → "Internal engine — device &
|
|
||||||
precision".
|
|
||||||
|
|
||||||
## [1.9.3] - 2026-06-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **ⓘ info buttons on every settings field.** Each field in every tab now has a
|
|
||||||
small information icon that opens a plain-language help popover when clicked —
|
|
||||||
so non-technical users can understand what each setting does without hovering
|
|
||||||
or reading the manual.
|
|
||||||
- **Manual tab in Settings.** A new "Manual" tab shows the full `MANUAL.md`
|
|
||||||
reference doc directly inside the Settings window.
|
|
||||||
- **Quickstart templates for engines.** A "Quickstart ▾" button in the STT and
|
|
||||||
LLM engine toolbars opens a menu of common services (OpenAI, Groq, OpenRouter,
|
|
||||||
Ollama, LM Studio, vLLM, llama-swap, faster-whisper-server, NVIDIA Riva) and
|
|
||||||
pre-fills the form — one click to configure a provider.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Engine type names are now human-readable.** STT types now read "Internal —
|
|
||||||
faster-whisper, runs inside the app", "Server — OpenAI-compatible API (LAN or
|
|
||||||
cloud)", and "Realtime — NVIDIA Riva / NIM streaming" instead of the raw
|
|
||||||
identifiers. LLM types read "LAN server — runs on your machine or local
|
|
||||||
network" and "Cloud service — OpenAI, Groq, OpenRouter, …".
|
|
||||||
- **Device selector now shows "GPU (CUDA)" instead of "cuda"**, and compute
|
|
||||||
types have plain-language descriptions (e.g. "int8 — fast, less memory").
|
|
||||||
|
|
||||||
## [1.9.2] - 2026-06-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Emoji picker for preset icons.** The "Icon (emoji)" field in Settings →
|
|
||||||
Presets now has a 😀 button that opens a scrollable emoji grid (60 common
|
|
||||||
emojis across six categories). Click any emoji to insert it — or keep typing
|
|
||||||
directly into the field as before.
|
|
||||||
|
|
||||||
## [1.9.1] - 2026-06-08
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Settings opens instantly.** Each tab's contents are now built the first time
|
|
||||||
you view it instead of all up front, so the dialog no longer pauses ~1.3s
|
|
||||||
constructing the file-choosers in the Input/Benchmark tabs. Saving force-builds
|
|
||||||
any tab you didn't visit first, so no field is ever missed.
|
|
||||||
- **Connection dots moved beside their field.** The Wakeword and TTS reachability
|
|
||||||
dots now sit just left of the URL entry (matching the Engines tab) instead of
|
|
||||||
at the far right of the row.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Settings could be opened more than once.** Choosing Settings while it's
|
|
||||||
already open now raises the existing window instead of stacking a second copy.
|
|
||||||
|
|
||||||
## [1.9.0] - 2026-06-08
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Connection indicators** for remote endpoints. The **Wakeword engine** field
|
|
||||||
(Input tab — renamed from "Wyoming URI" to read more generally) and the **TTS
|
|
||||||
URL** field (Benchmark tab) now show a coloured dot: green when the server is
|
|
||||||
reachable, red when it's configured but unreachable, grey when blank — mirroring
|
|
||||||
the STT/LLM engine dots. It's a lightweight background TCP probe, refreshed when
|
|
||||||
the dialog opens, when you press ⟳, and when you leave the field.
|
|
||||||
|
|
||||||
## [1.8.1] - 2026-06-08
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Settings dialog and control panel wouldn't open on some desktops.** When the
|
|
||||||
gvfs `org.gtk.vfs.UDisks2VolumeMonitor` dbus service fails to activate (common
|
|
||||||
on headless or minimal sessions), every `Gtk.FileChooserButton` blocked ~25s on
|
|
||||||
a `StartServiceByName` timeout while realizing — so the Settings dialog never
|
|
||||||
finished appearing, and the stalled GTK main loop froze the panel too. Blitztext
|
|
||||||
now selects GIO's native `/proc/mounts` volume monitor
|
|
||||||
(`GIO_USE_VOLUME_MONITOR=unix`) before any window is realized, so file choosers
|
|
||||||
open instantly with no dbus dependency.
|
|
||||||
|
|
||||||
## [1.8.0] - 2026-06-08
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Send by voice**: say a distinctive phrase like **"computer send"** at the
|
|
||||||
start or end of a clip and the word is stripped, then the rest is typed **and
|
|
||||||
submitted with Enter** — the spoken equivalent of "stop + paste + Enter".
|
|
||||||
Mainly for hands-free use, where you can't press a key. Configure under
|
|
||||||
Settings → Input → "Send words", or `[routing] send_keywords`. Off by default;
|
|
||||||
because it presses Enter, use a multi-word phrase (e.g. your wakeword + "send")
|
|
||||||
so a sentence that merely ends in "send" doesn't submit by accident. Matched
|
|
||||||
the same edge-anchored, ASR-tolerant way as routing/cancel keywords.
|
|
||||||
- **Wakeword benchmark** (Settings → Benchmark): stress-test hands-free
|
|
||||||
detection. It synthesizes short sentences with your wake phrase spoken in
|
|
||||||
random voices (plus pure-filler utterances with none), streams them to your
|
|
||||||
wyoming-openwakeword server, and reports **recall** (how reliably it fires),
|
|
||||||
**false fires**, and a **per-voice** breakdown. Speech comes from any
|
|
||||||
OpenAI-compatible TTS server (Kokoro-FastAPI, XTTS, OpenAI, …): set its URL,
|
|
||||||
optional API-key env var, model, and voices under the new `[tts]` config / the
|
|
||||||
Benchmark tab, and use **Connect** to test it (it auto-fills the voice list
|
|
||||||
when the server exposes one).
|
|
||||||
|
|
||||||
## [1.7.1] - 2026-06-08
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Overlay waveform and silence countdown ring never appeared** on systems
|
|
||||||
where PortAudio/`sounddevice` can't open the default input — notably PipeWire
|
|
||||||
boxes, where opening an input stream simply hangs. Both the live waveform and
|
|
||||||
the auto-stop countdown are driven by a single level meter, which was the only
|
|
||||||
part of the app still using `sounddevice` (everything else records via
|
|
||||||
`pw-record`). The meter now streams raw PCM from the **same system recorder as
|
|
||||||
the WAV recorder** (`pw-record`/`parecord`/`arecord`) and computes the level
|
|
||||||
itself, so it works wherever recording works — on both the hotkey and
|
|
||||||
hands-free (wakeword) paths, plus the mic-level preview in Settings. No more
|
|
||||||
PortAudio dependency for metering.
|
|
||||||
- **App reported itself as "`__main__.py`"** in the taskbar and in GNOME's
|
|
||||||
"… is not responding" dialog. Launched via `python -m blitztext`, GTK's default
|
|
||||||
program name is `argv[0]`'s basename. It now sets `prgname`/application name to
|
|
||||||
**Blitztext** before any window is realized (and the desktop file gains
|
|
||||||
`StartupWMClass=blitztext` for the .desktop match + icon), without touching the
|
|
||||||
`-m blitztext` entry point.
|
|
||||||
|
|
||||||
## [1.7.0] - 2026-06-07
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Spoken cancel keyword**: say a word like **"abbrechen"** (or "cancel") at the
|
|
||||||
start or end of a clip and the whole dictation is **discarded** — it is never
|
|
||||||
routed, rewritten, or typed anywhere. Mainly rescues an accidentally triggered
|
|
||||||
(e.g. wakeword) recording. Configure under Settings → Mic/Cues → "Cancel words",
|
|
||||||
or `[routing] cancel_keywords` (default `["abbrechen", "cancel"]`; empty list
|
|
||||||
disables it). Matched the same edge-anchored, ASR-tolerant way as routing
|
|
||||||
keywords, so the word buried mid-sentence won't trip it.
|
|
||||||
|
|
||||||
## [1.6.0] - 2026-06-07
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Session freeze when the overlay's caret tracking was active** (could lock up
|
|
||||||
the whole GNOME/X11 desktop, forcing a logout/reboot). The AT-SPI caret tracker
|
|
||||||
subscribed to the high-frequency `object:text-caret-moved` signal and made
|
|
||||||
**synchronous, blocking AT-SPI reads from inside the event handler** — which
|
|
||||||
re-enters the accessibility dispatcher and is stormed by the app's *own*
|
|
||||||
`xdotool` typing (one event per character), congesting the a11y bus until the
|
|
||||||
desktop stopped responding. It now tracks **focus changes only** and reads the
|
|
||||||
caret rectangle lazily (once, when the overlay shows), never from inside an
|
|
||||||
event dispatch.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Matched preset is fused into the overlay instead of a desktop notification**:
|
|
||||||
when voice routing picks a preset, the overlay shows its emoji icon, name, and
|
|
||||||
the spoken keyword on a banner, and narrates the phase ("Transcribing…" →
|
|
||||||
"Rewriting…"). With the overlay on, the redundant per-dictation notifications
|
|
||||||
are suppressed (errors still notify); headless/overlay-off keeps notifications.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Live LLM rewrite in the overlay**: rewrite presets now stream the model's
|
|
||||||
output into the bubble token-by-token, so you watch it write. The delivered
|
|
||||||
text is still the complete result, typed once the rewrite finishes.
|
|
||||||
|
|
||||||
## [1.5.1] - 2026-06-07
|
## [1.5.1] - 2026-06-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
105
linux/README.md
@ -13,13 +13,9 @@ experiment along those lines was moved out to
|
|||||||
the optional rewrite step calls out to an LLM.
|
the optional rewrite step calls out to an LLM.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../Screenshots/main-panel.png" alt="Blitztext control panel" width="360">
|
<img src="../Screenshots/panel.png" alt="Blitztext control panel" width="380">
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="../Screenshots/overlay-listening.png" alt="On-screen overlay while listening" width="360">
|
|
||||||
|
|
||||||
<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>
|
</p>
|
||||||
|
|
||||||
## Inspiration
|
## Inspiration
|
||||||
@ -43,18 +39,6 @@ Each normal hotkey **toggles**: press to start recording, press again to stop
|
|||||||
then it transcribes, optionally rewrites, and types the result where your cursor
|
then it transcribes, optionally rewrites, and types the result where your cursor
|
||||||
is. Streaming workflows type stable words live while you speak.
|
is. Streaming workflows type stable words live while you speak.
|
||||||
|
|
||||||
**Cancel by voice:** say *"abbrechen"* (or *"cancel"*) at the start or end of a
|
|
||||||
clip and the whole dictation is discarded — never routed, rewritten, or typed.
|
|
||||||
It's the rescue for an accidentally triggered (e.g. wakeword) recording. Set the
|
|
||||||
words under `[routing] cancel_keywords` (default `["abbrechen", "cancel"]`; an
|
|
||||||
empty list turns it off).
|
|
||||||
|
|
||||||
**Send by voice:** say a distinctive phrase like *"computer send"* at the start
|
|
||||||
or end of a clip and the word is stripped, then the rest is typed **and submitted
|
|
||||||
with Enter** — the spoken equivalent of "stop + paste + Enter", ideal hands-free.
|
|
||||||
Off by default; set the phrases under `[routing] send_keywords` (use a multi-word
|
|
||||||
phrase so a sentence merely ending in "send" doesn't submit by accident).
|
|
||||||
|
|
||||||
While you dictate, an optional **on-screen overlay** (Settings → General →
|
While you dictate, an optional **on-screen overlay** (Settings → General →
|
||||||
"Visual overlay", default on) shows a translucent bubble at the cursor with a
|
"Visual overlay", default on) shows a translucent bubble at the cursor with a
|
||||||
pulsing microphone, a live waveform of your mic level, and the recognised text —
|
pulsing microphone, a live waveform of your mic level, and the recognised text —
|
||||||
@ -65,90 +49,47 @@ visible feedback. Tune the anchor with `[general] overlay_anchor`. X11 only.
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
Everything is configured in the **Settings** window — the sidebar gives quick
|
Everything is configured in the **Settings** window — every tab has tooltips and
|
||||||
access to every page. Click any image to open it full size.
|
screen-reader (ATK) support. Click any image to open it full size.
|
||||||
|
|
||||||
### Main panel & overlay
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="../Screenshots/main-panel.png"><img src="../Screenshots/main-panel.png" alt="Blitztext main panel" width="46%"></a>
|
<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/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>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="../Screenshots/overlay-result.png"><img src="../Screenshots/overlay-result.png" alt="Overlay — transcription result" width="46%"></a>
|
<a href="../Screenshots/settings-engines.png"><img src="../Screenshots/settings-engines.png" alt="Engines settings tab" width="100%"></a><br>
|
||||||
</p>
|
<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 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>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="../Screenshots/settings-keyboard.png"><img src="../Screenshots/settings-keyboard.png" alt="Keyboard page" width="48%"></a>
|
<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-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>
|
||||||
|
|
||||||
### Settings — Engines
|
|
||||||
|
|
||||||
<p align="center">
|
<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/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/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>
|
||||||
|
|
||||||
### Settings — Benchmark
|
|
||||||
|
|
||||||
<p align="center">
|
<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-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-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>
|
</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 to find the fastest and most accurate, with a Device column (CPU / GPU / remote).</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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-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-about.png"><img src="../Screenshots/settings-about.png" alt="About page" width="48%"></a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<em><b>Log</b> — live activity log for recording, transcription, routing, and wakeword events.</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>
|
||||||
<em><b>About</b> — version, source link, inline changelog, and licence.</em>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Requirements
|
## 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.
|
(not in a container) so it can type into any application via xdotool.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "2.03.41"
|
__version__ = "1.5.1"
|
||||||
|
|||||||
@ -1,39 +1,43 @@
|
|||||||
"""Audio helpers: enumerate input devices and a live input-level meter.
|
"""Audio helpers: enumerate input devices and a live input-level meter.
|
||||||
|
|
||||||
Mic enumeration uses pactl (PipeWire/PulseAudio source names, which pw-record
|
Mic enumeration uses pactl (PipeWire/PulseAudio source names, which pw-record
|
||||||
and parecord accept via --target/-d). The level meter shells out to the same
|
and parecord accept via --target/-d). The level meter uses sounddevice to read
|
||||||
system recorder the app uses (pw-record/parecord/arecord), reading raw PCM from
|
the chosen input and report a 0..1 level to a callback.
|
||||||
its stdout and reporting a 0..1 level to a callback — no Python audio binding,
|
|
||||||
so it works wherever the recorder does (PortAudio/sounddevice can't open the
|
|
||||||
default input on some PipeWire systems).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from .recorder import detect_recorder
|
|
||||||
|
|
||||||
# Raw-PCM (s16le, 16 kHz mono) variants of the recorders, streamed to stdout so
|
@contextlib.contextmanager
|
||||||
# we can RMS each chunk directly. Mirrors recorder.py's WAV commands but emits
|
def _quiet_c_stderr():
|
||||||
# headerless PCM. pw-record/parecord default to stdout; arecord uses "-t raw".
|
"""Silence chatter written directly to fd 2 by C libraries.
|
||||||
_METER_ARGV: dict[str, list[str]] = {
|
|
||||||
"pw-record": ["pw-record", "--rate=16000", "--channels=1", "--format=s16", "-"],
|
|
||||||
"parecord": ["parecord", "--rate=16000", "--channels=1", "--format=s16le"],
|
|
||||||
"arecord": ["arecord", "-q", "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "raw"],
|
|
||||||
}
|
|
||||||
|
|
||||||
# How to point each recorder at a specific pactl/pipewire source (mirrors
|
PortAudio/ALSA print harmless thread-teardown noise
|
||||||
# recorder._DEVICE_FLAG; arecord uses ALSA names, so it stays on the default).
|
("pthread_join ... failed", "PaUnixThread_Terminate ... failed") straight to
|
||||||
_DEVICE_FLAG: dict[str, list[str]] = {
|
the underlying stderr file descriptor, which Python-level redirection can't
|
||||||
"pw-record": ["--target"],
|
catch. We briefly point fd 2 at /dev/null around the offending call.
|
||||||
"parecord": ["-d"],
|
"""
|
||||||
"arecord": [],
|
try:
|
||||||
}
|
stderr_fd = sys.stderr.fileno()
|
||||||
|
except (AttributeError, ValueError, OSError):
|
||||||
_CHUNK_BYTES = 3200 # 100 ms of 16 kHz, 16-bit, mono → ~10 Hz level updates
|
yield # No real stderr fd (already captured/redirected) — nothing to do.
|
||||||
|
return
|
||||||
|
saved_fd = os.dup(stderr_fd)
|
||||||
|
devnull_fd = os.open(os.devnull, os.O_WRONLY)
|
||||||
|
try:
|
||||||
|
os.dup2(devnull_fd, stderr_fd)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
os.dup2(saved_fd, stderr_fd)
|
||||||
|
os.close(devnull_fd)
|
||||||
|
os.close(saved_fd)
|
||||||
|
|
||||||
|
|
||||||
def list_mics() -> list[tuple[str, str]]:
|
def list_mics() -> list[tuple[str, str]]:
|
||||||
@ -64,79 +68,58 @@ def list_mics() -> list[tuple[str, str]]:
|
|||||||
|
|
||||||
|
|
||||||
class LevelMeter:
|
class LevelMeter:
|
||||||
"""Stream mic audio via a system recorder and call `on_level(0..1)` ~10x/s.
|
"""Open the given input device and call `on_level(0..1)` periodically."""
|
||||||
|
|
||||||
Uses pw-record/parecord/arecord (the same recorders as the WAV recorder)
|
def __init__(self, device: str = "", on_level=None):
|
||||||
rather than a Python audio binding, so it works on PipeWire boxes where
|
self.device = device or None
|
||||||
PortAudio can't open the default input. Best-effort: ``start()`` returns
|
|
||||||
False if no recorder is available or the device can't be opened.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, device: str = "", on_level=None, recorder: str = "auto", on_chunk=None):
|
|
||||||
self.device = device or ""
|
|
||||||
self.on_level = on_level
|
self.on_level = on_level
|
||||||
self.on_chunk = on_chunk # optional: called with raw s16le PCM bytes each chunk
|
self._stream = None
|
||||||
self._recorder = recorder
|
self._lock = threading.Lock()
|
||||||
self._proc: subprocess.Popen | None = None
|
|
||||||
self._thread: threading.Thread | None = None
|
|
||||||
self._stop = threading.Event()
|
|
||||||
|
|
||||||
def _argv(self, recorder: str) -> list[str]:
|
|
||||||
argv = list(_METER_ARGV[recorder])
|
|
||||||
flag = _DEVICE_FLAG.get(recorder, [])
|
|
||||||
if self.device and flag:
|
|
||||||
argv += flag + [self.device]
|
|
||||||
return argv
|
|
||||||
|
|
||||||
def start(self) -> bool:
|
def start(self) -> bool:
|
||||||
try:
|
|
||||||
recorder = detect_recorder(self._recorder)
|
|
||||||
except RuntimeError:
|
|
||||||
return False
|
|
||||||
if recorder not in _METER_ARGV:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
self._proc = subprocess.Popen(
|
|
||||||
self._argv(recorder), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
|
|
||||||
)
|
|
||||||
except OSError:
|
|
||||||
self._proc = None
|
|
||||||
return False
|
|
||||||
self._stop.clear()
|
|
||||||
self._thread = threading.Thread(target=self._loop, daemon=True, name="LevelMeter")
|
|
||||||
self._thread.start()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _loop(self) -> None:
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import sounddevice as sd
|
||||||
|
|
||||||
proc = self._proc
|
def _cb(indata, _frames, _time, _status):
|
||||||
if proc is None or proc.stdout is None:
|
level = float(np.sqrt(np.mean(np.square(indata)))) if indata.size else 0.0
|
||||||
return
|
|
||||||
try:
|
|
||||||
while not self._stop.is_set() and proc.poll() is None:
|
|
||||||
chunk = proc.stdout.read(_CHUNK_BYTES)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
if self.on_chunk:
|
|
||||||
self.on_chunk(chunk)
|
|
||||||
samples = np.frombuffer(chunk, dtype=np.int16).astype(np.float32) / 32768.0
|
|
||||||
level = float(np.sqrt(np.mean(np.square(samples)))) if samples.size else 0.0
|
|
||||||
if self.on_level:
|
if self.on_level:
|
||||||
# Scale RMS (typically small) into a usable 0..1 range.
|
# Scale RMS (typically small) into a usable 0..1 range.
|
||||||
self.on_level(min(1.0, level * 12.0))
|
self.on_level(min(1.0, level * 12.0))
|
||||||
except Exception: # noqa: BLE001 - metering is eye-candy; never crash the app
|
|
||||||
|
try:
|
||||||
|
with _quiet_c_stderr():
|
||||||
|
self._stream = sd.InputStream(
|
||||||
|
samplerate=16000, channels=1, dtype="float32",
|
||||||
|
blocksize=1600, device=self._resolve_device(), callback=_cb,
|
||||||
|
)
|
||||||
|
self._stream.start()
|
||||||
|
return True
|
||||||
|
except Exception: # noqa: BLE001 - device may be busy/unavailable
|
||||||
|
self._stream = None
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _resolve_device(self):
|
||||||
|
# sounddevice wants an index/name it knows; pactl names rarely match, so
|
||||||
|
# fall back to the default input when the name isn't resolvable.
|
||||||
|
if not self.device:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import sounddevice as sd
|
||||||
|
|
||||||
|
for i, d in enumerate(sd.query_devices()):
|
||||||
|
if d["max_input_channels"] > 0 and self.device in d["name"]:
|
||||||
|
return i
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._stop.set()
|
with self._lock:
|
||||||
proc, self._proc = self._proc, None
|
if self._stream is not None:
|
||||||
if proc is not None and proc.poll() is None:
|
|
||||||
proc.terminate()
|
|
||||||
try:
|
try:
|
||||||
proc.wait(timeout=1.0)
|
# PortAudio/ALSA spews thread-teardown noise to fd 2 here.
|
||||||
except subprocess.TimeoutExpired:
|
with _quiet_c_stderr():
|
||||||
proc.kill()
|
self._stream.stop()
|
||||||
thread, self._thread = self._thread, None
|
self._stream.close()
|
||||||
if thread is not None:
|
finally:
|
||||||
thread.join(timeout=1.0)
|
self._stream = None
|
||||||
|
|||||||
@ -50,7 +50,6 @@ def set_enabled(enabled: bool) -> None:
|
|||||||
f"Exec={_exec_command()}\n"
|
f"Exec={_exec_command()}\n"
|
||||||
"Icon=blitztext\n"
|
"Icon=blitztext\n"
|
||||||
"Terminal=false\n"
|
"Terminal=false\n"
|
||||||
"X-GNOME-Autostart-enabled=true\n"
|
"X-GNOME-Autostart-enabled=true\n",
|
||||||
"X-GNOME-Autostart-Delay=12\n",
|
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,7 +7,6 @@ Benchmark tab to find the fastest and most accurate engine/model.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -16,33 +15,16 @@ from . import stt
|
|||||||
from .routing import normalize
|
from .routing import normalize
|
||||||
|
|
||||||
|
|
||||||
def _rss_mb() -> float:
|
|
||||||
"""Current process RSS in MB via /proc/self/status (Linux only)."""
|
|
||||||
try:
|
|
||||||
with open("/proc/self/status") as fh:
|
|
||||||
for line in fh:
|
|
||||||
if line.startswith("VmRSS:"):
|
|
||||||
return int(line.split()[1]) / 1024.0 # kB → MB
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BenchRow:
|
class BenchRow:
|
||||||
engine: str
|
engine: str
|
||||||
url: str # base URL of the engine (empty for local)
|
|
||||||
model: str
|
model: str
|
||||||
device: str # "CPU" | "CUDA" | "remote"
|
device: str # "CPU" | "GPU" | "remote"
|
||||||
best_for: str # "Short clips" | "Short / medium" | "Long / batch" | "Streaming"
|
|
||||||
languages: list[str] # ISO 639-1 codes from /v1/models, empty if unknown
|
|
||||||
ok: bool
|
ok: bool
|
||||||
seconds: float
|
seconds: float
|
||||||
wer: float
|
wer: float
|
||||||
accuracy: float # percent, max(0, 1-wer)*100
|
accuracy: float # percent, max(0, 1-wer)*100
|
||||||
text: str
|
text: str
|
||||||
ram_mb: float = 0.0 # local RSS delta in MB
|
|
||||||
srv_ram_mb: float | None = None # server /metrics RSS in MB, None = not available
|
|
||||||
error: str = ""
|
error: str = ""
|
||||||
|
|
||||||
|
|
||||||
@ -76,103 +58,32 @@ def wer(reference: str, hypothesis: str, *, case_sensitive: bool = False) -> flo
|
|||||||
return _edit_distance(ref, hyp) / len(ref)
|
return _edit_distance(ref, hyp) / len(ref)
|
||||||
|
|
||||||
|
|
||||||
def _engine_device(engine, transcriber, _cache: dict) -> str:
|
def _engine_device(engine, transcriber) -> str:
|
||||||
if engine.is_local:
|
if engine.is_local:
|
||||||
return "CUDA" if getattr(transcriber, "device", "cpu") == "cuda" else "CPU"
|
return "GPU" if getattr(transcriber, "device", "cpu") == "cuda" else "CPU"
|
||||||
url = engine.url
|
return "remote"
|
||||||
if url not in _cache:
|
|
||||||
_cache[url] = stt.detect_remote_device(url)
|
|
||||||
return _cache[url]
|
|
||||||
|
|
||||||
|
|
||||||
def _engine_best_for(engine) -> str:
|
|
||||||
if engine.type == "riva_realtime":
|
|
||||||
return "Streaming"
|
|
||||||
model = (engine.model or "").lower()
|
|
||||||
name = engine.name.lower()
|
|
||||||
if any(x in name for x in ("stream", "realtime", "real-time", "live")):
|
|
||||||
return "Streaming"
|
|
||||||
if engine.is_local:
|
|
||||||
if any(x in model for x in ("tiny", "base")):
|
|
||||||
return "Short clips"
|
|
||||||
if any(x in model for x in ("large",)):
|
|
||||||
return "Long / batch"
|
|
||||||
return "Short / medium"
|
|
||||||
# Remote endpoint
|
|
||||||
if any(x in name for x in ("large", "batch")):
|
|
||||||
return "Long / batch"
|
|
||||||
return "Short / medium"
|
|
||||||
|
|
||||||
|
|
||||||
def run(engines, wav_path: Path, reference: str, *, language: str = "",
|
def run(engines, wav_path: Path, reference: str, *, language: str = "",
|
||||||
case_sensitive: bool = True, get_local_transcriber=None, progress=None,
|
case_sensitive: bool = True, get_local_transcriber=None, progress=None) -> list[BenchRow]:
|
||||||
expand_models: bool = False) -> list[BenchRow]:
|
|
||||||
"""Benchmark each engine; calls progress(row) as each finishes.
|
"""Benchmark each engine; calls progress(row) as each finishes.
|
||||||
|
|
||||||
expand_models=True fetches available models for each remote engine and
|
|
||||||
runs one benchmark row per model instead of just the configured one.
|
|
||||||
Accuracy is case-sensitive by default so wrong capitalisation counts.
|
Accuracy is case-sensitive by default so wrong capitalisation counts.
|
||||||
"""
|
"""
|
||||||
# Build the run list, optionally expanding remote engines by their models
|
|
||||||
run_list: list = []
|
|
||||||
for e in engines:
|
|
||||||
if expand_models and not e.is_local and not e.is_streaming:
|
|
||||||
models = stt.list_models(e.url, e.api_key_env)
|
|
||||||
if len(models) > 1:
|
|
||||||
for m in models:
|
|
||||||
run_list.append(dataclasses.replace(e, model=m,
|
|
||||||
name=f"{e.name} [{m}]"))
|
|
||||||
continue
|
|
||||||
run_list.append(e)
|
|
||||||
|
|
||||||
device_cache: dict = {}
|
|
||||||
meta_cache: dict = {} # url → list[ModelMeta]
|
|
||||||
server_ram_cache: dict = {} # url → float | None (MB from /metrics)
|
|
||||||
|
|
||||||
def _get_langs(e) -> list[str]:
|
|
||||||
if e.is_local:
|
|
||||||
return []
|
|
||||||
url = e.url
|
|
||||||
if url not in meta_cache:
|
|
||||||
meta_cache[url] = stt.list_models_meta(url, e.api_key_env)
|
|
||||||
for m in meta_cache[url]:
|
|
||||||
if not e.model or m.id == e.model or m.id.endswith("/" + e.model):
|
|
||||||
return m.languages
|
|
||||||
return meta_cache[url][0].languages if meta_cache[url] else []
|
|
||||||
|
|
||||||
rows: list[BenchRow] = []
|
rows: list[BenchRow] = []
|
||||||
for e in run_list:
|
for e in engines:
|
||||||
tr = get_local_transcriber(e) if (e.is_local and get_local_transcriber) else None
|
tr = get_local_transcriber(e) if (e.is_local and get_local_transcriber) else None
|
||||||
# Snapshot server RAM before (if Prometheus metrics available)
|
|
||||||
if not e.is_local and e.url not in server_ram_cache:
|
|
||||||
server_ram_cache[e.url] = None # sentinel — probe once per URL
|
|
||||||
srv_before: float | None = None
|
|
||||||
if not e.is_local:
|
|
||||||
srv_before = stt.probe_server_ram_mb(e.url)
|
|
||||||
rss_before = _rss_mb()
|
|
||||||
res = stt.benchmark(e, wav_path, language=language, local_transcriber=tr)
|
res = stt.benchmark(e, wav_path, language=language, local_transcriber=tr)
|
||||||
rss_after = _rss_mb()
|
|
||||||
ram_delta = max(0.0, rss_after - rss_before)
|
|
||||||
srv_ram: float | None = None
|
|
||||||
if not e.is_local:
|
|
||||||
srv_after = stt.probe_server_ram_mb(e.url)
|
|
||||||
if srv_before is not None and srv_after is not None:
|
|
||||||
srv_ram = srv_after # report current RSS, not delta (server may not unload)
|
|
||||||
w = wer(reference, res.text, case_sensitive=case_sensitive) if res.ok else 1.0
|
w = wer(reference, res.text, case_sensitive=case_sensitive) if res.ok else 1.0
|
||||||
row = BenchRow(
|
row = BenchRow(
|
||||||
engine=e.name,
|
engine=e.name,
|
||||||
url=e.url,
|
|
||||||
model=e.model or ("local" if e.is_local else "(default)"),
|
model=e.model or ("local" if e.is_local else "(default)"),
|
||||||
device=_engine_device(e, tr, device_cache),
|
device=_engine_device(e, tr),
|
||||||
best_for=_engine_best_for(e),
|
|
||||||
languages=_get_langs(e),
|
|
||||||
ok=res.ok,
|
ok=res.ok,
|
||||||
seconds=res.seconds,
|
seconds=res.seconds,
|
||||||
wer=w,
|
wer=w,
|
||||||
accuracy=max(0.0, 1.0 - w) * 100.0,
|
accuracy=max(0.0, 1.0 - w) * 100.0,
|
||||||
text=res.text,
|
text=res.text,
|
||||||
ram_mb=ram_delta,
|
|
||||||
srv_ram_mb=srv_ram,
|
|
||||||
error=res.error,
|
error=res.error,
|
||||||
)
|
)
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
|
|||||||
@ -4,12 +4,12 @@ The overlay wants to sit at "the cursor where the text will land". On X11 there
|
|||||||
is no portable way to read the text caret of an arbitrary app, so we degrade
|
is no portable way to read the text caret of an arbitrary app, so we degrade
|
||||||
through a chain of decreasing precision:
|
through a chain of decreasing precision:
|
||||||
|
|
||||||
1. Mouse pointer — `xdotool getmouselocation`. Default; always available on X11;
|
1. AT-SPI caret — the real text insertion point, when the focused app exposes
|
||||||
a reliable proxy since the pointer is usually near where you're typing.
|
it over accessibility (native GTK/Qt apps do; many terminals / Electron /
|
||||||
2. AT-SPI caret — the real text insertion point (opt-in via overlay_anchor=caret).
|
web views do not). Tracked passively via the GLib main loop, so reads are
|
||||||
Only native GTK/Qt apps expose it; many terminals / Electron / web views do
|
instant and never block.
|
||||||
not. The blocking D-Bus read runs in a thread with a 300ms hard timeout so it
|
2. Mouse pointer — `xdotool getmouselocation`. Always available on X11; a good
|
||||||
can never stall the GTK main loop and freeze the session.
|
proxy since the pointer is usually near where you're typing.
|
||||||
3. Window / screen — top-centre of the target window, else screen bottom-centre.
|
3. Window / screen — top-centre of the target window, else screen bottom-centre.
|
||||||
|
|
||||||
Everything here is defensive: any failure falls through to the next tier, and
|
Everything here is defensive: any failure falls through to the next tier, and
|
||||||
@ -22,7 +22,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
@ -41,39 +40,25 @@ class Anchor:
|
|||||||
# Tier 1: AT-SPI caret tracking (best-effort, passive)
|
# Tier 1: AT-SPI caret tracking (best-effort, passive)
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
class _CaretTracker:
|
class _CaretTracker:
|
||||||
"""Remember the most recently focused text object; read its caret lazily.
|
"""Passively follow the focused editable text's caret via AT-SPI events.
|
||||||
|
|
||||||
*Why so cautious.* AT-SPI queries (``get_character_extents`` and friends) are
|
We never poll the a11y tree on the hot path (that can be slow and can block
|
||||||
**synchronous, blocking D-Bus round-trips into the target application**. The
|
on unresponsive apps). Instead we subscribe to caret-moved / focus events and
|
||||||
earlier design subscribed to the high-frequency ``object:text-caret-moved``
|
cache the last caret rectangle; `rect()` just returns the cached value if it
|
||||||
signal and ran those blocking reads from *inside* the event handler. Two ways
|
is fresh enough to still be meaningful.
|
||||||
that wedges a whole GNOME/X11 session:
|
|
||||||
|
|
||||||
• Calling a synchronous AT-SPI method from within an AT-SPI event dispatch
|
|
||||||
re-enters the a11y dispatcher and can deadlock the accessibility bus.
|
|
||||||
• ``text-caret-moved`` fires once *per character* — and delivering text is
|
|
||||||
exactly what this app does, typing via ``xdotool`` into the focused
|
|
||||||
field. So a single dictation became a storm of blocking round-trips on
|
|
||||||
the GTK main loop, congesting the a11y bus until the desktop froze.
|
|
||||||
|
|
||||||
So we now subscribe to **focus changes only** (rare, and never emitted by our
|
|
||||||
own synthetic typing), cache just the focused accessible, and do the one
|
|
||||||
blocking extents read **on demand** in :meth:`rect` — called once, when the
|
|
||||||
overlay shows, outside any event dispatch. Worst case is a slightly delayed
|
|
||||||
overlay placement, never a frozen session.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
STALE_SECONDS = 30.0 # ignore a focus older than this
|
STALE_SECONDS = 30.0 # ignore a cached caret older than this
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._ok = False
|
self._ok = False
|
||||||
self._listener = None
|
self._listener = None
|
||||||
self._focused = None # last focused accessible (read lazily)
|
self._rect: tuple[int, int, int, int] | None = None # x, y, w, h (screen)
|
||||||
self._stamp = 0.0
|
self._stamp = 0.0
|
||||||
self._Atspi = None
|
self._Atspi = None
|
||||||
|
|
||||||
def start(self) -> bool:
|
def start(self) -> bool:
|
||||||
"""Register the AT-SPI focus listener on the running GLib main loop.
|
"""Register AT-SPI listeners on the (already running) GLib main loop.
|
||||||
|
|
||||||
Safe to call when accessibility is disabled — it just returns False and
|
Safe to call when accessibility is disabled — it just returns False and
|
||||||
the anchor logic skips this tier from then on.
|
the anchor logic skips this tier from then on.
|
||||||
@ -89,10 +74,10 @@ class _CaretTracker:
|
|||||||
# init() is idempotent; returns 0/1. Connects to the a11y registry.
|
# init() is idempotent; returns 0/1. Connects to the a11y registry.
|
||||||
Atspi.init()
|
Atspi.init()
|
||||||
self._Atspi = Atspi
|
self._Atspi = Atspi
|
||||||
self._listener = Atspi.EventListener.new(self._on_focus)
|
self._listener = Atspi.EventListener.new(self._on_event)
|
||||||
# Focus changes only. Deliberately NOT "object:text-caret-moved": that
|
# Caret moves give us the live position; focus changes let us grab the
|
||||||
# firehose (one event per typed character, including our own output)
|
# caret of a freshly-focused field even before it moves.
|
||||||
# plus synchronous reads is what could freeze the session.
|
self._listener.register("object:text-caret-moved")
|
||||||
self._listener.register("object:state-changed:focused")
|
self._listener.register("object:state-changed:focused")
|
||||||
self._ok = True
|
self._ok = True
|
||||||
log("[overlay] AT-SPI caret tracking active")
|
log("[overlay] AT-SPI caret tracking active")
|
||||||
@ -105,22 +90,25 @@ class _CaretTracker:
|
|||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
try:
|
try:
|
||||||
if self._listener is not None:
|
if self._listener is not None:
|
||||||
|
self._listener.deregister("object:text-caret-moved")
|
||||||
self._listener.deregister("object:state-changed:focused")
|
self._listener.deregister("object:state-changed:focused")
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
self._listener = None
|
self._listener = None
|
||||||
self._focused = None
|
|
||||||
self._ok = False
|
self._ok = False
|
||||||
|
|
||||||
def _on_focus(self, event) -> None:
|
def _on_event(self, event) -> None:
|
||||||
# Runs on the GLib main thread (same loop GTK uses). Do the *minimum*:
|
# Runs on the GLib main thread (same loop GTK uses). Keep it cheap and
|
||||||
# stash the focused accessible and stamp it. Crucially, make NO synchronous
|
# never raise — an exception here would bubble into the a11y dispatcher.
|
||||||
# AT-SPI calls here — that would re-enter the a11y dispatcher and risk
|
|
||||||
# deadlocking the bus. The blocking extents read happens later, in rect().
|
|
||||||
try:
|
try:
|
||||||
if not event.detail1:
|
if event.type.startswith("object:state-changed:focused") and not event.detail1:
|
||||||
return # a *de*focus event — nothing to track
|
return # a *de*focus event — nothing to read
|
||||||
self._focused = event.source
|
source = event.source
|
||||||
|
if source is None:
|
||||||
|
return
|
||||||
|
rect = self._caret_rect(source)
|
||||||
|
if rect is not None:
|
||||||
|
self._rect = rect
|
||||||
self._stamp = time.time()
|
self._stamp = time.time()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
@ -154,27 +142,11 @@ class _CaretTracker:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def rect(self) -> tuple[int, int, int, int] | None:
|
def rect(self) -> tuple[int, int, int, int] | None:
|
||||||
# Called once when the overlay shows (not on the a11y hot path).
|
if not self._ok or self._rect is None:
|
||||||
# We run the blocking D-Bus extents read in a worker thread and join it
|
|
||||||
# with a hard 300ms deadline — so even a frozen or slow target app can
|
|
||||||
# never stall the GTK main loop long enough to freeze the session.
|
|
||||||
if not self._ok or self._focused is None:
|
|
||||||
return None
|
return None
|
||||||
if time.time() - self._stamp > self.STALE_SECONDS:
|
if time.time() - self._stamp > self.STALE_SECONDS:
|
||||||
return None
|
return None
|
||||||
result: list[tuple[int, int, int, int] | None] = [None]
|
return self._rect
|
||||||
focused = self._focused
|
|
||||||
|
|
||||||
def _read() -> None:
|
|
||||||
try:
|
|
||||||
result[0] = self._caret_rect(focused)
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
|
|
||||||
t = threading.Thread(target=_read, daemon=True)
|
|
||||||
t.start()
|
|
||||||
t.join(timeout=0.3)
|
|
||||||
return result[0]
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@ -25,22 +25,11 @@ class Workflow:
|
|||||||
# Optional per-workflow overrides of the [rewrite] defaults.
|
# Optional per-workflow overrides of the [rewrite] defaults.
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
temperature: float | None = None
|
temperature: float | None = None
|
||||||
# Which LLM engine to use for this preset's rewrite step.
|
|
||||||
# "" (empty) = use whichever engine is currently active in the Engines tab.
|
|
||||||
llm_engine: str = ""
|
|
||||||
# Cosmetic, used by the GUI.
|
# Cosmetic, used by the GUI.
|
||||||
description: str = ""
|
description: str = ""
|
||||||
icon: str = "⚡"
|
icon: str = "⚡"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class WakewordEngine:
|
|
||||||
"""A wyoming-openwakeword endpoint to compare in the wakeword benchmark."""
|
|
||||||
name: str = ""
|
|
||||||
uri: str = "tcp://127.0.0.1:10400"
|
|
||||||
model: str = "okay_computer"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
# general
|
# general
|
||||||
@ -53,7 +42,7 @@ class Config:
|
|||||||
language: str = "de" # whisper hint; "" = autodetect
|
language: str = "de" # whisper hint; "" = autodetect
|
||||||
# on-screen overlay (mic + live waveform + recognised-text bubble)
|
# on-screen overlay (mic + live waveform + recognised-text bubble)
|
||||||
overlay_enabled: bool = True
|
overlay_enabled: bool = True
|
||||||
overlay_anchor: str = "pointer" # pointer | caret (AT-SPI, may freeze) | corner
|
overlay_anchor: str = "caret" # caret (best-effort AT-SPI) | pointer | corner
|
||||||
# input scheme
|
# input scheme
|
||||||
input_mode: str = "modifiers" # "modifiers" (Ctrl+Win/Ctrl/Alt/Esc) | "hotkeys" (combos)
|
input_mode: str = "modifiers" # "modifiers" (Ctrl+Win/Ctrl/Alt/Esc) | "hotkeys" (combos)
|
||||||
push_to_talk: bool = False
|
push_to_talk: bool = False
|
||||||
@ -86,16 +75,6 @@ class Config:
|
|||||||
routing_hotkey: str = "<ctrl>+<alt>+<space>"
|
routing_hotkey: str = "<ctrl>+<alt>+<space>"
|
||||||
routing_default: str = "" # preset name used when no keyword matches; "" = first
|
routing_default: str = "" # preset name used when no keyword matches; "" = first
|
||||||
routing_threshold: float = 0.82
|
routing_threshold: float = 0.82
|
||||||
# Spoken abort: if one of these words is heard at the start/end of a clip, the
|
|
||||||
# dictation is discarded — never transcribed onward, routed, rewritten, or
|
|
||||||
# typed. Empty list = disabled. Mainly for accidental wakeword triggers.
|
|
||||||
cancel_keywords: list[str] = field(default_factory=lambda: ["abbrechen", "cancel"])
|
|
||||||
# Spoken send: if one of these is heard at the start/end of a clip, it is
|
|
||||||
# stripped and the rest is delivered *and submitted with Enter* — the spoken
|
|
||||||
# equivalent of "stop + paste + Enter". Empty = disabled. Because it presses
|
|
||||||
# Enter, prefer a distinctive multi-word phrase (e.g. "computer send") so a
|
|
||||||
# sentence that merely ends in "send" doesn't submit by accident.
|
|
||||||
send_keywords: list[str] = field(default_factory=list)
|
|
||||||
# speech-to-text engines (presets)
|
# speech-to-text engines (presets)
|
||||||
stt_engines: list[STTEngine] = field(default_factory=list)
|
stt_engines: list[STTEngine] = field(default_factory=list)
|
||||||
stt_active: str = ""
|
stt_active: str = ""
|
||||||
@ -104,31 +83,11 @@ class Config:
|
|||||||
llm_active: str = ""
|
llm_active: str = ""
|
||||||
# wakeword
|
# wakeword
|
||||||
wakeword_enabled: bool = False
|
wakeword_enabled: bool = False
|
||||||
wakeword_active: str = "" # name of selected WakewordEngine preset, "" = custom
|
|
||||||
wakeword_uri: str = "tcp://127.0.0.1:10400"
|
wakeword_uri: str = "tcp://127.0.0.1:10400"
|
||||||
wakeword_model: str = "okay_computer"
|
wakeword_model: str = "okay_computer"
|
||||||
wakeword_sound_detected: str = "" # WAV played when the wakeword fires (speak now)
|
wakeword_sound_detected: str = "" # WAV played when the wakeword fires (speak now)
|
||||||
wakeword_sound_done: str = "" # WAV played when the command is captured
|
wakeword_sound_done: str = "" # WAV played when the command is captured
|
||||||
wakeword_silence_seconds: float = 2.0 # auto-stop after this much trailing silence
|
wakeword_silence_seconds: float = 2.0 # auto-stop after this much trailing silence
|
||||||
wakeword_cancel_model: str = "" # wakeword model that cancels an in-progress recording
|
|
||||||
wakeword_send_model: str = "" # wakeword model that finishes + sends (Enter) a recording
|
|
||||||
setup_complete: bool = False # True once the first-run wizard has been completed
|
|
||||||
# Text-to-speech for the wakeword benchmark — its own OpenAI-compatible
|
|
||||||
# endpoint (Kokoro, XTTS, OpenAI, …): base URL incl. /v1, an optional bearer
|
|
||||||
# key env var, a model id, and the voices to cycle through.
|
|
||||||
tts_url: str = ""
|
|
||||||
tts_api_key_env: str = ""
|
|
||||||
tts_model: str = ""
|
|
||||||
tts_voices: list[str] = field(
|
|
||||||
default_factory=lambda: ["alloy", "echo", "fable", "onyx", "nova", "shimmer"])
|
|
||||||
# Wakeword engines to compare in the benchmark (each a wyoming-openwakeword
|
|
||||||
# endpoint). Empty → the benchmark falls back to the live [wakeword] above.
|
|
||||||
wakeword_engines: list[WakewordEngine] = field(default_factory=list)
|
|
||||||
# STT benchmark: last-used WAV / reference transcript paths and options
|
|
||||||
bench_wav: str = ""
|
|
||||||
bench_ref: str = ""
|
|
||||||
bench_expand_models: bool = False
|
|
||||||
bench_last: dict = field(default_factory=dict) # {engine_name: {seconds, accuracy, ok}}
|
|
||||||
# workflows
|
# workflows
|
||||||
workflows: list[Workflow] = field(default_factory=list)
|
workflows: list[Workflow] = field(default_factory=list)
|
||||||
|
|
||||||
@ -195,7 +154,6 @@ def load(path: Path = CONFIG_PATH) -> Config:
|
|||||||
q = data.get("quality", {})
|
q = data.get("quality", {})
|
||||||
snd = data.get("sounds", {})
|
snd = data.get("sounds", {})
|
||||||
ww = data.get("wakeword", {})
|
ww = data.get("wakeword", {})
|
||||||
tts = data.get("tts", {})
|
|
||||||
|
|
||||||
cfg = Config(
|
cfg = Config(
|
||||||
recorder=g.get("recorder", "auto"),
|
recorder=g.get("recorder", "auto"),
|
||||||
@ -203,11 +161,10 @@ def load(path: Path = CONFIG_PATH) -> Config:
|
|||||||
output=g.get("output", "type"),
|
output=g.get("output", "type"),
|
||||||
type_delay_ms=int(g.get("type_delay_ms", 4)),
|
type_delay_ms=int(g.get("type_delay_ms", 4)),
|
||||||
notify=bool(g.get("notify", True)),
|
notify=bool(g.get("notify", True)),
|
||||||
setup_complete=bool(g.get("setup_complete", False)),
|
|
||||||
notify_routing=bool(g.get("notify_routing", True)),
|
notify_routing=bool(g.get("notify_routing", True)),
|
||||||
language=g.get("language", "de"),
|
language=g.get("language", "de"),
|
||||||
overlay_enabled=bool(g.get("overlay_enabled", True)),
|
overlay_enabled=bool(g.get("overlay_enabled", True)),
|
||||||
overlay_anchor=g.get("overlay_anchor", "pointer"),
|
overlay_anchor=g.get("overlay_anchor", "caret"),
|
||||||
model=w.get("model", "small"),
|
model=w.get("model", "small"),
|
||||||
device=w.get("device", "auto"),
|
device=w.get("device", "auto"),
|
||||||
compute_type=w.get("compute_type", "auto"),
|
compute_type=w.get("compute_type", "auto"),
|
||||||
@ -221,8 +178,6 @@ def load(path: Path = CONFIG_PATH) -> Config:
|
|||||||
routing_hotkey=rt.get("hotkey", "<ctrl>+<alt>+<space>"),
|
routing_hotkey=rt.get("hotkey", "<ctrl>+<alt>+<space>"),
|
||||||
routing_default=rt.get("default", ""),
|
routing_default=rt.get("default", ""),
|
||||||
routing_threshold=float(rt.get("threshold", 0.82)),
|
routing_threshold=float(rt.get("threshold", 0.82)),
|
||||||
cancel_keywords=list(rt.get("cancel_keywords", ["abbrechen", "cancel"])),
|
|
||||||
send_keywords=list(rt.get("send_keywords", [])),
|
|
||||||
input_mode=inp.get("mode", "modifiers"),
|
input_mode=inp.get("mode", "modifiers"),
|
||||||
push_to_talk=bool(inp.get("push_to_talk", False)),
|
push_to_talk=bool(inp.get("push_to_talk", False)),
|
||||||
key_start=inp.get("start", "<ctrl>+<cmd>"),
|
key_start=inp.get("start", "<ctrl>+<cmd>"),
|
||||||
@ -237,22 +192,11 @@ def load(path: Path = CONFIG_PATH) -> Config:
|
|||||||
sound_before=snd.get("before", ""),
|
sound_before=snd.get("before", ""),
|
||||||
sound_after=snd.get("after", ""),
|
sound_after=snd.get("after", ""),
|
||||||
wakeword_enabled=bool(ww.get("enabled", False)),
|
wakeword_enabled=bool(ww.get("enabled", False)),
|
||||||
wakeword_active=ww.get("active", ""),
|
|
||||||
wakeword_uri=ww.get("uri", "tcp://127.0.0.1:10400"),
|
wakeword_uri=ww.get("uri", "tcp://127.0.0.1:10400"),
|
||||||
wakeword_model=ww.get("model", "okay_computer"),
|
wakeword_model=ww.get("model", "okay_computer"),
|
||||||
wakeword_sound_detected=ww.get("sound_detected", ""),
|
wakeword_sound_detected=ww.get("sound_detected", ""),
|
||||||
wakeword_sound_done=ww.get("sound_done", ""),
|
wakeword_sound_done=ww.get("sound_done", ""),
|
||||||
wakeword_silence_seconds=float(ww.get("silence_seconds", 2.0)),
|
wakeword_silence_seconds=float(ww.get("silence_seconds", 2.0)),
|
||||||
wakeword_cancel_model=ww.get("cancel_model", ""),
|
|
||||||
wakeword_send_model=ww.get("send_model", ""),
|
|
||||||
tts_url=tts.get("url", "").rstrip("/"),
|
|
||||||
tts_api_key_env=tts.get("api_key_env", ""),
|
|
||||||
tts_model=tts.get("model", ""),
|
|
||||||
tts_voices=list(tts.get("voices", ["alloy", "echo", "fable", "onyx", "nova", "shimmer"])),
|
|
||||||
bench_wav=data.get("benchmark", {}).get("wav", ""),
|
|
||||||
bench_ref=data.get("benchmark", {}).get("ref", ""),
|
|
||||||
bench_expand_models=bool(data.get("benchmark", {}).get("expand_models", False)),
|
|
||||||
bench_last=dict(data.get("benchmark", {}).get("last", {})),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for entry in data.get("workflow", []):
|
for entry in data.get("workflow", []):
|
||||||
@ -265,7 +209,6 @@ def load(path: Path = CONFIG_PATH) -> Config:
|
|||||||
keywords=list(entry.get("keywords", [])),
|
keywords=list(entry.get("keywords", [])),
|
||||||
model=entry.get("model"),
|
model=entry.get("model"),
|
||||||
temperature=entry.get("temperature"),
|
temperature=entry.get("temperature"),
|
||||||
llm_engine=entry.get("llm_engine", ""),
|
|
||||||
description=entry.get("description", ""),
|
description=entry.get("description", ""),
|
||||||
icon=entry.get("icon", "⚡"),
|
icon=entry.get("icon", "⚡"),
|
||||||
)
|
)
|
||||||
@ -282,30 +225,11 @@ def load(path: Path = CONFIG_PATH) -> Config:
|
|||||||
url=e.get("url", "").rstrip("/"),
|
url=e.get("url", "").rstrip("/"),
|
||||||
model=e.get("model", ""),
|
model=e.get("model", ""),
|
||||||
api_key_env=e.get("api_key_env", ""),
|
api_key_env=e.get("api_key_env", ""),
|
||||||
timeout=int(e.get("timeout", 30)),
|
|
||||||
)
|
)
|
||||||
for e in data.get("stt_engine", [])
|
for e in data.get("stt_engine", [])
|
||||||
] or [STTEngine("Local faster-whisper", "local", model=cfg.model)]
|
] or [STTEngine("Local faster-whisper", "local", model=cfg.model)]
|
||||||
cfg.stt_active = data.get("stt", {}).get("active", cfg.stt_engines[0].name)
|
cfg.stt_active = data.get("stt", {}).get("active", cfg.stt_engines[0].name)
|
||||||
|
|
||||||
# Wakeword engines for the benchmark (optional; benchmark falls back to the
|
|
||||||
# live [wakeword] config when none are listed).
|
|
||||||
cfg.wakeword_engines = [
|
|
||||||
WakewordEngine(
|
|
||||||
name=e.get("name", ""),
|
|
||||||
uri=e.get("uri", "tcp://127.0.0.1:10400"),
|
|
||||||
model=e.get("model", "okay_computer"),
|
|
||||||
)
|
|
||||||
for e in data.get("wakeword_engine", [])
|
|
||||||
]
|
|
||||||
# Migration: ensure at least one engine exists so the CRUD UI always has a row.
|
|
||||||
if not cfg.wakeword_engines:
|
|
||||||
cfg.wakeword_engines = [WakewordEngine(
|
|
||||||
name=cfg.wakeword_active or "Local wyoming-openwakeword",
|
|
||||||
uri=cfg.wakeword_uri,
|
|
||||||
model=cfg.wakeword_model,
|
|
||||||
)]
|
|
||||||
|
|
||||||
# LLM engines (default: synthesized from the legacy [rewrite] block).
|
# LLM engines (default: synthesized from the legacy [rewrite] block).
|
||||||
cfg.llm_engines = [
|
cfg.llm_engines = [
|
||||||
LLMEngine(
|
LLMEngine(
|
||||||
@ -337,7 +261,6 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None:
|
|||||||
"output": cfg.output,
|
"output": cfg.output,
|
||||||
"type_delay_ms": cfg.type_delay_ms,
|
"type_delay_ms": cfg.type_delay_ms,
|
||||||
"notify": cfg.notify,
|
"notify": cfg.notify,
|
||||||
"setup_complete": cfg.setup_complete,
|
|
||||||
"notify_routing": cfg.notify_routing,
|
"notify_routing": cfg.notify_routing,
|
||||||
"language": cfg.language,
|
"language": cfg.language,
|
||||||
"overlay_enabled": cfg.overlay_enabled,
|
"overlay_enabled": cfg.overlay_enabled,
|
||||||
@ -369,8 +292,6 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None:
|
|||||||
"hotkey": cfg.routing_hotkey,
|
"hotkey": cfg.routing_hotkey,
|
||||||
"default": cfg.routing_default,
|
"default": cfg.routing_default,
|
||||||
"threshold": cfg.routing_threshold,
|
"threshold": cfg.routing_threshold,
|
||||||
"cancel_keywords": cfg.cancel_keywords,
|
|
||||||
"send_keywords": cfg.send_keywords,
|
|
||||||
},
|
},
|
||||||
"quality": {
|
"quality": {
|
||||||
"min_speech_seconds": cfg.min_speech_seconds,
|
"min_speech_seconds": cfg.min_speech_seconds,
|
||||||
@ -385,37 +306,18 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None:
|
|||||||
},
|
},
|
||||||
"wakeword": {
|
"wakeword": {
|
||||||
"enabled": cfg.wakeword_enabled,
|
"enabled": cfg.wakeword_enabled,
|
||||||
"active": cfg.wakeword_active,
|
|
||||||
"uri": cfg.wakeword_uri,
|
"uri": cfg.wakeword_uri,
|
||||||
"model": cfg.wakeword_model,
|
"model": cfg.wakeword_model,
|
||||||
"sound_detected": cfg.wakeword_sound_detected,
|
"sound_detected": cfg.wakeword_sound_detected,
|
||||||
"sound_done": cfg.wakeword_sound_done,
|
"sound_done": cfg.wakeword_sound_done,
|
||||||
"silence_seconds": cfg.wakeword_silence_seconds,
|
"silence_seconds": cfg.wakeword_silence_seconds,
|
||||||
"cancel_model": cfg.wakeword_cancel_model,
|
|
||||||
"send_model": cfg.wakeword_send_model,
|
|
||||||
},
|
},
|
||||||
"tts": {
|
|
||||||
"url": cfg.tts_url,
|
|
||||||
"api_key_env": cfg.tts_api_key_env,
|
|
||||||
"model": cfg.tts_model,
|
|
||||||
"voices": cfg.tts_voices,
|
|
||||||
},
|
|
||||||
"benchmark": {
|
|
||||||
"wav": cfg.bench_wav,
|
|
||||||
"ref": cfg.bench_ref,
|
|
||||||
"expand_models": cfg.bench_expand_models,
|
|
||||||
"last": cfg.bench_last,
|
|
||||||
},
|
|
||||||
"wakeword_engine": [
|
|
||||||
{"name": e.name, "uri": e.uri, "model": e.model} for e in cfg.wakeword_engines
|
|
||||||
],
|
|
||||||
"stt": {"active": cfg.stt_active},
|
"stt": {"active": cfg.stt_active},
|
||||||
"stt_engine": [
|
"stt_engine": [
|
||||||
{k: v for k, v in {
|
{k: v for k, v in {
|
||||||
"name": e.name, "type": e.type, "url": e.url,
|
"name": e.name, "type": e.type, "url": e.url,
|
||||||
"model": e.model, "api_key_env": e.api_key_env,
|
"model": e.model, "api_key_env": e.api_key_env,
|
||||||
"timeout": e.timeout if e.timeout != 30 else None,
|
}.items() if v or k in ("name", "type")}
|
||||||
}.items() if v is not None and (v or k in ("name", "type"))}
|
|
||||||
for e in cfg.stt_engines
|
for e in cfg.stt_engines
|
||||||
],
|
],
|
||||||
"llm": {"active": cfg.llm_active},
|
"llm": {"active": cfg.llm_active},
|
||||||
@ -436,8 +338,6 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None:
|
|||||||
entry["model"] = wf.model
|
entry["model"] = wf.model
|
||||||
if wf.temperature is not None:
|
if wf.temperature is not None:
|
||||||
entry["temperature"] = wf.temperature
|
entry["temperature"] = wf.temperature
|
||||||
if wf.llm_engine:
|
|
||||||
entry["llm_engine"] = wf.llm_engine
|
|
||||||
if wf.description:
|
if wf.description:
|
||||||
entry["description"] = wf.description
|
entry["description"] = wf.description
|
||||||
if wf.icon and wf.icon != "⚡":
|
if wf.icon and wf.icon != "⚡":
|
||||||
@ -470,7 +370,7 @@ notify = true # desktop notifications for each phase
|
|||||||
notify_routing = true # announce which preset/keyword a voice command matched (shown even hands-free)
|
notify_routing = true # announce which preset/keyword a voice command matched (shown even hands-free)
|
||||||
language = "de" # Whisper language hint; "" = autodetect
|
language = "de" # Whisper language hint; "" = autodetect
|
||||||
overlay_enabled = true # on-screen mic + live waveform + recognised-text bubble at the cursor
|
overlay_enabled = true # on-screen mic + live waveform + recognised-text bubble at the cursor
|
||||||
overlay_anchor = "pointer" # pointer (near the mouse — safe) | caret (AT-SPI, may freeze) | corner
|
overlay_anchor = "caret" # caret (best-effort, follows the text cursor) | pointer | corner
|
||||||
|
|
||||||
[input]
|
[input]
|
||||||
# How you start/stop dictation.
|
# How you start/stop dictation.
|
||||||
@ -529,16 +429,6 @@ enabled = true
|
|||||||
hotkey = "<ctrl>+<alt>+<space>"
|
hotkey = "<ctrl>+<alt>+<space>"
|
||||||
default = "Transcribe" # preset used when no keyword is recognised
|
default = "Transcribe" # preset used when no keyword is recognised
|
||||||
threshold = 0.82 # 0..1 fuzzy-match strictness (higher = stricter)
|
threshold = 0.82 # 0..1 fuzzy-match strictness (higher = stricter)
|
||||||
# Say one of these at the start or end of a clip to DISCARD it — nothing is
|
|
||||||
# routed, rewritten, or typed. Handy when a wakeword fires by accident. Pick
|
|
||||||
# words you won't naturally end a real dictation with. Empty list = off.
|
|
||||||
cancel_keywords = ["abbrechen", "cancel"]
|
|
||||||
# Say one of these at the start or end of a clip to SEND it: the word is stripped
|
|
||||||
# and the rest is delivered AND submitted with Enter (spoken "stop+paste+Enter").
|
|
||||||
# Because it presses Enter, use a distinctive multi-word phrase (e.g. your
|
|
||||||
# wakeword + "send") so a sentence that just ends in "send" won't submit. Off by
|
|
||||||
# default; empty list = off.
|
|
||||||
send_keywords = []
|
|
||||||
|
|
||||||
[wakeword]
|
[wakeword]
|
||||||
# Hands-free dictation using an external wyoming-openwakeword server.
|
# Hands-free dictation using an external wyoming-openwakeword server.
|
||||||
@ -555,30 +445,6 @@ sound_done = ""
|
|||||||
# Auto-stop the recording this many seconds after you stop speaking (silence).
|
# Auto-stop the recording this many seconds after you stop speaking (silence).
|
||||||
silence_seconds = 2.0
|
silence_seconds = 2.0
|
||||||
|
|
||||||
[tts]
|
|
||||||
# Text-to-speech for the WAKEWORD BENCHMARK only (Settings → Benchmark). Point it
|
|
||||||
# at any OpenAI-compatible TTS server's /audio/speech (Kokoro-FastAPI, XTTS-v2,
|
|
||||||
# OpenAI, …). url = base incl. /v1; api_key_env = env var holding a bearer key
|
|
||||||
# (leave empty for no-auth local servers); model = the TTS model id; voices = the
|
|
||||||
# names your endpoint serves. Leave url/model empty to disable the benchmark.
|
|
||||||
url = ""
|
|
||||||
api_key_env = ""
|
|
||||||
model = ""
|
|
||||||
voices = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]
|
|
||||||
|
|
||||||
# Wakeword engines to COMPARE in the benchmark — add one block per
|
|
||||||
# wyoming-openwakeword endpoint/model you want side by side. Leave this out to
|
|
||||||
# just benchmark the live [wakeword] engine above.
|
|
||||||
# [[wakeword_engine]]
|
|
||||||
# name = "openWakeWord · computer"
|
|
||||||
# uri = "tcp://127.0.0.1:10400"
|
|
||||||
# model = "computer"
|
|
||||||
#
|
|
||||||
# [[wakeword_engine]]
|
|
||||||
# name = "microWakeWord · hey_jarvis"
|
|
||||||
# uri = "tcp://127.0.0.1:10500"
|
|
||||||
# model = "hey_jarvis"
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# Speech-to-text engines (presets). The active one is used for transcription.
|
# Speech-to-text engines (presets). The active one is used for transcription.
|
||||||
# type = "local" -> in-process faster-whisper (uses [whisper] above)
|
# type = "local" -> in-process faster-whisper (uses [whisper] above)
|
||||||
|
|||||||
@ -7,10 +7,8 @@ routing: one hotkey records, then the spoken keyword selects the preset.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import signal
|
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from . import llm, quality, stt
|
from . import llm, quality, stt
|
||||||
@ -21,7 +19,7 @@ from .notify import notify
|
|||||||
from .paste import active_window_id, deliver
|
from .paste import active_window_id, deliver
|
||||||
from .streaming import RivaRealtimeStreamer
|
from .streaming import RivaRealtimeStreamer
|
||||||
from .recorder import Recording, detect_recorder
|
from .recorder import Recording, detect_recorder
|
||||||
from .routing import is_cancel, match_send, route
|
from .routing import route
|
||||||
from .transcribe import Transcriber
|
from .transcribe import Transcriber
|
||||||
|
|
||||||
# status_cb(state, workflow_name, message)
|
# status_cb(state, workflow_name, message)
|
||||||
@ -34,126 +32,22 @@ StatusCallback = Callable[[str, str | None, str], None]
|
|||||||
# stop speaking.
|
# stop speaking.
|
||||||
_VAD_COUNTDOWN_GRACE = 0.35
|
_VAD_COUNTDOWN_GRACE = 0.35
|
||||||
|
|
||||||
# Cancel-watcher: accumulate this much audio before the first check, then
|
|
||||||
# re-check every time this many new bytes arrive. 3200 bytes = 100 ms at
|
|
||||||
# 16 kHz s16le mono. 0.8 s min avoids false positives on the very first chunk;
|
|
||||||
# 0.6 s poll keeps latency low without hammering the transcriber.
|
|
||||||
_CANCEL_MIN_BYTES = int(0.8 * 16000 * 2) # 25600
|
|
||||||
_CANCEL_POLL_BYTES = int(0.6 * 16000 * 2) # 19200
|
|
||||||
|
|
||||||
|
|
||||||
def _pcm_to_wav(path: str, pcm: bytes) -> None:
|
|
||||||
"""Write raw s16le 16 kHz mono PCM bytes as a minimal RIFF WAV."""
|
|
||||||
import struct
|
|
||||||
data_len = len(pcm)
|
|
||||||
with open(path, "wb") as f:
|
|
||||||
f.write(b"RIFF")
|
|
||||||
f.write(struct.pack("<I", 36 + data_len))
|
|
||||||
f.write(b"WAVE")
|
|
||||||
f.write(b"fmt ")
|
|
||||||
f.write(struct.pack("<IHHIIHH", 16, 1, 1, 16000, 32000, 2, 16))
|
|
||||||
f.write(b"data")
|
|
||||||
f.write(struct.pack("<I", data_len))
|
|
||||||
f.write(pcm)
|
|
||||||
|
|
||||||
|
|
||||||
class _CancelWatcher:
|
|
||||||
"""Listens to in-progress VAD audio and triggers cancel if a keyword is heard.
|
|
||||||
|
|
||||||
PCM chunks (raw s16le 16 kHz mono) are fed via :meth:`feed` from the VAD
|
|
||||||
level-meter thread. Every _CANCEL_POLL_BYTES of *new* audio (after an
|
|
||||||
initial _CANCEL_MIN_BYTES warm-up), a fast beam_size=1 transcription of the
|
|
||||||
accumulated buffer runs in a background thread. If a cancel keyword is
|
|
||||||
found, the supplied ``on_cancel`` callback fires once and the watcher stops.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, transcriber, cancel_keywords, language, threshold, on_cancel):
|
|
||||||
self._transcriber = transcriber
|
|
||||||
self._keywords = cancel_keywords
|
|
||||||
self._language = language
|
|
||||||
self._threshold = threshold
|
|
||||||
self._on_cancel = on_cancel
|
|
||||||
self._buf = bytearray()
|
|
||||||
self._new_bytes = 0
|
|
||||||
self._active = True
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._checking = False # prevents overlapping check threads
|
|
||||||
|
|
||||||
def feed(self, chunk: bytes) -> None:
|
|
||||||
if not self._active:
|
|
||||||
return
|
|
||||||
with self._lock:
|
|
||||||
if not self._active:
|
|
||||||
return
|
|
||||||
self._buf.extend(chunk)
|
|
||||||
self._new_bytes += len(chunk)
|
|
||||||
ready = (len(self._buf) >= _CANCEL_MIN_BYTES
|
|
||||||
and self._new_bytes >= _CANCEL_POLL_BYTES
|
|
||||||
and not self._checking)
|
|
||||||
if ready:
|
|
||||||
self._new_bytes = 0
|
|
||||||
self._checking = True
|
|
||||||
snapshot = bytes(self._buf)
|
|
||||||
if ready:
|
|
||||||
threading.Thread(target=self._check, args=(snapshot,), daemon=True,
|
|
||||||
name="CancelWatcher").start()
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._active = False
|
|
||||||
|
|
||||||
def _check(self, audio: bytes) -> None:
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
fd, tmp = tempfile.mkstemp(prefix="bt-cw-", suffix=".wav")
|
|
||||||
try:
|
|
||||||
os.close(fd)
|
|
||||||
_pcm_to_wav(tmp, audio)
|
|
||||||
text = self._transcriber.transcribe(
|
|
||||||
Path(tmp), language=self._language, beam_size=1)
|
|
||||||
with self._lock:
|
|
||||||
if not self._active:
|
|
||||||
return
|
|
||||||
kw = is_cancel(text, self._keywords, threshold=self._threshold)
|
|
||||||
if kw:
|
|
||||||
with self._lock:
|
|
||||||
if not self._active:
|
|
||||||
return
|
|
||||||
self._active = False
|
|
||||||
log(f'[cancel-watcher] “{kw}” heard live — cancelling immediately.')
|
|
||||||
self._on_cancel()
|
|
||||||
except Exception: # noqa: BLE001 — watcher must never crash the daemon
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
Path(tmp).unlink(missing_ok=True)
|
|
||||||
with self._lock:
|
|
||||||
self._checking = False
|
|
||||||
|
|
||||||
|
|
||||||
class Daemon:
|
class Daemon:
|
||||||
def __init__(self, cfg: Config, status_cb: StatusCallback | None = None,
|
def __init__(self, cfg: Config, status_cb: StatusCallback | None = None,
|
||||||
level_cb: Callable[[float], None] | None = None,
|
level_cb: Callable[[float], None] | None = None,
|
||||||
text_cb: Callable[[str], None] | None = None,
|
text_cb: Callable[[str], None] | None = None,
|
||||||
countdown_cb: Callable[[float | None, float], None] | None = None,
|
countdown_cb: Callable[[float | None, float], None] | None = None):
|
||||||
routing_cb: Callable[[str, str, str | None], None] | None = None):
|
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
self.status_cb = status_cb
|
self.status_cb = status_cb
|
||||||
# Optional UI feedback hooks for the on-screen overlay. The daemon stays
|
# Optional UI feedback hooks for the on-screen overlay. The daemon stays
|
||||||
# UI-agnostic: these are no-ops in headless mode. level_cb gets the live
|
# UI-agnostic: these are no-ops in headless mode. level_cb gets the live
|
||||||
# mic level (0..1); text_cb gets the running transcript while streaming
|
# mic level (0..1); text_cb gets the running transcript while streaming;
|
||||||
# (and the live LLM rewrite); countdown_cb(seconds_left, window) drives the
|
# countdown_cb(seconds_left, window) drives the silence auto-stop ring
|
||||||
# silence auto-stop ring (seconds_left=None while you're speaking, so the
|
# (seconds_left=None while you're speaking, so the ring clears).
|
||||||
# ring clears); routing_cb(icon, preset_name, keyword) fires when voice
|
|
||||||
# routing picks a preset, so the overlay can show it.
|
|
||||||
self.level_cb = level_cb
|
self.level_cb = level_cb
|
||||||
self.text_cb = text_cb
|
self.text_cb = text_cb
|
||||||
self.countdown_cb = countdown_cb
|
self.countdown_cb = countdown_cb
|
||||||
self.routing_cb = routing_cb
|
|
||||||
# An overlay consumes routing_cb, and it narrates every phase on-screen, so
|
|
||||||
# the redundant desktop notifications are fused into it (only errors still
|
|
||||||
# pop a bubble). Headless / overlay-off keeps the notifications.
|
|
||||||
self._overlay = routing_cb is not None
|
|
||||||
self._ov_meter = None
|
self._ov_meter = None
|
||||||
self._ov_text_final = ""
|
self._ov_text_final = ""
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
@ -163,7 +57,6 @@ class Daemon:
|
|||||||
self._active_workflow: Workflow | None = None
|
self._active_workflow: Workflow | None = None
|
||||||
self._target_window: str | None = None
|
self._target_window: str | None = None
|
||||||
self._busy = False
|
self._busy = False
|
||||||
self._abort_event = threading.Event()
|
|
||||||
self._prepared = False
|
self._prepared = False
|
||||||
self._listener = None
|
self._listener = None
|
||||||
# Synthetic preset used by the voice-routing hotkey.
|
# Synthetic preset used by the voice-routing hotkey.
|
||||||
@ -206,20 +99,14 @@ class Daemon:
|
|||||||
notify(title, body, urgency=urgency, enabled=self.cfg.notify)
|
notify(title, body, urgency=urgency, enabled=self.cfg.notify)
|
||||||
|
|
||||||
def _dnotify(self, title: str, body: str = "", urgency: str = "normal") -> None:
|
def _dnotify(self, title: str, body: str = "", urgency: str = "normal") -> None:
|
||||||
"""Per-dictation notification — suppressed for hands-free (wakeword)
|
"""Per-dictation notification — suppressed for hands-free (wakeword) sessions."""
|
||||||
sessions, and (except for errors) when an overlay is narrating on-screen."""
|
if not self._session_silent:
|
||||||
if self._session_silent:
|
|
||||||
return
|
|
||||||
if self._overlay and urgency != "critical":
|
|
||||||
return # fused into the on-screen overlay instead of a desktop bubble
|
|
||||||
self._notify(title, body, urgency=urgency)
|
self._notify(title, body, urgency=urgency)
|
||||||
|
|
||||||
def _rnotify(self, title: str, body: str = "", urgency: str = "normal") -> None:
|
def _rnotify(self, title: str, body: str = "", urgency: str = "normal") -> None:
|
||||||
"""Routing feedback — which preset/keyword a voice command matched. When an
|
"""Routing feedback — which preset/keyword a voice command matched. Shown
|
||||||
overlay is present this is shown there (via routing_cb) instead of a
|
even for hands-free sessions (it has its own toggle) so you can always see
|
||||||
notification; otherwise it pops a bubble. Only fires on a real match."""
|
what you triggered. Only fires on a real match, so it never spams silence."""
|
||||||
if self._overlay:
|
|
||||||
return # shown on the overlay banner instead
|
|
||||||
notify(title, body, urgency=urgency, enabled=self.cfg.notify_routing)
|
notify(title, body, urgency=urgency, enabled=self.cfg.notify_routing)
|
||||||
|
|
||||||
def _emit(self, state: str, workflow: str | None = None, message: str = "") -> None:
|
def _emit(self, state: str, workflow: str | None = None, message: str = "") -> None:
|
||||||
@ -250,30 +137,9 @@ class Daemon:
|
|||||||
log(f"Using remote STT '{engine.name}' — no local model to load")
|
log(f"Using remote STT '{engine.name}' — no local model to load")
|
||||||
self._prepared = True
|
self._prepared = True
|
||||||
self._init_wakeword()
|
self._init_wakeword()
|
||||||
self._install_freeze_diagnostic()
|
|
||||||
log("Ready.")
|
log("Ready.")
|
||||||
self._emit("idle", None, "Ready")
|
self._emit("idle", None, "Ready")
|
||||||
|
|
||||||
def _install_freeze_diagnostic(self) -> None:
|
|
||||||
"""Register SIGQUIT (Ctrl+\\ or kill -QUIT) to dump all thread stacks.
|
|
||||||
|
|
||||||
When the system appears frozen, run:
|
|
||||||
kill -QUIT $(pgrep -f blitztext)
|
|
||||||
and the full thread dump appears in the Blitztext log window.
|
|
||||||
"""
|
|
||||||
def _dump(_sig, _frame):
|
|
||||||
lines = ["\n=== FREEZE DIAGNOSTIC — all thread stacks ==="]
|
|
||||||
for tid, frame in sys._current_frames().items():
|
|
||||||
name = next((t.name for t in threading.enumerate() if t.ident == tid), str(tid))
|
|
||||||
lines.append(f"\n-- Thread: {name} (id={tid}) --")
|
|
||||||
lines.extend(traceback.format_stack(frame))
|
|
||||||
lines.append("=== END FREEZE DIAGNOSTIC ===")
|
|
||||||
log("\n".join(lines))
|
|
||||||
try:
|
|
||||||
signal.signal(signal.SIGQUIT, _dump)
|
|
||||||
except (OSError, ValueError):
|
|
||||||
pass # not available on all platforms
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ready(self) -> bool:
|
def ready(self) -> bool:
|
||||||
return getattr(self, "_prepared", False)
|
return getattr(self, "_prepared", False)
|
||||||
@ -316,70 +182,13 @@ class Daemon:
|
|||||||
elif self.countdown_cb:
|
elif self.countdown_cb:
|
||||||
self.countdown_cb(None, silence)
|
self.countdown_cb(None, silence)
|
||||||
|
|
||||||
# Wakeword-based action listener: if cancel/send wakeword models are
|
self._vad_meter = audio.LevelMeter(self.cfg.mic, on_level=on_level)
|
||||||
# configured, open a second Wyoming connection during recording so a
|
self._vad_meter.start()
|
||||||
# dedicated wakeword ("stop", "send it") fires instantly — no Whisper pass.
|
|
||||||
self._action_listener = None
|
|
||||||
if self.cfg.wakeword_enabled and (
|
|
||||||
self.cfg.wakeword_cancel_model or self.cfg.wakeword_send_model):
|
|
||||||
from .wakeword import WakewordActionListener
|
|
||||||
cbs: dict = {}
|
|
||||||
if self.cfg.wakeword_cancel_model:
|
|
||||||
cbs[self.cfg.wakeword_cancel_model] = lambda: GLib.idle_add(
|
|
||||||
self.cancel_dictation)
|
|
||||||
if self.cfg.wakeword_send_model:
|
|
||||||
cbs[self.cfg.wakeword_send_model] = lambda: GLib.idle_add(
|
|
||||||
lambda: self.finish_dictation(send_enter=True))
|
|
||||||
self._action_listener = WakewordActionListener(
|
|
||||||
uri=self.cfg.wakeword_uri, model_callbacks=cbs, mic=self.cfg.mic)
|
|
||||||
self._action_listener.start()
|
|
||||||
|
|
||||||
# Whisper-based cancel watcher: fallback when no cancel wakeword model is
|
|
||||||
# set, or as belt-and-suspenders for the spoken cancel keyword list.
|
|
||||||
on_chunk = None
|
|
||||||
use_whisper_watcher = (self.cfg.cancel_keywords
|
|
||||||
and getattr(self, "transcriber", None) is not None
|
|
||||||
and not self.cfg.wakeword_cancel_model)
|
|
||||||
if use_whisper_watcher:
|
|
||||||
self._cancel_watcher = _CancelWatcher(
|
|
||||||
self.transcriber,
|
|
||||||
self.cfg.cancel_keywords,
|
|
||||||
self.cfg.language,
|
|
||||||
self.cfg.routing_threshold,
|
|
||||||
on_cancel=lambda: GLib.idle_add(self.cancel_dictation),
|
|
||||||
)
|
|
||||||
on_chunk = self._cancel_watcher.feed
|
|
||||||
else:
|
|
||||||
self._cancel_watcher = None
|
|
||||||
|
|
||||||
self._vad_meter = audio.LevelMeter(self.cfg.mic, on_level=on_level,
|
|
||||||
recorder=self.recorder_name, on_chunk=on_chunk)
|
|
||||||
ok = self._vad_meter.start()
|
|
||||||
|
|
||||||
# Safety net: if the LevelMeter fails to open the mic (e.g. device busy
|
|
||||||
# because the wakeword listener already holds a pw-record stream), the
|
|
||||||
# on_level callback never fires and dictation hangs forever. Add a hard
|
|
||||||
# 30-second timeout so the session always terminates.
|
|
||||||
_MAX_WAKEWORD_SECONDS = 30
|
|
||||||
if not ok:
|
|
||||||
log("[vad] LevelMeter failed to start — scheduling 30s hard timeout")
|
|
||||||
GLib.timeout_add(_MAX_WAKEWORD_SECONDS * 1000,
|
|
||||||
lambda: self.finish_dictation(send_enter=False) or False)
|
|
||||||
else:
|
|
||||||
# Even when the meter works, cap wakeword sessions at 60s.
|
|
||||||
GLib.timeout_add(60_000,
|
|
||||||
lambda: self.is_recording and self.finish_dictation(send_enter=False) or False)
|
|
||||||
|
|
||||||
def _vad_stop(self) -> None:
|
def _vad_stop(self) -> None:
|
||||||
if getattr(self, '_vad_meter', None) is not None:
|
if getattr(self, '_vad_meter', None) is not None:
|
||||||
self._vad_meter.stop()
|
self._vad_meter.stop()
|
||||||
self._vad_meter = None
|
self._vad_meter = None
|
||||||
if getattr(self, "_cancel_watcher", None) is not None:
|
|
||||||
self._cancel_watcher.stop()
|
|
||||||
self._cancel_watcher = None
|
|
||||||
if getattr(self, "_action_listener", None) is not None:
|
|
||||||
self._action_listener.stop()
|
|
||||||
self._action_listener = None
|
|
||||||
|
|
||||||
def _ov_meter_start(self) -> None:
|
def _ov_meter_start(self) -> None:
|
||||||
"""A level meter purely to drive the overlay waveform in streaming mode.
|
"""A level meter purely to drive the overlay waveform in streaming mode.
|
||||||
@ -390,7 +199,7 @@ class Daemon:
|
|||||||
if not self.level_cb:
|
if not self.level_cb:
|
||||||
return
|
return
|
||||||
from . import audio
|
from . import audio
|
||||||
self._ov_meter = audio.LevelMeter(self.cfg.mic, on_level=self.level_cb, recorder=self.recorder_name)
|
self._ov_meter = audio.LevelMeter(self.cfg.mic, on_level=self.level_cb)
|
||||||
self._ov_meter.start()
|
self._ov_meter.start()
|
||||||
|
|
||||||
def _ov_meter_stop(self) -> None:
|
def _ov_meter_stop(self) -> None:
|
||||||
@ -521,19 +330,13 @@ class Daemon:
|
|||||||
self._active_workflow = None
|
self._active_workflow = None
|
||||||
self._stream_segment_text = ""
|
self._stream_segment_text = ""
|
||||||
rec = None
|
rec = None
|
||||||
busy = False
|
else:
|
||||||
elif self._recording is not None:
|
|
||||||
streamer = None
|
streamer = None
|
||||||
|
if self._recording is None:
|
||||||
|
return
|
||||||
rec = self._recording
|
rec = self._recording
|
||||||
self._recording = None
|
self._recording = None
|
||||||
self._active_workflow = None
|
self._active_workflow = None
|
||||||
busy = False
|
|
||||||
elif self._busy:
|
|
||||||
streamer = None
|
|
||||||
rec = None
|
|
||||||
busy = True
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
if streamer is not None:
|
if streamer is not None:
|
||||||
streamer.stop()
|
streamer.stop()
|
||||||
self._ov_meter_stop()
|
self._ov_meter_stop()
|
||||||
@ -541,11 +344,6 @@ class Daemon:
|
|||||||
self._notify("Cancelled", "Streaming stopped.", "low")
|
self._notify("Cancelled", "Streaming stopped.", "low")
|
||||||
self._play_sound("device-removed")
|
self._play_sound("device-removed")
|
||||||
return
|
return
|
||||||
if busy:
|
|
||||||
self._abort_event.set()
|
|
||||||
self._emit("idle", None, "Cancelled")
|
|
||||||
self._play_sound("device-removed")
|
|
||||||
return
|
|
||||||
rec.discard()
|
rec.discard()
|
||||||
self._emit("idle", None, "Cancelled")
|
self._emit("idle", None, "Cancelled")
|
||||||
self._notify("Cancelled", "Recording discarded.", "low")
|
self._notify("Cancelled", "Recording discarded.", "low")
|
||||||
@ -620,7 +418,6 @@ class Daemon:
|
|||||||
|
|
||||||
# -- worker ---------------------------------------------------------------
|
# -- worker ---------------------------------------------------------------
|
||||||
def _process(self, audio_path, workflow: Workflow, window_id, send_enter: bool = False) -> None:
|
def _process(self, audio_path, workflow: Workflow, window_id, send_enter: bool = False) -> None:
|
||||||
self._abort_event.clear()
|
|
||||||
label = workflow.name
|
label = workflow.name
|
||||||
try:
|
try:
|
||||||
# Quality gate: drop silent / too-short clips before we even transcribe.
|
# Quality gate: drop silent / too-short clips before we even transcribe.
|
||||||
@ -643,37 +440,13 @@ class Daemon:
|
|||||||
local_transcriber=self.transcriber,
|
local_transcriber=self.transcriber,
|
||||||
timeout=self.cfg.timeout,
|
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.clean(text, strip_trailing_punctuation=self.cfg.strip_trailing_punctuation)
|
||||||
text = quality.expand_spoken_punctuation(text)
|
|
||||||
if not text or (self.cfg.reject_hallucinations and quality.is_hallucination(text, duration)):
|
if not text or (self.cfg.reject_hallucinations and quality.is_hallucination(text, duration)):
|
||||||
self._emit("idle", label, "No speech detected")
|
self._emit("idle", label, "No speech detected")
|
||||||
log("Nothing heard — no speech detected.")
|
log("Nothing heard — no speech detected.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Spoken abort: a configured cancel word heard at an edge discards the
|
|
||||||
# whole clip — nothing is routed, rewritten, or typed. This is the
|
|
||||||
# rescue for an accidentally triggered (e.g. wakeword) dictation.
|
|
||||||
cancel_kw = is_cancel(text, self.cfg.cancel_keywords, threshold=self.cfg.routing_threshold)
|
|
||||||
if cancel_kw:
|
|
||||||
log(f"✗ Discarded by voice keyword “{cancel_kw}”.")
|
|
||||||
if self.text_cb:
|
|
||||||
self.text_cb("✗ Abgebrochen")
|
|
||||||
self._emit("idle", label, "Cancelled")
|
|
||||||
self._dnotify("Abgebrochen", f"„{cancel_kw}“ gehört — verworfen.", "low")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Spoken send: a configured word at an edge ("computer send") is
|
|
||||||
# stripped, and the rest is delivered AND submitted with Enter — the
|
|
||||||
# spoken equivalent of stop+paste+Enter. Mainly for hands-free use.
|
|
||||||
send_kw, text = match_send(text, self.cfg.send_keywords, threshold=self.cfg.routing_threshold)
|
|
||||||
if send_kw:
|
|
||||||
send_enter = True
|
|
||||||
log(f"⏎ Send keyword “{send_kw}” — delivering and pressing Enter.")
|
|
||||||
|
|
||||||
# Voice routing: pick the preset from a spoken keyword, strip it.
|
# Voice routing: pick the preset from a spoken keyword, strip it.
|
||||||
if workflow.mode == "route":
|
if workflow.mode == "route":
|
||||||
res = route(text, self.cfg.workflows, threshold=self.cfg.routing_threshold)
|
res = route(text, self.cfg.workflows, threshold=self.cfg.routing_threshold)
|
||||||
@ -683,13 +456,6 @@ class Daemon:
|
|||||||
icon = (getattr(target, "icon", "") or "🎙") if target else "🎙"
|
icon = (getattr(target, "icon", "") or "🎙") if target else "🎙"
|
||||||
via = f"“{res.keyword}”" if res.keyword else "no keyword → default"
|
via = f"“{res.keyword}”" if res.keyword else "no keyword → default"
|
||||||
self._emit("busy", label, f"→ {label} ({via})")
|
self._emit("busy", label, f"→ {label} ({via})")
|
||||||
# Fuse the match onto the overlay (icon + preset + keyword); falls
|
|
||||||
# back to a desktop notification only when there's no overlay.
|
|
||||||
if self.routing_cb:
|
|
||||||
try:
|
|
||||||
self.routing_cb(icon, label, res.keyword)
|
|
||||||
except Exception: # noqa: BLE001 - UI must not break the engine
|
|
||||||
pass
|
|
||||||
self._rnotify(f"{icon} {label}", f"matched: {via}")
|
self._rnotify(f"{icon} {label}", f"matched: {via}")
|
||||||
log(f"→ routed to {label} (matched: {via})")
|
log(f"→ routed to {label} (matched: {via})")
|
||||||
else:
|
else:
|
||||||
@ -702,98 +468,19 @@ class Daemon:
|
|||||||
return
|
return
|
||||||
self._emit("busy", label, "Rewriting…")
|
self._emit("busy", label, "Rewriting…")
|
||||||
self._dnotify(f"⌛ {label}", "Rewriting…")
|
self._dnotify(f"⌛ {label}", "Rewriting…")
|
||||||
# Show the transcribed text immediately so the user sees what was heard.
|
|
||||||
if self.text_cb:
|
|
||||||
self.text_cb(f"📝 {text}")
|
|
||||||
# Stream the rewrite into the overlay so you watch the model write
|
|
||||||
# (the bubble updates token-by-token). The delivered text is still
|
|
||||||
# the complete result, typed once the rewrite finishes.
|
|
||||||
on_token = None
|
|
||||||
if self.text_cb:
|
|
||||||
# Use a deque to stream the last ~400 chars to the overlay so
|
|
||||||
# Pango never has to lay out a 15 000-char code block on each
|
|
||||||
# token, and "".join() stays O(window) not O(total).
|
|
||||||
from collections import deque
|
|
||||||
_window: deque[str] = deque()
|
|
||||||
_window_len: list[int] = [0]
|
|
||||||
_OVERLAY_CHARS = 400
|
|
||||||
|
|
||||||
_thinking_frames = ["⏳ Thinking.", "⏳ Thinking..", "⏳ Thinking...", "⏳ Thinking"]
|
|
||||||
_thinking_state: list[int] = [0]
|
|
||||||
_first_token: list[bool] = [False]
|
|
||||||
|
|
||||||
def _pulse_thinking(_s=_thinking_state, _f=_first_token) -> bool:
|
|
||||||
if _f[0]:
|
|
||||||
return False
|
|
||||||
self.text_cb(_thinking_frames[_s[0] % len(_thinking_frames)])
|
|
||||||
_s[0] += 1
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
from gi.repository import GLib as _GLib
|
|
||||||
# idle_add ensures timeout_add runs on the GTK main thread —
|
|
||||||
# calling timeout_add from a background thread is not safe.
|
|
||||||
_GLib.idle_add(lambda: _GLib.timeout_add(400, _pulse_thinking) and False)
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
|
|
||||||
def on_token(delta: str,
|
|
||||||
_w=_window, _wl=_window_len, _f=_first_token) -> None:
|
|
||||||
_f[0] = True
|
|
||||||
_w.append(delta)
|
|
||||||
_wl[0] += len(delta)
|
|
||||||
# Trim old tokens from the front to stay within the window.
|
|
||||||
while _wl[0] > _OVERLAY_CHARS and len(_w) > 1:
|
|
||||||
_wl[0] -= len(_w[0])
|
|
||||||
_w.popleft()
|
|
||||||
self.text_cb("".join(_w))
|
|
||||||
|
|
||||||
# Use the preset's pinned engine when set, else the active one.
|
|
||||||
engine_name = getattr(target, "llm_engine", "") or ""
|
|
||||||
llm_engine = (
|
|
||||||
next((e for e in self.cfg.llm_engines if e.name == engine_name), None)
|
|
||||||
or self.cfg.active_llm
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
text = llm.chat(
|
text = llm.chat(
|
||||||
llm_engine,
|
self.cfg.active_llm,
|
||||||
target.prompt,
|
target.prompt,
|
||||||
text,
|
text,
|
||||||
model=target.model or None,
|
model=target.model or None,
|
||||||
temperature=target.temperature,
|
temperature=target.temperature,
|
||||||
timeout=self.cfg.timeout,
|
timeout=self.cfg.timeout,
|
||||||
on_token=on_token,
|
|
||||||
abort_event=self._abort_event,
|
|
||||||
)
|
)
|
||||||
except LLMError as exc:
|
except LLMError as exc:
|
||||||
self._emit("error", label, str(exc))
|
self._emit("error", label, str(exc))
|
||||||
self._dnotify("Rewrite failed", str(exc), "critical")
|
self._dnotify("Rewrite failed", str(exc), "critical")
|
||||||
log(f"ERROR ({label} rewrite): {exc}")
|
log(f"ERROR ({label} rewrite): {exc}")
|
||||||
if self.text_cb:
|
|
||||||
self.text_cb(f"✗ {exc}")
|
|
||||||
return
|
|
||||||
except Exception as exc: # noqa: BLE001 - guard against any uncaught error
|
|
||||||
msg = f"LLM error: {exc}"
|
|
||||||
self._emit("error", label, msg)
|
|
||||||
log(f"ERROR ({label} rewrite unexpected): {exc}")
|
|
||||||
if self.text_cb:
|
|
||||||
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.
|
|
||||||
non_ws = sum(1 for c in text if not c.isspace())
|
|
||||||
if non_ws < max(1, len(text) * 0.20):
|
|
||||||
msg = "LLM returned mostly whitespace — discarded"
|
|
||||||
self._emit("error", label, msg)
|
|
||||||
log(f"ERROR ({label}): {msg} (len={len(text)})")
|
|
||||||
if self.text_cb:
|
|
||||||
self.text_cb(f"✗ {msg}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
|
|||||||
@ -96,11 +96,7 @@ class App:
|
|||||||
try:
|
try:
|
||||||
from .overlay import Overlay
|
from .overlay import Overlay
|
||||||
|
|
||||||
self.overlay = Overlay(
|
self.overlay = Overlay(anchor_mode=cfg.overlay_anchor)
|
||||||
anchor_mode=cfg.overlay_anchor,
|
|
||||||
on_cancel=lambda: threading.Thread(
|
|
||||||
target=self.daemon.cancel_dictation, daemon=True).start(),
|
|
||||||
)
|
|
||||||
if cfg.overlay_anchor == "caret":
|
if cfg.overlay_anchor == "caret":
|
||||||
from . import caret
|
from . import caret
|
||||||
|
|
||||||
@ -112,20 +108,11 @@ class App:
|
|||||||
cfg, status_cb=self._status_cb,
|
cfg, status_cb=self._status_cb,
|
||||||
level_cb=self._on_level, text_cb=self._on_text,
|
level_cb=self._on_level, text_cb=self._on_text,
|
||||||
countdown_cb=self._on_countdown,
|
countdown_cb=self._on_countdown,
|
||||||
# Only consume routing on the overlay when there's an overlay to show
|
|
||||||
# it on; otherwise the daemon keeps the desktop notification.
|
|
||||||
routing_cb=self._on_routing if self.overlay is not None else None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_install_css()
|
_install_css()
|
||||||
self._build_window()
|
self._build_window()
|
||||||
|
|
||||||
# Show the first-run wizard for fresh installs before the daemon starts.
|
|
||||||
if not getattr(cfg, "setup_complete", False):
|
|
||||||
from .setup_wizard import SetupWizard
|
|
||||||
wiz = SetupWizard(cfg, parent=None)
|
|
||||||
wiz.run()
|
|
||||||
|
|
||||||
threading.Thread(target=self._startup, daemon=True).start()
|
threading.Thread(target=self._startup, daemon=True).start()
|
||||||
|
|
||||||
# -- window ---------------------------------------------------------------
|
# -- window ---------------------------------------------------------------
|
||||||
@ -289,10 +276,6 @@ class App:
|
|||||||
if self.overlay is not None:
|
if self.overlay is not None:
|
||||||
self.overlay.set_countdown(remaining, total)
|
self.overlay.set_countdown(remaining, total)
|
||||||
|
|
||||||
def _on_routing(self, icon: str, name: str, keyword: str | None) -> None:
|
|
||||||
if self.overlay is not None:
|
|
||||||
self.overlay.set_preset(icon, name, keyword)
|
|
||||||
|
|
||||||
def _overlay_status(self, state: str, message: str) -> None:
|
def _overlay_status(self, state: str, message: str) -> None:
|
||||||
"""Translate engine phases into overlay show/update/hide (GTK thread)."""
|
"""Translate engine phases into overlay show/update/hide (GTK thread)."""
|
||||||
ov = self.overlay
|
ov = self.overlay
|
||||||
@ -304,10 +287,7 @@ class App:
|
|||||||
ov.show(state, getattr(self.daemon, "_target_window", None))
|
ov.show(state, getattr(self.daemon, "_target_window", None))
|
||||||
elif state == "busy":
|
elif state == "busy":
|
||||||
self._ov_state = state
|
self._ov_state = state
|
||||||
# The routing detail ("→ Nicer email (matched: …)") is shown on the
|
ov.set_state("busy", message)
|
||||||
# preset banner, not as a phase chip — keep the chip a clean phase word.
|
|
||||||
phase = "Transcribing…" if message.startswith("→") else message
|
|
||||||
ov.set_state("busy", phase)
|
|
||||||
elif state == "done":
|
elif state == "done":
|
||||||
# Non-streaming: the 'done' message carries the final text. Streaming
|
# Non-streaming: the 'done' message carries the final text. Streaming
|
||||||
# already showed it live, so don't overwrite with "Streaming stopped".
|
# already showed it live, so don't overwrite with "Streaming stopped".
|
||||||
@ -376,18 +356,9 @@ class App:
|
|||||||
|
|
||||||
# -- panel / settings / lifecycle -----------------------------------------
|
# -- panel / settings / lifecycle -----------------------------------------
|
||||||
def open_settings(self) -> None:
|
def open_settings(self) -> None:
|
||||||
# Single instance: if Settings is already open, bring it to the front
|
|
||||||
# instead of spawning a second dialog (the tray menu bypasses the
|
|
||||||
# dialog's own modality, so it could otherwise be opened repeatedly).
|
|
||||||
existing = getattr(self, "_settings", None)
|
|
||||||
if existing is not None:
|
|
||||||
existing.dlg.present()
|
|
||||||
return
|
|
||||||
from .gtksettings import SettingsDialog
|
from .gtksettings import SettingsDialog
|
||||||
|
|
||||||
self._settings = SettingsDialog(self.win, self.cfg, daemon=self.daemon)
|
SettingsDialog(self.win, self.cfg, daemon=self.daemon).run_dialog()
|
||||||
self._settings.dlg.connect("destroy", lambda *_: setattr(self, "_settings", None))
|
|
||||||
self._settings.run_dialog()
|
|
||||||
|
|
||||||
def show_panel(self) -> None:
|
def show_panel(self) -> None:
|
||||||
self.win.show_all()
|
self.win.show_all()
|
||||||
@ -427,21 +398,6 @@ class App:
|
|||||||
|
|
||||||
|
|
||||||
def run_gui(tray_mode: bool = False) -> int:
|
def run_gui(tray_mode: bool = False) -> int:
|
||||||
# Use GIO's native /proc/mounts volume monitor instead of the gvfs/udisks2
|
|
||||||
# one. On headless or minimal desktops the `org.gtk.vfs.UDisks2VolumeMonitor`
|
|
||||||
# dbus service often fails to activate, and every Gtk.FileChooserButton then
|
|
||||||
# blocks ~25s on a StartServiceByName timeout while realizing — which freezes
|
|
||||||
# the settings dialog (and, via the stalled main loop, the panel) so neither
|
|
||||||
# ever appears. The unix monitor needs no dbus and opens choosers instantly.
|
|
||||||
os.environ.setdefault("GIO_USE_VOLUME_MONITOR", "unix")
|
|
||||||
# Identify to the window manager as "blitztext" rather than the Python entry
|
|
||||||
# point's filename. Launched via `python -m blitztext`, GTK's default program
|
|
||||||
# name is argv[0]'s basename ("__main__.py"), which is what shows in the
|
|
||||||
# taskbar and in GNOME's "… is not responding" dialog. Setting it here (before
|
|
||||||
# any window is realized) gives every window the app's real name + .desktop
|
|
||||||
# match, without touching the `-m blitztext` entry point.
|
|
||||||
GLib.set_prgname("blitztext")
|
|
||||||
GLib.set_application_name("Blitztext")
|
|
||||||
cfg = load()
|
cfg = load()
|
||||||
App(cfg, tray_mode=tray_mode).run()
|
App(cfg, tray_mode=tray_mode).run()
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@ -74,21 +74,18 @@ class ModifierScheme:
|
|||||||
return
|
return
|
||||||
self._pressed.add(token)
|
self._pressed.add(token)
|
||||||
|
|
||||||
# Cancel works while arming/armed AND when the daemon is recording via
|
|
||||||
# wakeword (state stays "idle" in the scheme because wakeword bypasses
|
|
||||||
# the key-press path entirely).
|
|
||||||
if self._is(token, self.cancel) and (
|
|
||||||
self._state != "idle" or self.daemon.is_recording):
|
|
||||||
self._state = "idle"
|
|
||||||
self.daemon.cancel_dictation()
|
|
||||||
return
|
|
||||||
|
|
||||||
if self._state == "idle":
|
if self._state == "idle":
|
||||||
if self.start.issubset(self._pressed):
|
if self.start.issubset(self._pressed):
|
||||||
self._state = "arming"
|
self._state = "arming"
|
||||||
self.daemon.start_dictation()
|
self.daemon.start_dictation()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Cancel works while arming or armed.
|
||||||
|
if self._is(token, self.cancel):
|
||||||
|
self._state = "idle"
|
||||||
|
self.daemon.cancel_dictation()
|
||||||
|
return
|
||||||
|
|
||||||
if self._state == "armed":
|
if self._state == "armed":
|
||||||
if self._is(token, self.send):
|
if self._is(token, self.send):
|
||||||
self._state = "idle"
|
self._state = "idle"
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import os
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
from .stt import reachable
|
from .stt import reachable
|
||||||
|
|
||||||
@ -49,19 +48,10 @@ def chat(
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
temperature: float | None = None,
|
temperature: float | None = None,
|
||||||
timeout: int = 45,
|
timeout: int = 45,
|
||||||
on_token: Callable[[str], None] | None = None,
|
|
||||||
abort_event=None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Run a chat completion and return the full text.
|
|
||||||
|
|
||||||
When ``on_token`` is given, the request is streamed and each content delta is
|
|
||||||
handed to the callback as it arrives (so a UI can show the model writing in
|
|
||||||
real time). The callback is best-effort — it never affects the return value,
|
|
||||||
which is always the complete, stripped response.
|
|
||||||
"""
|
|
||||||
stream = on_token is not None
|
|
||||||
api_key = engine.api_key
|
api_key = engine.api_key
|
||||||
body_obj = {
|
payload = json.dumps(
|
||||||
|
{
|
||||||
"model": model or engine.model,
|
"model": model or engine.model,
|
||||||
"temperature": engine.temperature if temperature is None else temperature,
|
"temperature": engine.temperature if temperature is None else temperature,
|
||||||
"messages": [
|
"messages": [
|
||||||
@ -69,9 +59,7 @@ def chat(
|
|||||||
{"role": "user", "content": user_text},
|
{"role": "user", "content": user_text},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
if stream:
|
).encode("utf-8")
|
||||||
body_obj["stream"] = True
|
|
||||||
payload = json.dumps(body_obj).encode("utf-8")
|
|
||||||
|
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
if api_key:
|
if api_key:
|
||||||
@ -82,55 +70,19 @@ def chat(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
if stream:
|
|
||||||
content = _read_stream(resp, on_token, abort_event)
|
|
||||||
else:
|
|
||||||
body = json.loads(resp.read().decode("utf-8"))
|
body = json.loads(resp.read().decode("utf-8"))
|
||||||
content = body["choices"][0]["message"]["content"]
|
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
detail = exc.read().decode("utf-8", "replace")[:300]
|
detail = exc.read().decode("utf-8", "replace")[:300]
|
||||||
raise LLMError(f"HTTP {exc.code}: {detail}") from exc
|
raise LLMError(f"HTTP {exc.code}: {detail}") from exc
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
raise LLMError(f"Connection failed: {exc.reason}") from exc
|
raise LLMError(f"Connection failed: {exc.reason}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = body["choices"][0]["message"]["content"]
|
||||||
except (KeyError, IndexError, TypeError) as exc:
|
except (KeyError, IndexError, TypeError) as exc:
|
||||||
raise LLMError(f"Unexpected response: {exc}") from exc
|
raise LLMError(f"Unexpected response: {str(body)[:300]}") from exc
|
||||||
except (TimeoutError, OSError) as exc:
|
|
||||||
# socket.timeout (subclass of OSError / TimeoutError) fires when the server
|
|
||||||
# stops sending data mid-stream. Not wrapped in URLError — must be caught
|
|
||||||
# separately or it would propagate uncaught and kill the background thread.
|
|
||||||
raise LLMError(f"Request timed out or connection lost: {exc}") from exc
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
raise LLMError(f"Unexpected error: {exc}") from exc
|
|
||||||
|
|
||||||
content = (content or "").strip()
|
content = (content or "").strip()
|
||||||
if not content:
|
if not content:
|
||||||
raise LLMError("Empty response from model.")
|
raise LLMError("Empty response from model.")
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
data = line[len("data:"):].strip()
|
|
||||||
if data == "[DONE]":
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
obj = json.loads(data)
|
|
||||||
delta = obj["choices"][0]["delta"].get("content")
|
|
||||||
except (ValueError, KeyError, IndexError, TypeError):
|
|
||||||
continue
|
|
||||||
if delta:
|
|
||||||
parts.append(delta)
|
|
||||||
try:
|
|
||||||
on_token(delta)
|
|
||||||
except Exception: # noqa: BLE001 - UI hiccups must not break delivery
|
|
||||||
pass
|
|
||||||
return "".join(parts)
|
|
||||||
|
|||||||
@ -3,9 +3,6 @@
|
|||||||
Our own messages go through log(); library logs (faster-whisper, huggingface_hub)
|
Our own messages go through log(); library logs (faster-whisper, huggingface_hub)
|
||||||
are captured via a logging handler so model download/load progress is visible
|
are captured via a logging handler so model download/load progress is visible
|
||||||
instead of an opaque "Loading…".
|
instead of an opaque "Loading…".
|
||||||
|
|
||||||
Each entry stores (timestamp_str, level, message) so the UI can filter by level.
|
|
||||||
Levels: DEBUG, INFO, WARNING, ERROR (default: INFO)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -16,53 +13,32 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
# Each entry: (time_str, level, message)
|
_LINES: deque[str] = deque(maxlen=2000)
|
||||||
_ENTRIES: deque[tuple[str, str, str]] = deque(maxlen=2000)
|
|
||||||
_LOCK = threading.Lock()
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
_LEVEL_ORDER = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3}
|
|
||||||
|
|
||||||
|
def log(msg: str, *, echo: bool = True) -> None:
|
||||||
def log(msg: str, *, echo: bool = True, level: str = "INFO") -> None:
|
line = f"{time.strftime('%H:%M:%S')} {msg}"
|
||||||
level = level.upper()
|
|
||||||
ts = time.strftime("%H:%M:%S")
|
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
_ENTRIES.append((ts, level, msg))
|
_LINES.append(line)
|
||||||
if echo:
|
if echo:
|
||||||
print(f"{ts} [{level}] {msg}", file=sys.stderr, flush=True)
|
print(line, file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
def lines(min_level: str = "DEBUG") -> list[str]:
|
def lines() -> list[str]:
|
||||||
"""Return formatted lines at or above min_level."""
|
|
||||||
threshold = _LEVEL_ORDER.get(min_level.upper(), 0)
|
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
entries = list(_ENTRIES)
|
return list(_LINES)
|
||||||
result = []
|
|
||||||
for ts, lvl, msg in entries:
|
|
||||||
if _LEVEL_ORDER.get(lvl, 1) >= threshold:
|
|
||||||
prefix = f"[{lvl}] " if lvl not in ("INFO",) else ""
|
|
||||||
result.append(f"{ts} {prefix}{msg}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def clear() -> None:
|
def clear() -> None:
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
_ENTRIES.clear()
|
_LINES.clear()
|
||||||
|
|
||||||
|
|
||||||
class _BufferHandler(logging.Handler):
|
class _BufferHandler(logging.Handler):
|
||||||
_PY_TO_LEVEL = {
|
|
||||||
logging.DEBUG: "DEBUG",
|
|
||||||
logging.INFO: "INFO",
|
|
||||||
logging.WARNING: "WARNING",
|
|
||||||
logging.ERROR: "ERROR",
|
|
||||||
logging.CRITICAL:"ERROR",
|
|
||||||
}
|
|
||||||
|
|
||||||
def emit(self, record: logging.LogRecord) -> None:
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
try:
|
try:
|
||||||
lvl = self._PY_TO_LEVEL.get(record.levelno, "INFO")
|
log(self.format(record), echo=False)
|
||||||
log(self.format(record), echo=False, level=lvl)
|
|
||||||
except Exception: # noqa: BLE001 - never let logging break the app
|
except Exception: # noqa: BLE001 - never let logging break the app
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -81,4 +57,4 @@ def install_logging() -> None:
|
|||||||
for name in ("faster_whisper", "huggingface_hub", "blitztext"):
|
for name in ("faster_whisper", "huggingface_hub", "blitztext"):
|
||||||
lg = logging.getLogger(name)
|
lg = logging.getLogger(name)
|
||||||
lg.addHandler(handler)
|
lg.addHandler(handler)
|
||||||
lg.setLevel(logging.DEBUG)
|
lg.setLevel(logging.INFO)
|
||||||
|
|||||||
@ -40,7 +40,6 @@ _TAIL_W = 20
|
|||||||
_TAIL_H = 11
|
_TAIL_H = 11
|
||||||
_GAP = 12 # clearance between the tail tip and the anchor
|
_GAP = 12 # clearance between the tail tip and the anchor
|
||||||
_BARS = 30 # waveform bar count
|
_BARS = 30 # waveform bar count
|
||||||
_PRESET_H = 22 # matched-preset header row (emoji + name + keyword)
|
|
||||||
_MIN_TEXT_H = 0
|
_MIN_TEXT_H = 0
|
||||||
_MAX_TEXT_H = 120
|
_MAX_TEXT_H = 120
|
||||||
|
|
||||||
@ -56,22 +55,13 @@ _PHASES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
_CANCEL_BTN_R = 11 # hit-radius of the × button (px)
|
|
||||||
|
|
||||||
class Overlay:
|
class Overlay:
|
||||||
def __init__(self, anchor_mode: str = "caret", on_cancel=None) -> None:
|
def __init__(self, anchor_mode: str = "caret") -> None:
|
||||||
self.anchor_mode = anchor_mode
|
self.anchor_mode = anchor_mode
|
||||||
self._on_cancel_cb = on_cancel # callable() → cancels current recording
|
|
||||||
self._visible = False
|
self._visible = False
|
||||||
self._state = "recording"
|
self._state = "recording"
|
||||||
self._text = ""
|
self._text = ""
|
||||||
self._phase_label = "Listening…"
|
self._phase_label = "Listening…"
|
||||||
# Matched-preset banner (fused in from voice routing instead of a separate
|
|
||||||
# desktop notification): the preset's emoji, its name, and the spoken
|
|
||||||
# keyword that selected it.
|
|
||||||
self._preset_icon = ""
|
|
||||||
self._preset_name = ""
|
|
||||||
self._keyword = ""
|
|
||||||
self._anchor: caret.Anchor | None = None
|
self._anchor: caret.Anchor | None = None
|
||||||
self._tail_up = False # tail on top edge (bubble below anchor)?
|
self._tail_up = False # tail on top edge (bubble below anchor)?
|
||||||
self._tail_x = _WIDTH // 2 # tail tip, window-local x
|
self._tail_x = _WIDTH // 2 # tail tip, window-local x
|
||||||
@ -89,15 +79,6 @@ class Overlay:
|
|||||||
self._tick_id: int | None = None
|
self._tick_id: int | None = None
|
||||||
self._hide_id: int | None = None
|
self._hide_id: int | None = None
|
||||||
self._t0 = time.time()
|
self._t0 = time.time()
|
||||||
# Coalescing text updates: background LLM streaming can fire dozens of
|
|
||||||
# set_text() calls per second. We buffer the latest text and only ever
|
|
||||||
# have ONE idle_add pending — so the GTK main loop is never flooded.
|
|
||||||
self._pending_text: str = ""
|
|
||||||
self._text_flush_queued: bool = False
|
|
||||||
self._pending_level: float = 0.0
|
|
||||||
self._level_flush_queued: bool = False
|
|
||||||
|
|
||||||
self._cancel_btn_rect = (0, 0, 0, 0) # (x, y, w, h) in window coords
|
|
||||||
|
|
||||||
self._win = Gtk.Window(type=Gtk.WindowType.POPUP)
|
self._win = Gtk.Window(type=Gtk.WindowType.POPUP)
|
||||||
self._win.set_app_paintable(True)
|
self._win.set_app_paintable(True)
|
||||||
@ -117,66 +98,25 @@ class Overlay:
|
|||||||
self._area.connect("draw", self._on_draw)
|
self._area.connect("draw", self._on_draw)
|
||||||
self._win.add(self._area)
|
self._win.add(self._area)
|
||||||
self._win.connect("realize", self._on_realize)
|
self._win.connect("realize", self._on_realize)
|
||||||
self._win.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
|
|
||||||
self._win.connect("button-press-event", self._on_click)
|
|
||||||
self._win.set_default_size(_WIDTH, self._height)
|
self._win.set_default_size(_WIDTH, self._height)
|
||||||
|
|
||||||
# -- click-through (all except the × button) ------------------------------
|
# -- click-through --------------------------------------------------------
|
||||||
def _on_realize(self, _w) -> None:
|
def _on_realize(self, _w) -> None:
|
||||||
self._update_input_region()
|
|
||||||
|
|
||||||
def _update_input_region(self) -> None:
|
|
||||||
gdkwin = self._win.get_window()
|
gdkwin = self._win.get_window()
|
||||||
if gdkwin is None:
|
if gdkwin is not None:
|
||||||
return
|
# Empty input region → the HUD ignores all clicks; they fall through
|
||||||
if self._on_cancel_cb and self._state in ("recording", "streaming", "busy"):
|
# to whatever is underneath (the field you're typing into).
|
||||||
x, y, w, h = self._cancel_btn_rect
|
gdkwin.input_shape_combine_region(cairo.Region(), 0, 0)
|
||||||
r = cairo.Region(cairo.RectangleInt(int(x), int(y), int(w), int(h)))
|
|
||||||
else:
|
|
||||||
r = cairo.Region() # empty → fully click-through
|
|
||||||
gdkwin.input_shape_combine_region(r, 0, 0)
|
|
||||||
|
|
||||||
def _on_click(self, _win, event) -> bool:
|
|
||||||
if self._on_cancel_cb is None:
|
|
||||||
return False
|
|
||||||
x, y, w, h = self._cancel_btn_rect
|
|
||||||
if x <= event.x <= x + w and y <= event.y <= y + h:
|
|
||||||
self._on_cancel_cb()
|
|
||||||
return True
|
|
||||||
|
|
||||||
# -- thread-safe public API ----------------------------------------------
|
# -- thread-safe public API ----------------------------------------------
|
||||||
def show(self, state: str, window_id: str | None) -> None:
|
def show(self, state: str, window_id: str | None) -> None:
|
||||||
GLib.idle_add(self._show, state, window_id)
|
GLib.idle_add(self._show, state, window_id)
|
||||||
|
|
||||||
def set_level(self, level: float) -> None:
|
def set_level(self, level: float) -> None:
|
||||||
self._pending_level = float(level)
|
GLib.idle_add(self._set_level, float(level))
|
||||||
if not self._level_flush_queued:
|
|
||||||
self._level_flush_queued = True
|
|
||||||
GLib.idle_add(self._flush_level)
|
|
||||||
|
|
||||||
def _flush_level(self) -> bool:
|
|
||||||
self._level_flush_queued = False
|
|
||||||
self._set_level(self._pending_level)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def set_text(self, text: str) -> None:
|
def set_text(self, text: str) -> None:
|
||||||
self._pending_text = text or ""
|
GLib.idle_add(self._set_text, text or "")
|
||||||
# Only schedule a flush if none is already queued; this collapses a burst
|
|
||||||
# of token callbacks (e.g. 50/s from LLM streaming) into a single GTK
|
|
||||||
# redraw, preventing main-loop flooding and session freezes.
|
|
||||||
if not self._text_flush_queued:
|
|
||||||
self._text_flush_queued = True
|
|
||||||
GLib.idle_add(self._flush_text)
|
|
||||||
|
|
||||||
def _flush_text(self) -> bool:
|
|
||||||
self._text_flush_queued = False
|
|
||||||
self._set_text(self._pending_text)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def set_preset(self, icon: str, name: str, keyword: str | None) -> None:
|
|
||||||
"""Show the matched voice-routing preset on the overlay (emoji + name +
|
|
||||||
the spoken keyword), in place of a separate desktop notification."""
|
|
||||||
GLib.idle_add(self._set_preset, icon or "", name or "", keyword or "")
|
|
||||||
|
|
||||||
def set_countdown(self, remaining: float | None, total: float) -> None:
|
def set_countdown(self, remaining: float | None, total: float) -> None:
|
||||||
"""Silence auto-stop progress: ``remaining`` seconds until it fires over
|
"""Silence auto-stop progress: ``remaining`` seconds until it fires over
|
||||||
@ -198,9 +138,6 @@ class Overlay:
|
|||||||
self._state = state
|
self._state = state
|
||||||
self._phase_label = _PHASES.get(state, ((1, 1, 1), ""))[1]
|
self._phase_label = _PHASES.get(state, ((1, 1, 1), ""))[1]
|
||||||
self._text = ""
|
self._text = ""
|
||||||
self._preset_icon = ""
|
|
||||||
self._preset_name = ""
|
|
||||||
self._keyword = ""
|
|
||||||
self._levels = deque([0.0] * _BARS, maxlen=_BARS)
|
self._levels = deque([0.0] * _BARS, maxlen=_BARS)
|
||||||
self._disp = [0.0] * _BARS
|
self._disp = [0.0] * _BARS
|
||||||
self._cd_deadline = None
|
self._cd_deadline = None
|
||||||
@ -226,16 +163,6 @@ class Overlay:
|
|||||||
self._area.queue_draw()
|
self._area.queue_draw()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _set_preset(self, icon: str, name: str, keyword: str) -> bool:
|
|
||||||
if (icon, name, keyword) == (self._preset_icon, self._preset_name, self._keyword):
|
|
||||||
return False
|
|
||||||
self._preset_icon = icon
|
|
||||||
self._preset_name = name
|
|
||||||
self._keyword = keyword
|
|
||||||
self._relayout()
|
|
||||||
self._area.queue_draw()
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _set_countdown(self, remaining: float | None, total: float) -> bool:
|
def _set_countdown(self, remaining: float | None, total: float) -> bool:
|
||||||
if remaining is None:
|
if remaining is None:
|
||||||
self._cd_deadline = None
|
self._cd_deadline = None
|
||||||
@ -246,18 +173,11 @@ class Overlay:
|
|||||||
|
|
||||||
def _set_state(self, state: str, message: str) -> bool:
|
def _set_state(self, state: str, message: str) -> bool:
|
||||||
self._state = state
|
self._state = state
|
||||||
if state == "busy":
|
|
||||||
# Honour a clean phase word from the caller ("Transcribing…",
|
|
||||||
# "Rewriting…") so the overlay narrates what's happening; fall back to
|
|
||||||
# the canned label otherwise.
|
|
||||||
self._phase_label = (message.strip() or _PHASES["busy"][1])[:28]
|
|
||||||
else:
|
|
||||||
self._phase_label = _PHASES.get(state, ((1, 1, 1), message[:40]))[1] or message[:40]
|
self._phase_label = _PHASES.get(state, ((1, 1, 1), message[:40]))[1] or message[:40]
|
||||||
if state not in ("recording", "streaming"):
|
if state not in ("recording", "streaming"):
|
||||||
# The countdown only makes sense while listening; drop it as soon as
|
# The countdown only makes sense while listening; drop it as soon as
|
||||||
# we move on to transcribing / done / idle so the ring doesn't linger.
|
# we move on to transcribing / done / idle so the ring doesn't linger.
|
||||||
self._cd_deadline = None
|
self._cd_deadline = None
|
||||||
self._update_input_region() # enable/disable × hit area
|
|
||||||
if state in ("recording", "streaming", "busy"):
|
if state in ("recording", "streaming", "busy"):
|
||||||
self._cancel_hide()
|
self._cancel_hide()
|
||||||
elif state in ("done", "idle", "error"):
|
elif state in ("done", "idle", "error"):
|
||||||
@ -335,10 +255,8 @@ class Overlay:
|
|||||||
|
|
||||||
def _relayout(self) -> None:
|
def _relayout(self) -> None:
|
||||||
text_h = self._text_height()
|
text_h = self._text_height()
|
||||||
preset_h = _PRESET_H if self._preset_name else 0
|
|
||||||
gap_preset = 8 if preset_h else 0
|
|
||||||
gap_text = 8 if text_h else 0
|
gap_text = 8 if text_h else 0
|
||||||
body_h = _HEADER_H + gap_preset + preset_h + gap_text + text_h + 2 * _PAD
|
body_h = _HEADER_H + gap_text + text_h + 2 * _PAD
|
||||||
self._height = body_h + _TAIL_H
|
self._height = body_h + _TAIL_H
|
||||||
|
|
||||||
geo = self._monitor_geo()
|
geo = self._monitor_geo()
|
||||||
@ -365,13 +283,6 @@ class Overlay:
|
|||||||
self._win.move(int(win_x), int(win_y))
|
self._win.move(int(win_x), int(win_y))
|
||||||
self._area.set_size_request(_WIDTH, self._height)
|
self._area.set_size_request(_WIDTH, self._height)
|
||||||
|
|
||||||
# X button: top-right corner of the bubble body.
|
|
||||||
_body_top = _TAIL_H if self._tail_up else 0
|
|
||||||
btn_d = _CANCEL_BTN_R * 2
|
|
||||||
self._cancel_btn_rect = (
|
|
||||||
_WIDTH - _PAD - btn_d, _body_top + _PAD // 2, btn_d, btn_d)
|
|
||||||
self._update_input_region()
|
|
||||||
|
|
||||||
# -- drawing --------------------------------------------------------------
|
# -- drawing --------------------------------------------------------------
|
||||||
def _on_draw(self, _area, cr) -> bool:
|
def _on_draw(self, _area, cr) -> bool:
|
||||||
# Start fully transparent.
|
# Start fully transparent.
|
||||||
@ -403,63 +314,15 @@ class Overlay:
|
|||||||
wf_w = w - _PAD - wf_x
|
wf_w = w - _PAD - wf_x
|
||||||
self._draw_wave(cr, wf_x, body_top + _PAD, wf_w, _HEADER_H)
|
self._draw_wave(cr, wf_x, body_top + _PAD, wf_w, _HEADER_H)
|
||||||
|
|
||||||
# × cancel button (top-right corner, recording/streaming/busy).
|
# Phase label (top-right, small) when there's room and no text yet.
|
||||||
if self._on_cancel_cb and self._state in ("recording", "streaming", "busy"):
|
if self._phase_label and not self._text:
|
||||||
self._draw_cancel_btn(cr, body_top)
|
self._draw_label(cr, w - _PAD, body_top + _PAD + 12, self._phase_label)
|
||||||
|
|
||||||
# Phase label by the waveform — only when there's no preset banner and no text.
|
# Recognised text below the header row.
|
||||||
# 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")
|
|
||||||
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)
|
|
||||||
|
|
||||||
y = body_top + _PAD + _HEADER_H
|
|
||||||
# Matched-preset banner: emoji + name (left), live phase chip (right).
|
|
||||||
if self._preset_name:
|
|
||||||
y += 8
|
|
||||||
self._draw_preset(cr, _PAD, y, w - 2 * _PAD)
|
|
||||||
y += _PRESET_H
|
|
||||||
# Recognised / rewritten text below.
|
|
||||||
if self._text:
|
if self._text:
|
||||||
y += 8
|
self._draw_text(cr, _PAD, body_top + _PAD + _HEADER_H + 8, w - 2 * _PAD)
|
||||||
self._draw_text(cr, _PAD, y, w - 2 * _PAD)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _draw_preset(self, cr, x, y, w) -> None:
|
|
||||||
"""The matched voice-routing preset, fused onto the overlay in place of a
|
|
||||||
desktop notification: emoji + name (and the spoken keyword) on the left,
|
|
||||||
the current phase ("Transcribing…", "Rewriting…") on the right."""
|
|
||||||
cy = y + _PRESET_H / 2
|
|
||||||
cursor = x
|
|
||||||
if self._preset_icon:
|
|
||||||
ic = self._win.create_pango_layout(self._preset_icon)
|
|
||||||
ic.set_font_description(Pango.FontDescription("Sans 13"))
|
|
||||||
iw, ih = ic.get_pixel_size()
|
|
||||||
cr.set_source_rgba(1, 1, 1, 0.95)
|
|
||||||
cr.move_to(cursor, cy - ih / 2)
|
|
||||||
PangoCairo.show_layout(cr, ic)
|
|
||||||
cursor += iw + 7
|
|
||||||
name = GLib.markup_escape_text(self._preset_name)
|
|
||||||
if self._keyword:
|
|
||||||
kw = GLib.markup_escape_text(self._keyword)
|
|
||||||
markup = f'<b>{name}</b> <span alpha="55%">“{kw}”</span>'
|
|
||||||
else:
|
|
||||||
markup = f"<b>{name}</b>"
|
|
||||||
layout = self._win.create_pango_layout("")
|
|
||||||
layout.set_markup(markup, -1)
|
|
||||||
layout.set_font_description(Pango.FontDescription("Sans 10"))
|
|
||||||
layout.set_ellipsize(Pango.EllipsizeMode.END)
|
|
||||||
avail = (x + w) - cursor - 96 # leave room for the phase chip
|
|
||||||
layout.set_width(max(40, avail) * Pango.SCALE)
|
|
||||||
_nw, nh = layout.get_pixel_size()
|
|
||||||
cr.set_source_rgba(0.95, 0.96, 0.99, 0.98)
|
|
||||||
cr.move_to(cursor, cy - nh / 2)
|
|
||||||
PangoCairo.show_layout(cr, layout)
|
|
||||||
if self._phase_label:
|
|
||||||
self._draw_label(cr, x + w, cy, self._phase_label)
|
|
||||||
|
|
||||||
def _bubble_path(self, cr, x, y, w, h) -> None:
|
def _bubble_path(self, cr, x, y, w, h) -> None:
|
||||||
r = _RADIUS
|
r = _RADIUS
|
||||||
cr.new_sub_path()
|
cr.new_sub_path()
|
||||||
@ -561,25 +424,6 @@ class Overlay:
|
|||||||
cr.move_to(right_x - tw, cy - th / 2)
|
cr.move_to(right_x - tw, cy - th / 2)
|
||||||
PangoCairo.show_layout(cr, layout)
|
PangoCairo.show_layout(cr, layout)
|
||||||
|
|
||||||
def _draw_cancel_btn(self, cr, body_top) -> None:
|
|
||||||
x, y, w, h = self._cancel_btn_rect
|
|
||||||
cx_btn = x + w / 2
|
|
||||||
cy_btn = y + h / 2
|
|
||||||
cr.set_source_rgba(1, 1, 1, 0.15)
|
|
||||||
cr.arc(cx_btn, cy_btn, _CANCEL_BTN_R, 0, 2 * math.pi)
|
|
||||||
cr.fill()
|
|
||||||
arm = _CANCEL_BTN_R * 0.45
|
|
||||||
cr.set_source_rgba(1, 1, 1, 0.80)
|
|
||||||
cr.set_line_width(1.8)
|
|
||||||
cr.set_line_cap(cairo.LINE_CAP_ROUND)
|
|
||||||
cr.move_to(cx_btn - arm, cy_btn - arm)
|
|
||||||
cr.line_to(cx_btn + arm, cy_btn + arm)
|
|
||||||
cr.stroke()
|
|
||||||
cr.move_to(cx_btn + arm, cy_btn - arm)
|
|
||||||
cr.line_to(cx_btn - arm, cy_btn + arm)
|
|
||||||
cr.stroke()
|
|
||||||
cr.set_line_cap(cairo.LINE_CAP_BUTT)
|
|
||||||
|
|
||||||
def _draw_text(self, cr, x, y, w) -> None:
|
def _draw_text(self, cr, x, y, w) -> None:
|
||||||
layout = self._win.create_pango_layout(self._text)
|
layout = self._win.create_pango_layout(self._text)
|
||||||
layout.set_width(w * Pango.SCALE)
|
layout.set_width(w * Pango.SCALE)
|
||||||
|
|||||||
@ -41,13 +41,6 @@ def _focus(window_id: str | None) -> None:
|
|||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
|
||||||
# Above this character count, or when the text contains newlines, xdotool type
|
|
||||||
# sends thousands of synchronous X11 round-trips and can flood the X11 server's
|
|
||||||
# per-client event buffer until the whole session freezes. Auto-upgrade to a
|
|
||||||
# single clipboard paste instead, which is instantaneous.
|
|
||||||
_TYPE_THRESHOLD = 300
|
|
||||||
|
|
||||||
|
|
||||||
def deliver(text: str, *, mode: str = "type", window_id: str | None = None, type_delay_ms: int = 4) -> None:
|
def deliver(text: str, *, mode: str = "type", window_id: str | None = None, type_delay_ms: int = 4) -> None:
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
@ -62,15 +55,6 @@ def deliver(text: str, *, mode: str = "type", window_id: str | None = None, type
|
|||||||
# Give the user time to release the hotkey modifiers before we synthesize input.
|
# Give the user time to release the hotkey modifiers before we synthesize input.
|
||||||
time.sleep(0.12)
|
time.sleep(0.12)
|
||||||
|
|
||||||
# Long or multi-line text: force clipboard paste regardless of configured mode.
|
|
||||||
# xdotool type at 12ms/char for a 15 000-char code block takes ~3 minutes and
|
|
||||||
# sends so many synchronous X11 events that the server's per-client buffer
|
|
||||||
# overflows, freezing the entire X11 session.
|
|
||||||
if mode == "type" and (len(text) > _TYPE_THRESHOLD or "\n" in text):
|
|
||||||
if _set_clipboard(text):
|
|
||||||
mode = "paste"
|
|
||||||
# If clipboard isn't available we fall through to xdotool type as before.
|
|
||||||
|
|
||||||
if mode == "paste" and _set_clipboard(text):
|
if mode == "paste" and _set_clipboard(text):
|
||||||
if wayland:
|
if wayland:
|
||||||
if shutil.which("wtype"):
|
if shutil.which("wtype"):
|
||||||
|
|||||||
@ -71,23 +71,3 @@ def clean(text: str, *, strip_trailing_punctuation: bool = False) -> str:
|
|||||||
if strip_trailing_punctuation:
|
if strip_trailing_punctuation:
|
||||||
text = text.rstrip(" .,!?;:")
|
text = text.rstrip(" .,!?;:")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
# Spoken phrases that should become newlines/paragraphs in the typed output.
|
|
||||||
# Matched case-insensitively, whole-phrase (surrounded by word boundaries or
|
|
||||||
# start/end of string). German and English variants are both covered.
|
|
||||||
_NEWLINE_PHRASES: list[tuple[re.Pattern[str], str]] = [
|
|
||||||
# Paragraph break (blank line) — must come before single-newline patterns
|
|
||||||
(re.compile(r"(?i)\b(new paragraph|neuer absatz|neues absatz|absatz)\b"), "\n\n"),
|
|
||||||
# Single line break
|
|
||||||
(re.compile(r"(?i)\b(new line|neue zeile|zeilenumbruch|line break)\b"), "\n"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def expand_spoken_punctuation(text: str) -> str:
|
|
||||||
"""Replace spoken newline/paragraph commands with actual control characters."""
|
|
||||||
for pattern, replacement in _NEWLINE_PHRASES:
|
|
||||||
text = pattern.sub(replacement, text)
|
|
||||||
# Collapse leading/trailing whitespace per line but preserve intentional newlines
|
|
||||||
lines = [ln.strip() for ln in text.split("\n")]
|
|
||||||
return "\n".join(lines).strip()
|
|
||||||
|
|||||||
@ -110,55 +110,6 @@ def route(
|
|||||||
return RouteResult(name, cleaned, keyword, position, score)
|
return RouteResult(name, cleaned, keyword, position, score)
|
||||||
|
|
||||||
|
|
||||||
def is_cancel(transcript: str, cancel_keywords, *, threshold: float = DEFAULT_THRESHOLD) -> str | None:
|
|
||||||
"""Return the cancel keyword that matches an edge of the transcript, else None.
|
|
||||||
|
|
||||||
Lets a spoken word like "abbrechen" abort an (often accidentally triggered)
|
|
||||||
dictation before it is routed, rewritten, or delivered. Matched the same
|
|
||||||
edge-anchored, ASR-tolerant way as routing keywords, so the word appearing
|
|
||||||
deep inside a sentence won't trigger it — only at the start or end.
|
|
||||||
"""
|
|
||||||
if not cancel_keywords:
|
|
||||||
return None
|
|
||||||
tokens = normalize(transcript)
|
|
||||||
if not tokens:
|
|
||||||
return None
|
|
||||||
for kw in cancel_keywords:
|
|
||||||
kw_tokens = normalize(kw)
|
|
||||||
if kw_tokens and _match_window(tokens, kw_tokens, threshold) is not None:
|
|
||||||
return kw
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def match_send(transcript: str, send_keywords, *, threshold: float = DEFAULT_THRESHOLD):
|
|
||||||
"""Detect a spoken 'send' keyword at an edge; return (keyword, cleaned_text).
|
|
||||||
|
|
||||||
Like is_cancel, but the keyword is *stripped* and the remaining text is meant
|
|
||||||
to be delivered and submitted with Enter — the spoken equivalent of
|
|
||||||
stop+paste+Enter. Returns (None, transcript) when nothing matches. Matched the
|
|
||||||
same edge-anchored, ASR-tolerant way as routing keywords, so the word deep
|
|
||||||
inside a sentence won't trigger it — only at the start or end.
|
|
||||||
"""
|
|
||||||
if not send_keywords:
|
|
||||||
return None, transcript
|
|
||||||
tokens = normalize(transcript)
|
|
||||||
if not tokens:
|
|
||||||
return None, transcript
|
|
||||||
best = None # (score, span, position, keyword)
|
|
||||||
for kw in send_keywords:
|
|
||||||
kw_tokens = normalize(kw)
|
|
||||||
m = _match_window(tokens, kw_tokens, threshold)
|
|
||||||
if m is None:
|
|
||||||
continue
|
|
||||||
position, score, span = m
|
|
||||||
if best is None or (score, span) > (best[0], best[1]):
|
|
||||||
best = (score, span, position, kw)
|
|
||||||
if best is None:
|
|
||||||
return None, transcript
|
|
||||||
_score, span, position, kw = best
|
|
||||||
return kw, _strip_span(transcript, span, position)
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_span(transcript: str, span_words: int, position: str) -> str:
|
def _strip_span(transcript: str, span_words: int, position: str) -> str:
|
||||||
"""Remove the matched keyword from the given edge of the original transcript.
|
"""Remove the matched keyword from the given edge of the original transcript.
|
||||||
|
|
||||||
|
|||||||
@ -1,844 +0,0 @@
|
|||||||
"""First-run setup wizard — guides new users through the essential settings.
|
|
||||||
|
|
||||||
Shows automatically when no config file exists yet (fresh install).
|
|
||||||
Can also be reopened from Settings → "Setup Wizard…".
|
|
||||||
|
|
||||||
Flow:
|
|
||||||
Welcome → Trigger method → [Keyboard] → [Wakeword] → STT → LLM → Done
|
|
||||||
|
|
||||||
Pages in brackets are shown conditionally depending on the trigger choice.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import gi
|
|
||||||
|
|
||||||
gi.require_version("Gtk", "3.0")
|
|
||||||
gi.require_version("Gdk", "3.0")
|
|
||||||
from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tiny style helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _h1(text: str) -> Gtk.Label:
|
|
||||||
lbl = Gtk.Label(label=text, xalign=0.0)
|
|
||||||
lbl.set_line_wrap(True)
|
|
||||||
attrs = Pango.AttrList()
|
|
||||||
attrs.insert(Pango.attr_weight_new(Pango.Weight.BOLD))
|
|
||||||
attrs.insert(Pango.attr_scale_new(1.45))
|
|
||||||
lbl.set_attributes(attrs)
|
|
||||||
return lbl
|
|
||||||
|
|
||||||
|
|
||||||
def _sub(text: str) -> Gtk.Label:
|
|
||||||
lbl = Gtk.Label(label=text, xalign=0.0)
|
|
||||||
lbl.set_line_wrap(True)
|
|
||||||
lbl.set_max_width_chars(64)
|
|
||||||
lbl.get_style_context().add_class("dim-label")
|
|
||||||
return lbl
|
|
||||||
|
|
||||||
|
|
||||||
def _section(text: str) -> Gtk.Label:
|
|
||||||
lbl = Gtk.Label(xalign=0.0)
|
|
||||||
lbl.set_markup(f"<b><small>{GLib.markup_escape_text(text.upper())}</small></b>")
|
|
||||||
lbl.set_margin_top(12)
|
|
||||||
lbl.get_style_context().add_class("dim-label")
|
|
||||||
return lbl
|
|
||||||
|
|
||||||
|
|
||||||
def _page_box() -> Gtk.Box:
|
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
|
||||||
box.set_margin_top(28); box.set_margin_bottom(8)
|
|
||||||
box.set_margin_start(32); box.set_margin_end(32)
|
|
||||||
return box
|
|
||||||
|
|
||||||
|
|
||||||
def _option_card(icon: str, title: str, desc: str) -> tuple[Gtk.RadioButton, Gtk.Box]:
|
|
||||||
"""A large selectable card with an icon, bold title, and grey description."""
|
|
||||||
card = Gtk.Box(spacing=14)
|
|
||||||
card.set_margin_top(4); card.set_margin_bottom(4)
|
|
||||||
card.get_style_context().add_class("card")
|
|
||||||
|
|
||||||
icon_lbl = Gtk.Label(label=icon)
|
|
||||||
attrs = Pango.AttrList()
|
|
||||||
attrs.insert(Pango.attr_scale_new(2.0))
|
|
||||||
icon_lbl.set_attributes(attrs)
|
|
||||||
icon_lbl.set_margin_start(14)
|
|
||||||
card.pack_start(icon_lbl, False, False, 0)
|
|
||||||
|
|
||||||
text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
|
||||||
text_box.set_margin_top(12); text_box.set_margin_bottom(12)
|
|
||||||
title_lbl = Gtk.Label(label=title, xalign=0.0)
|
|
||||||
attrs2 = Pango.AttrList()
|
|
||||||
attrs2.insert(Pango.attr_weight_new(Pango.Weight.BOLD))
|
|
||||||
title_lbl.set_attributes(attrs2)
|
|
||||||
text_box.pack_start(title_lbl, False, False, 0)
|
|
||||||
desc_lbl = Gtk.Label(label=desc, xalign=0.0)
|
|
||||||
desc_lbl.get_style_context().add_class("dim-label")
|
|
||||||
desc_lbl.set_line_wrap(True)
|
|
||||||
desc_lbl.set_max_width_chars(55)
|
|
||||||
text_box.pack_start(desc_lbl, False, False, 0)
|
|
||||||
card.pack_start(text_box, True, True, 0)
|
|
||||||
|
|
||||||
# Invisible RadioButton — the whole card is the clickable area.
|
|
||||||
rb = Gtk.RadioButton()
|
|
||||||
rb.set_margin_end(14)
|
|
||||||
card.pack_start(rb, False, False, 0)
|
|
||||||
return rb, card
|
|
||||||
|
|
||||||
|
|
||||||
def _keyval_token(keyval: int) -> str | None:
|
|
||||||
from gi.repository import Gdk as _Gdk
|
|
||||||
name = _Gdk.keyval_name(keyval) or ""
|
|
||||||
low = name.lower()
|
|
||||||
for mod in ("control", "ctrl"):
|
|
||||||
if low.startswith(mod):
|
|
||||||
return "ctrl"
|
|
||||||
for mod in ("alt", "meta"):
|
|
||||||
if low.startswith(mod):
|
|
||||||
return "alt"
|
|
||||||
if low.startswith("super") or low.startswith("hyper") or low.startswith("win"):
|
|
||||||
return "cmd"
|
|
||||||
if low.startswith("shift"):
|
|
||||||
return "shift"
|
|
||||||
if low in ("escape", "esc"):
|
|
||||||
return "esc"
|
|
||||||
if low in ("return", "enter"):
|
|
||||||
return "enter"
|
|
||||||
if low == "space":
|
|
||||||
return "space"
|
|
||||||
if len(name) == 1 and name.isalpha():
|
|
||||||
return name.lower()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _format_combo(tokens: list[str]) -> str:
|
|
||||||
if not tokens:
|
|
||||||
return ""
|
|
||||||
order = ["ctrl", "alt", "shift", "cmd"]
|
|
||||||
mods = [t for t in order if t in tokens]
|
|
||||||
rest = [t for t in tokens if t not in order]
|
|
||||||
parts = mods + rest
|
|
||||||
return "+".join(f"<{p}>" for p in parts)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Wizard
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class SetupWizard:
|
|
||||||
"""Paged first-run setup dialog."""
|
|
||||||
|
|
||||||
# Page names in fixed order; "keyboard" and "wakeword" are conditional.
|
|
||||||
_ALL_PAGES = ("welcome", "trigger", "keyboard", "wakeword", "stt", "llm", "done")
|
|
||||||
|
|
||||||
def __init__(self, cfg, parent: Gtk.Window | None = None):
|
|
||||||
self.cfg = cfg
|
|
||||||
self._parent = parent
|
|
||||||
|
|
||||||
# Wizard state collected as user moves through pages.
|
|
||||||
self._trigger = "keyboard" # "keyboard" | "wakeword" | "both"
|
|
||||||
self._kb_start = cfg.key_start
|
|
||||||
self._kb_stop = cfg.key_stop
|
|
||||||
self._kb_send = cfg.key_send
|
|
||||||
self._kb_cancel = cfg.key_cancel
|
|
||||||
self._ww_uri = cfg.wakeword_uri
|
|
||||||
self._ww_model = cfg.wakeword_model
|
|
||||||
self._stt_local = True # True = faster-whisper, False = remote API
|
|
||||||
self._stt_size = "small"
|
|
||||||
self._stt_url = ""
|
|
||||||
self._stt_key = ""
|
|
||||||
self._llm_enabled = bool(cfg.base_url and cfg.base_url != "https://api.openai.com/v1")
|
|
||||||
self._llm_url = cfg.base_url
|
|
||||||
self._llm_model = cfg.rewrite_model
|
|
||||||
self._llm_key = cfg.api_key_env
|
|
||||||
|
|
||||||
# Key-binding capture state
|
|
||||||
self._bind_entry: Gtk.Entry | None = None
|
|
||||||
self._bind_pressed: list[str] = []
|
|
||||||
|
|
||||||
self._build()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Dialog shell
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _build(self) -> None:
|
|
||||||
self.dlg = Gtk.Dialog(title="Blitztext Setup")
|
|
||||||
if self._parent:
|
|
||||||
self.dlg.set_transient_for(self._parent)
|
|
||||||
self.dlg.set_modal(True)
|
|
||||||
self.dlg.set_default_size(580, 520)
|
|
||||||
self.dlg.set_resizable(False)
|
|
||||||
self.dlg.get_action_area().hide()
|
|
||||||
|
|
||||||
# Key capture
|
|
||||||
self.dlg.add_events(Gdk.EventMask.KEY_PRESS_MASK | Gdk.EventMask.KEY_RELEASE_MASK)
|
|
||||||
self.dlg.connect("key-press-event", self._on_key_press)
|
|
||||||
self.dlg.connect("key-release-event", self._on_key_release)
|
|
||||||
|
|
||||||
content = self.dlg.get_content_area()
|
|
||||||
content.set_spacing(0)
|
|
||||||
|
|
||||||
# Stack
|
|
||||||
self.stack = Gtk.Stack()
|
|
||||||
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
|
|
||||||
self.stack.set_transition_duration(180)
|
|
||||||
content.pack_start(self.stack, True, True, 0)
|
|
||||||
|
|
||||||
self._pg: dict[str, Gtk.Widget] = {}
|
|
||||||
builders = {
|
|
||||||
"welcome": self._build_welcome,
|
|
||||||
"trigger": self._build_trigger,
|
|
||||||
"keyboard": self._build_keyboard,
|
|
||||||
"wakeword": self._build_wakeword,
|
|
||||||
"stt": self._build_stt,
|
|
||||||
"llm": self._build_llm,
|
|
||||||
"done": self._build_done,
|
|
||||||
}
|
|
||||||
for name in self._ALL_PAGES:
|
|
||||||
w = builders[name]()
|
|
||||||
self.stack.add_named(w, name)
|
|
||||||
self._pg[name] = w
|
|
||||||
|
|
||||||
# Nav bar
|
|
||||||
sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
|
|
||||||
content.pack_start(sep, False, False, 0)
|
|
||||||
|
|
||||||
nav = Gtk.Box(spacing=8)
|
|
||||||
nav.set_margin_top(10); nav.set_margin_bottom(10)
|
|
||||||
nav.set_margin_start(20); nav.set_margin_end(20)
|
|
||||||
|
|
||||||
self._btn_back = Gtk.Button(label="← Back")
|
|
||||||
self._btn_back.connect("clicked", lambda _b: self._go(-1))
|
|
||||||
nav.pack_start(self._btn_back, False, False, 0)
|
|
||||||
|
|
||||||
self._step_lbl = Gtk.Label()
|
|
||||||
self._step_lbl.get_style_context().add_class("dim-label")
|
|
||||||
nav.pack_start(self._step_lbl, True, True, 0)
|
|
||||||
|
|
||||||
self._btn_skip = Gtk.Button(label="Skip")
|
|
||||||
self._btn_skip.get_style_context().add_class("flat")
|
|
||||||
self._btn_skip.connect("clicked", lambda _b: self._go(+1, skip=True))
|
|
||||||
nav.pack_start(self._btn_skip, False, False, 0)
|
|
||||||
|
|
||||||
self._btn_next = Gtk.Button(label="Next →")
|
|
||||||
self._btn_next.get_style_context().add_class("suggested-action")
|
|
||||||
self._btn_next.connect("clicked", lambda _b: self._go(+1))
|
|
||||||
nav.pack_start(self._btn_next, False, False, 0)
|
|
||||||
|
|
||||||
content.pack_start(nav, False, False, 0)
|
|
||||||
|
|
||||||
self._page_order: list[str] = []
|
|
||||||
self._idx = 0
|
|
||||||
self._refresh_page_order()
|
|
||||||
self._show(0)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Navigation
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _refresh_page_order(self) -> None:
|
|
||||||
pages = ["welcome", "trigger"]
|
|
||||||
if self._trigger in ("keyboard", "both"):
|
|
||||||
pages.append("keyboard")
|
|
||||||
if self._trigger in ("wakeword", "both"):
|
|
||||||
pages.append("wakeword")
|
|
||||||
pages += ["stt", "llm", "done"]
|
|
||||||
self._page_order = pages
|
|
||||||
|
|
||||||
def _show(self, idx: int) -> None:
|
|
||||||
self._idx = max(0, min(idx, len(self._page_order) - 1))
|
|
||||||
name = self._page_order[self._idx]
|
|
||||||
self.stack.set_visible_child_name(name)
|
|
||||||
|
|
||||||
is_first = self._idx == 0
|
|
||||||
is_last = self._idx == len(self._page_order) - 1
|
|
||||||
is_welcome = name == "welcome"
|
|
||||||
is_done = name == "done"
|
|
||||||
|
|
||||||
self._btn_back.set_sensitive(not is_first)
|
|
||||||
self._btn_skip.set_visible(not is_welcome and not is_done)
|
|
||||||
self._btn_next.set_label("Start dictating →" if is_last else "Next →")
|
|
||||||
|
|
||||||
# Step counter (exclude welcome and done from the count)
|
|
||||||
core_pages = [p for p in self._page_order if p not in ("welcome", "done")]
|
|
||||||
if name in core_pages:
|
|
||||||
step = core_pages.index(name) + 1
|
|
||||||
self._step_lbl.set_text(f"Step {step} of {len(core_pages)}")
|
|
||||||
else:
|
|
||||||
self._step_lbl.set_text("")
|
|
||||||
|
|
||||||
# When leaving "trigger", re-evaluate page order.
|
|
||||||
if name == "trigger":
|
|
||||||
self._read_trigger()
|
|
||||||
self._refresh_page_order()
|
|
||||||
|
|
||||||
def _go(self, direction: int, skip: bool = False) -> None:
|
|
||||||
name = self._page_order[self._idx]
|
|
||||||
if not skip:
|
|
||||||
self._commit_page(name)
|
|
||||||
if direction > 0 and self._idx >= len(self._page_order) - 1:
|
|
||||||
self._finish()
|
|
||||||
return
|
|
||||||
self._show(self._idx + direction)
|
|
||||||
|
|
||||||
def _finish(self) -> None:
|
|
||||||
self._apply_to_cfg()
|
|
||||||
self.dlg.response(Gtk.ResponseType.OK)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Page builders
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _build_welcome(self) -> Gtk.Widget:
|
|
||||||
box = _page_box()
|
|
||||||
box.set_valign(Gtk.Align.CENTER)
|
|
||||||
|
|
||||||
logo = Gtk.Label(label="🎙")
|
|
||||||
attrs = Pango.AttrList()
|
|
||||||
attrs.insert(Pango.attr_scale_new(4.0))
|
|
||||||
logo.set_attributes(attrs)
|
|
||||||
logo.set_margin_bottom(12)
|
|
||||||
box.pack_start(logo, False, False, 0)
|
|
||||||
|
|
||||||
box.pack_start(_h1("Welcome to Blitztext"), False, False, 0)
|
|
||||||
box.pack_start(_sub(
|
|
||||||
"Blitztext lets you speak and have your words typed anywhere on screen — "
|
|
||||||
"with optional AI polishing.\n\n"
|
|
||||||
"This short wizard sets up the basics. "
|
|
||||||
"You can change everything later in Settings."
|
|
||||||
), False, False, 0)
|
|
||||||
return box
|
|
||||||
|
|
||||||
def _build_trigger(self) -> Gtk.Widget:
|
|
||||||
box = _page_box()
|
|
||||||
box.pack_start(_h1("How do you want to trigger recording?"), False, False, 0)
|
|
||||||
box.pack_start(_sub("You can change this later in Settings → Input."), False, False, 0)
|
|
||||||
|
|
||||||
self._rb_kb, card_kb = _option_card("⌨", "Keyboard shortcut",
|
|
||||||
"Press a key combination to start and stop recording. "
|
|
||||||
"Great for desktop use.")
|
|
||||||
self._rb_ww, card_ww = _option_card("🎙", "Voice wakeword",
|
|
||||||
"Say a wake phrase like \"okay computer\" to start hands-free. "
|
|
||||||
"Needs a wakeword server.")
|
|
||||||
self._rb_both, card_both = _option_card("✨", "Both",
|
|
||||||
"Use a keyboard shortcut AND a voice wakeword — whichever is handy.")
|
|
||||||
|
|
||||||
# Group the radio buttons
|
|
||||||
self._rb_ww.join_group(self._rb_kb)
|
|
||||||
self._rb_both.join_group(self._rb_kb)
|
|
||||||
self._rb_kb.set_active(True)
|
|
||||||
|
|
||||||
for rb, card in ((self._rb_kb, card_kb), (self._rb_ww, card_ww),
|
|
||||||
(self._rb_both, card_both)):
|
|
||||||
# Make the card clickable by forwarding clicks to the radio button
|
|
||||||
ebox = Gtk.EventBox()
|
|
||||||
ebox.add(card)
|
|
||||||
ebox.connect("button-press-event",
|
|
||||||
lambda _e, _ev, r=rb: r.set_active(True))
|
|
||||||
box.pack_start(ebox, False, False, 0)
|
|
||||||
|
|
||||||
return box
|
|
||||||
|
|
||||||
def _build_keyboard(self) -> Gtk.Widget:
|
|
||||||
box = _page_box()
|
|
||||||
box.pack_start(_h1("Set up your keyboard shortcuts"), False, False, 0)
|
|
||||||
box.pack_start(_sub(
|
|
||||||
'Click "Set" and press a key combination to capture it. '
|
|
||||||
"The defaults shown here work well for most users."
|
|
||||||
), False, False, 0)
|
|
||||||
|
|
||||||
grid = Gtk.Grid(column_spacing=8, row_spacing=8)
|
|
||||||
grid.set_margin_top(16)
|
|
||||||
|
|
||||||
rows = [
|
|
||||||
("Start recording", self._kb_start, "_kb_start"),
|
|
||||||
("Stop and paste", self._kb_stop, "_kb_stop"),
|
|
||||||
("Stop, paste + Enter", self._kb_send, "_kb_send"),
|
|
||||||
("Cancel", self._kb_cancel, "_kb_cancel"),
|
|
||||||
]
|
|
||||||
self._kb_entries: dict[str, Gtk.Entry] = {}
|
|
||||||
|
|
||||||
for i, (label, value, attr) in enumerate(rows):
|
|
||||||
lbl = Gtk.Label(label=label, xalign=0.0)
|
|
||||||
lbl.set_size_request(180, -1)
|
|
||||||
grid.attach(lbl, 0, i, 1, 1)
|
|
||||||
|
|
||||||
entry = Gtk.Entry()
|
|
||||||
entry.set_text(value)
|
|
||||||
entry.set_size_request(140, -1)
|
|
||||||
entry.set_editable(False)
|
|
||||||
self._kb_entries[attr] = entry
|
|
||||||
grid.attach(entry, 1, i, 1, 1)
|
|
||||||
|
|
||||||
btn = Gtk.Button(label="Set")
|
|
||||||
btn.connect("clicked", lambda _b, e=entry: self._bind_key(e))
|
|
||||||
grid.attach(btn, 2, i, 1, 1)
|
|
||||||
|
|
||||||
box.pack_start(grid, False, False, 0)
|
|
||||||
|
|
||||||
note = Gtk.Label(xalign=0.0)
|
|
||||||
note.set_markup(
|
|
||||||
'<span size="small" alpha="75%">'
|
|
||||||
"Tip: use modifier keys (Ctrl, Alt, Win/Cmd) rather than letters "
|
|
||||||
"so shortcuts don't interfere with typing."
|
|
||||||
"</span>")
|
|
||||||
note.set_line_wrap(True)
|
|
||||||
note.set_margin_top(8)
|
|
||||||
box.pack_start(note, False, False, 0)
|
|
||||||
return box
|
|
||||||
|
|
||||||
def _build_wakeword(self) -> Gtk.Widget:
|
|
||||||
box = _page_box()
|
|
||||||
box.pack_start(_h1("Set up voice activation"), False, False, 0)
|
|
||||||
box.pack_start(_sub(
|
|
||||||
"Blitztext listens for your wake phrase through a local wakeword server. "
|
|
||||||
"The server runs on your machine — nothing is sent to the cloud."
|
|
||||||
), False, False, 0)
|
|
||||||
|
|
||||||
grid = Gtk.Grid(column_spacing=8, row_spacing=10)
|
|
||||||
grid.set_margin_top(16)
|
|
||||||
|
|
||||||
# Server URL
|
|
||||||
lbl_uri = Gtk.Label(label="Server URL", xalign=0.0)
|
|
||||||
lbl_uri.set_size_request(120, -1)
|
|
||||||
self._wiz_ww_uri = Gtk.Entry()
|
|
||||||
self._wiz_ww_uri.set_text(self._ww_uri)
|
|
||||||
self._wiz_ww_uri.set_hexpand(True)
|
|
||||||
self._wiz_ww_uri.set_placeholder_text("tcp://127.0.0.1:10400")
|
|
||||||
grid.attach(lbl_uri, 0, 0, 1, 1)
|
|
||||||
grid.attach(self._wiz_ww_uri, 1, 0, 1, 1)
|
|
||||||
|
|
||||||
# Model
|
|
||||||
lbl_model = Gtk.Label(label="Wake phrase", xalign=0.0)
|
|
||||||
self._wiz_ww_model = Gtk.Entry()
|
|
||||||
self._wiz_ww_model.set_text(self._ww_model)
|
|
||||||
self._wiz_ww_model.set_placeholder_text("okay_computer")
|
|
||||||
grid.attach(lbl_model, 0, 1, 1, 1)
|
|
||||||
grid.attach(self._wiz_ww_model, 1, 1, 1, 1)
|
|
||||||
|
|
||||||
box.pack_start(grid, False, False, 0)
|
|
||||||
|
|
||||||
# Test row
|
|
||||||
test_row = Gtk.Box(spacing=10)
|
|
||||||
test_row.set_margin_top(8)
|
|
||||||
self._wiz_ww_test_btn = Gtk.Button(label="Test connection")
|
|
||||||
self._wiz_ww_test_btn.connect("clicked", self._ww_test)
|
|
||||||
self._wiz_ww_test_lbl = Gtk.Label(label="", xalign=0.0)
|
|
||||||
test_row.pack_start(self._wiz_ww_test_btn, False, False, 0)
|
|
||||||
test_row.pack_start(self._wiz_ww_test_lbl, False, False, 0)
|
|
||||||
box.pack_start(test_row, False, False, 0)
|
|
||||||
|
|
||||||
# Setup hint (collapsible-ish — just always visible for simplicity)
|
|
||||||
hint = Gtk.Label(xalign=0.0)
|
|
||||||
hint.set_markup(
|
|
||||||
'<span size="small" alpha="75%">'
|
|
||||||
"Don't have a wakeword server yet? Run one with Docker:\n"
|
|
||||||
"<tt>docker run -it -p 10400:10400 homeassistant/wyoming-openwakeword</tt>"
|
|
||||||
"</span>")
|
|
||||||
hint.set_line_wrap(True)
|
|
||||||
hint.set_selectable(True)
|
|
||||||
hint.set_margin_top(12)
|
|
||||||
box.pack_start(hint, False, False, 0)
|
|
||||||
return box
|
|
||||||
|
|
||||||
def _build_stt(self) -> Gtk.Widget:
|
|
||||||
box = _page_box()
|
|
||||||
box.pack_start(_h1("Choose your speech-to-text engine"), False, False, 0)
|
|
||||||
box.pack_start(_sub(
|
|
||||||
"Blitztext transcribes your speech locally using Whisper, "
|
|
||||||
"or you can connect to a remote OpenAI-compatible API."
|
|
||||||
), False, False, 0)
|
|
||||||
|
|
||||||
# Local vs remote toggle
|
|
||||||
self._rb_local, card_local = _option_card(
|
|
||||||
"💻", "Local (faster-whisper)",
|
|
||||||
"Runs on your machine — private, no API key needed. "
|
|
||||||
"Choose the model size below.")
|
|
||||||
self._rb_remote, card_remote = _option_card(
|
|
||||||
"☁", "Remote API",
|
|
||||||
"Send audio to an OpenAI-compatible endpoint "
|
|
||||||
"(OpenAI, local Whisper server, etc.).")
|
|
||||||
self._rb_remote.join_group(self._rb_local)
|
|
||||||
self._rb_local.set_active(True)
|
|
||||||
|
|
||||||
for rb, card in ((self._rb_local, card_local), (self._rb_remote, card_remote)):
|
|
||||||
ebox = Gtk.EventBox()
|
|
||||||
ebox.add(card)
|
|
||||||
ebox.connect("button-press-event",
|
|
||||||
lambda _e, _ev, r=rb: (r.set_active(True),
|
|
||||||
self._stt_toggle()))
|
|
||||||
box.pack_start(ebox, False, False, 0)
|
|
||||||
rb.connect("toggled", lambda _r: self._stt_toggle())
|
|
||||||
|
|
||||||
# Local options
|
|
||||||
self._stt_local_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
|
||||||
self._stt_local_box.set_margin_top(8)
|
|
||||||
size_lbl = Gtk.Label(label="Model size", xalign=0.0)
|
|
||||||
size_lbl.get_style_context().add_class("dim-label")
|
|
||||||
|
|
||||||
self._stt_size_rb: dict[str, Gtk.RadioButton] = {}
|
|
||||||
sizes = [
|
|
||||||
("tiny", "Tiny — fastest, basic accuracy"),
|
|
||||||
("base", "Base — fast, decent accuracy"),
|
|
||||||
("small", "Small — balanced ✓ recommended"),
|
|
||||||
("medium", "Medium — better accuracy, slower"),
|
|
||||||
("large-v3", "Large — best accuracy, most memory"),
|
|
||||||
]
|
|
||||||
size_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
|
|
||||||
prev = None
|
|
||||||
for key, desc in sizes:
|
|
||||||
rb = Gtk.RadioButton(label=desc)
|
|
||||||
if prev:
|
|
||||||
rb.join_group(prev)
|
|
||||||
rb.set_active(key == self._stt_size)
|
|
||||||
rb.connect("toggled", lambda r, k=key: r.get_active() and
|
|
||||||
setattr(self, "_stt_size", k))
|
|
||||||
self._stt_size_rb[key] = rb
|
|
||||||
size_box.pack_start(rb, False, False, 0)
|
|
||||||
prev = rb
|
|
||||||
self._stt_local_box.pack_start(size_lbl, False, False, 0)
|
|
||||||
self._stt_local_box.pack_start(size_box, False, False, 0)
|
|
||||||
box.pack_start(self._stt_local_box, False, False, 0)
|
|
||||||
|
|
||||||
# Remote options
|
|
||||||
self._stt_remote_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
|
||||||
self._stt_remote_box.set_margin_top(8)
|
|
||||||
rgrid = Gtk.Grid(column_spacing=8, row_spacing=6)
|
|
||||||
rlbl_url = Gtk.Label(label="API URL", xalign=0.0); rlbl_url.set_size_request(100, -1)
|
|
||||||
self._stt_url_entry = Gtk.Entry(); self._stt_url_entry.set_hexpand(True)
|
|
||||||
self._stt_url_entry.set_text(self._stt_url or "http://localhost:8010/v1")
|
|
||||||
self._stt_url_entry.set_placeholder_text("http://localhost:8010/v1")
|
|
||||||
rlbl_key = Gtk.Label(label="API key env", xalign=0.0)
|
|
||||||
self._stt_key_entry = Gtk.Entry(); self._stt_key_entry.set_hexpand(True)
|
|
||||||
self._stt_key_entry.set_text(self._stt_key)
|
|
||||||
self._stt_key_entry.set_placeholder_text("OPENAI_API_KEY (leave empty if not needed)")
|
|
||||||
rgrid.attach(rlbl_url, 0, 0, 1, 1); rgrid.attach(self._stt_url_entry, 1, 0, 1, 1)
|
|
||||||
rgrid.attach(rlbl_key, 0, 1, 1, 1); rgrid.attach(self._stt_key_entry, 1, 1, 1, 1)
|
|
||||||
self._stt_remote_box.pack_start(rgrid, False, False, 0)
|
|
||||||
self._stt_remote_box.set_no_show_all(True)
|
|
||||||
box.pack_start(self._stt_remote_box, False, False, 0)
|
|
||||||
|
|
||||||
first_run_note = Gtk.Label(xalign=0.0)
|
|
||||||
first_run_note.set_markup(
|
|
||||||
'<span size="small" alpha="65%">'
|
|
||||||
"The local model is downloaded the first time you use it (~500 MB for Small). "
|
|
||||||
"Subsequent starts are instant."
|
|
||||||
"</span>")
|
|
||||||
first_run_note.set_line_wrap(True)
|
|
||||||
first_run_note.set_margin_top(6)
|
|
||||||
box.pack_start(first_run_note, False, False, 0)
|
|
||||||
return box
|
|
||||||
|
|
||||||
def _build_llm(self) -> Gtk.Widget:
|
|
||||||
box = _page_box()
|
|
||||||
box.pack_start(_h1("AI text processing (optional)"), False, False, 0)
|
|
||||||
box.pack_start(_sub(
|
|
||||||
"Blitztext can send your transcript to an AI model that rewrites it — "
|
|
||||||
"cleaning up speech artifacts, adjusting tone, or reformatting it."
|
|
||||||
), False, False, 0)
|
|
||||||
|
|
||||||
enable_row = Gtk.Box(spacing=10)
|
|
||||||
enable_row.set_margin_top(12)
|
|
||||||
self._llm_switch = Gtk.Switch()
|
|
||||||
self._llm_switch.set_active(self._llm_enabled)
|
|
||||||
self._llm_switch.set_valign(Gtk.Align.CENTER)
|
|
||||||
enable_lbl = Gtk.Label(label="Enable AI rewriting", xalign=0.0)
|
|
||||||
enable_row.pack_start(self._llm_switch, False, False, 0)
|
|
||||||
enable_row.pack_start(enable_lbl, False, False, 0)
|
|
||||||
box.pack_start(enable_row, False, False, 0)
|
|
||||||
|
|
||||||
self._llm_detail = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
|
||||||
self._llm_detail.set_margin_top(10)
|
|
||||||
|
|
||||||
lgrid = Gtk.Grid(column_spacing=8, row_spacing=8)
|
|
||||||
llbl_url = Gtk.Label(label="API URL", xalign=0.0); llbl_url.set_size_request(120, -1)
|
|
||||||
self._llm_url_entry = Gtk.Entry(); self._llm_url_entry.set_hexpand(True)
|
|
||||||
self._llm_url_entry.set_text(self._llm_url)
|
|
||||||
self._llm_url_entry.set_placeholder_text("https://api.openai.com/v1")
|
|
||||||
|
|
||||||
llbl_model = Gtk.Label(label="Model", xalign=0.0)
|
|
||||||
self._llm_model_entry = Gtk.Entry(); self._llm_model_entry.set_hexpand(True)
|
|
||||||
self._llm_model_entry.set_text(self._llm_model)
|
|
||||||
self._llm_model_entry.set_placeholder_text("gpt-4o-mini")
|
|
||||||
|
|
||||||
llbl_key = Gtk.Label(label="API key env", xalign=0.0)
|
|
||||||
self._llm_key_entry = Gtk.Entry(); self._llm_key_entry.set_hexpand(True)
|
|
||||||
self._llm_key_entry.set_text(self._llm_key)
|
|
||||||
self._llm_key_entry.set_placeholder_text("OPENAI_API_KEY")
|
|
||||||
|
|
||||||
lgrid.attach(llbl_url, 0, 0, 1, 1); lgrid.attach(self._llm_url_entry, 1, 0, 1, 1)
|
|
||||||
lgrid.attach(llbl_model, 0, 1, 1, 1); lgrid.attach(self._llm_model_entry, 1, 1, 1, 1)
|
|
||||||
lgrid.attach(llbl_key, 0, 2, 1, 1); lgrid.attach(self._llm_key_entry, 1, 2, 1, 1)
|
|
||||||
self._llm_detail.pack_start(lgrid, False, False, 0)
|
|
||||||
|
|
||||||
llm_note = Gtk.Label(xalign=0.0)
|
|
||||||
llm_note.set_markup(
|
|
||||||
'<span size="small" alpha="65%">'
|
|
||||||
"Works with OpenAI, or a local model via Ollama / LM Studio / vLLM. "
|
|
||||||
"You can fine-tune prompts per workflow in Settings → Workflows."
|
|
||||||
"</span>")
|
|
||||||
llm_note.set_line_wrap(True)
|
|
||||||
self._llm_detail.pack_start(llm_note, False, False, 0)
|
|
||||||
|
|
||||||
box.pack_start(self._llm_detail, False, False, 0)
|
|
||||||
|
|
||||||
self._llm_switch.connect("notify::active", lambda s, _p: self._llm_toggle())
|
|
||||||
self._llm_toggle()
|
|
||||||
return box
|
|
||||||
|
|
||||||
def _build_done(self) -> Gtk.Widget:
|
|
||||||
box = _page_box()
|
|
||||||
box.set_valign(Gtk.Align.CENTER)
|
|
||||||
|
|
||||||
lbl_done = Gtk.Label(label="✅")
|
|
||||||
attrs = Pango.AttrList()
|
|
||||||
attrs.insert(Pango.attr_scale_new(3.5))
|
|
||||||
lbl_done.set_attributes(attrs)
|
|
||||||
lbl_done.set_margin_bottom(12)
|
|
||||||
box.pack_start(lbl_done, False, False, 0)
|
|
||||||
|
|
||||||
box.pack_start(_h1("You're all set!"), False, False, 0)
|
|
||||||
self._done_summary = Gtk.Label(xalign=0.0)
|
|
||||||
self._done_summary.set_line_wrap(True)
|
|
||||||
self._done_summary.set_max_width_chars(60)
|
|
||||||
self._done_summary.get_style_context().add_class("dim-label")
|
|
||||||
box.pack_start(self._done_summary, False, False, 0)
|
|
||||||
|
|
||||||
tip = Gtk.Label(xalign=0.0)
|
|
||||||
tip.set_markup(
|
|
||||||
'\n<span size="small" alpha="70%">'
|
|
||||||
"You can always open <b>Settings</b> from the system tray to add workflows, "
|
|
||||||
"tune STT quality, or configure more shortcuts."
|
|
||||||
"</span>")
|
|
||||||
tip.set_line_wrap(True)
|
|
||||||
box.pack_start(tip, False, False, 0)
|
|
||||||
return box
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Page commit helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _read_trigger(self) -> None:
|
|
||||||
if self._rb_ww.get_active():
|
|
||||||
self._trigger = "wakeword"
|
|
||||||
elif self._rb_both.get_active():
|
|
||||||
self._trigger = "both"
|
|
||||||
else:
|
|
||||||
self._trigger = "keyboard"
|
|
||||||
|
|
||||||
def _commit_page(self, name: str) -> None:
|
|
||||||
if name == "trigger":
|
|
||||||
self._read_trigger()
|
|
||||||
self._refresh_page_order()
|
|
||||||
elif name == "keyboard":
|
|
||||||
for attr, entry in self._kb_entries.items():
|
|
||||||
setattr(self, attr, entry.get_text().strip())
|
|
||||||
elif name == "wakeword":
|
|
||||||
self._ww_uri = self._wiz_ww_uri.get_text().strip()
|
|
||||||
self._ww_model = self._wiz_ww_model.get_text().strip()
|
|
||||||
elif name == "stt":
|
|
||||||
self._stt_local = self._rb_local.get_active()
|
|
||||||
if not self._stt_local:
|
|
||||||
self._stt_url = self._stt_url_entry.get_text().strip()
|
|
||||||
self._stt_key = self._stt_key_entry.get_text().strip()
|
|
||||||
elif name == "llm":
|
|
||||||
self._llm_enabled = self._llm_switch.get_active()
|
|
||||||
self._llm_url = self._llm_url_entry.get_text().strip()
|
|
||||||
self._llm_model = self._llm_model_entry.get_text().strip()
|
|
||||||
self._llm_key = self._llm_key_entry.get_text().strip()
|
|
||||||
self._update_done_summary()
|
|
||||||
|
|
||||||
def _update_done_summary(self) -> None:
|
|
||||||
lines: list[str] = []
|
|
||||||
if self._trigger in ("keyboard", "both"):
|
|
||||||
lines.append(f"⌨ Keyboard — start: {self._kb_start}, cancel: {self._kb_cancel}")
|
|
||||||
if self._trigger in ("wakeword", "both"):
|
|
||||||
lines.append(f"🎙 Wakeword — {self._ww_model!r} @ {self._ww_uri}")
|
|
||||||
if self._stt_local:
|
|
||||||
lines.append(f"🤖 Local Whisper ({self._stt_size})")
|
|
||||||
else:
|
|
||||||
lines.append(f"☁ Remote STT — {self._stt_url}")
|
|
||||||
if self._llm_enabled:
|
|
||||||
lines.append(f"✨ AI rewriting — {self._llm_model} @ {self._llm_url}")
|
|
||||||
else:
|
|
||||||
lines.append("✨ AI rewriting — disabled")
|
|
||||||
if hasattr(self, "_done_summary"):
|
|
||||||
self._done_summary.set_text("\n".join(lines))
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Apply to config
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _apply_to_cfg(self) -> None:
|
|
||||||
from .config import save
|
|
||||||
from .stt import STTEngine
|
|
||||||
cfg = self.cfg
|
|
||||||
|
|
||||||
# Trigger / keyboard mode
|
|
||||||
if self._trigger in ("keyboard", "both"):
|
|
||||||
cfg.input_mode = "modifiers"
|
|
||||||
cfg.key_start = self._kb_start
|
|
||||||
cfg.key_stop = self._kb_stop
|
|
||||||
cfg.key_send = self._kb_send
|
|
||||||
cfg.key_cancel = self._kb_cancel
|
|
||||||
|
|
||||||
# Wakeword
|
|
||||||
cfg.wakeword_enabled = self._trigger in ("wakeword", "both")
|
|
||||||
if cfg.wakeword_enabled:
|
|
||||||
cfg.wakeword_uri = self._ww_uri
|
|
||||||
cfg.wakeword_model = self._ww_model
|
|
||||||
from .config import WakewordEngine
|
|
||||||
# Update or create the first engine preset.
|
|
||||||
if cfg.wakeword_engines:
|
|
||||||
cfg.wakeword_engines[0].uri = self._ww_uri
|
|
||||||
cfg.wakeword_engines[0].model = self._ww_model
|
|
||||||
else:
|
|
||||||
cfg.wakeword_engines = [WakewordEngine(
|
|
||||||
name="Default", uri=self._ww_uri, model=self._ww_model)]
|
|
||||||
cfg.wakeword_active = cfg.wakeword_engines[0].name
|
|
||||||
|
|
||||||
# STT
|
|
||||||
if self._stt_local:
|
|
||||||
local_eng = STTEngine(name="Local Whisper", type="local",
|
|
||||||
model=self._stt_size)
|
|
||||||
# Replace or add.
|
|
||||||
locals_ = [e for e in cfg.stt_engines if e.is_local]
|
|
||||||
if locals_:
|
|
||||||
idx = cfg.stt_engines.index(locals_[0])
|
|
||||||
cfg.stt_engines[idx] = local_eng
|
|
||||||
else:
|
|
||||||
cfg.stt_engines.insert(0, local_eng)
|
|
||||||
cfg.stt_active = local_eng.name
|
|
||||||
cfg.model = self._stt_size
|
|
||||||
else:
|
|
||||||
remote_eng = STTEngine(name="Remote STT", type="openai",
|
|
||||||
url=self._stt_url, api_key_env=self._stt_key)
|
|
||||||
remotes = [e for e in cfg.stt_engines if not e.is_local]
|
|
||||||
if remotes:
|
|
||||||
idx = cfg.stt_engines.index(remotes[0])
|
|
||||||
cfg.stt_engines[idx] = remote_eng
|
|
||||||
else:
|
|
||||||
cfg.stt_engines.append(remote_eng)
|
|
||||||
cfg.stt_active = remote_eng.name
|
|
||||||
|
|
||||||
# LLM
|
|
||||||
if self._llm_enabled:
|
|
||||||
cfg.base_url = self._llm_url
|
|
||||||
cfg.rewrite_model = self._llm_model
|
|
||||||
cfg.api_key_env = self._llm_key
|
|
||||||
from .llm import LLMEngine
|
|
||||||
llm_eng = LLMEngine("Default", self._llm_url,
|
|
||||||
self._llm_model, self._llm_key)
|
|
||||||
if cfg.llm_engines:
|
|
||||||
cfg.llm_engines[0] = llm_eng
|
|
||||||
else:
|
|
||||||
cfg.llm_engines = [llm_eng]
|
|
||||||
cfg.llm_active = llm_eng.name
|
|
||||||
|
|
||||||
# Mark setup as complete so the wizard doesn't auto-show again.
|
|
||||||
cfg.setup_complete = True
|
|
||||||
save(cfg)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# UI toggle helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _stt_toggle(self) -> None:
|
|
||||||
local = self._rb_local.get_active()
|
|
||||||
self._stt_local_box.set_visible(local)
|
|
||||||
self._stt_remote_box.set_visible(not local)
|
|
||||||
|
|
||||||
def _llm_toggle(self) -> None:
|
|
||||||
on = self._llm_switch.get_active()
|
|
||||||
self._llm_detail.set_sensitive(on)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Wakeword connection test
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _ww_test(self, _btn) -> None:
|
|
||||||
uri = self._wiz_ww_uri.get_text().strip()
|
|
||||||
self._wiz_ww_test_lbl.set_text("Testing…")
|
|
||||||
self._wiz_ww_test_btn.set_sensitive(False)
|
|
||||||
|
|
||||||
def _probe():
|
|
||||||
import socket as _socket
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
p = urlparse(uri)
|
|
||||||
host = p.hostname or "127.0.0.1"
|
|
||||||
port = p.port or 10400
|
|
||||||
try:
|
|
||||||
with _socket.create_connection((host, port), timeout=3.0):
|
|
||||||
ok = True
|
|
||||||
except OSError:
|
|
||||||
ok = False
|
|
||||||
|
|
||||||
def _update():
|
|
||||||
self._wiz_ww_test_btn.set_sensitive(True)
|
|
||||||
if ok:
|
|
||||||
self._wiz_ww_test_lbl.set_markup(
|
|
||||||
'<span foreground="#2a7d2a">✓ Connected</span>')
|
|
||||||
else:
|
|
||||||
self._wiz_ww_test_lbl.set_markup(
|
|
||||||
'<span foreground="#cc3333">✗ Could not connect — is the server running?</span>')
|
|
||||||
GLib.idle_add(_update)
|
|
||||||
|
|
||||||
threading.Thread(target=_probe, daemon=True).start()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Key-binding capture
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _bind_key(self, entry: Gtk.Entry) -> None:
|
|
||||||
self._bind_entry = entry
|
|
||||||
self._bind_pressed = []
|
|
||||||
entry.set_text("")
|
|
||||||
entry.set_placeholder_text("press the key combination…")
|
|
||||||
|
|
||||||
def _on_key_press(self, _w, event) -> bool:
|
|
||||||
if self._bind_entry is None:
|
|
||||||
return False
|
|
||||||
tok = _keyval_token(event.keyval)
|
|
||||||
if tok and tok not in self._bind_pressed:
|
|
||||||
self._bind_pressed.append(tok)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _on_key_release(self, _w, event) -> bool:
|
|
||||||
if self._bind_entry is None:
|
|
||||||
return False
|
|
||||||
combo = _format_combo(self._bind_pressed)
|
|
||||||
if combo:
|
|
||||||
self._bind_entry.set_text(combo)
|
|
||||||
self._bind_entry.set_placeholder_text("")
|
|
||||||
self._bind_entry = None
|
|
||||||
self._bind_pressed = []
|
|
||||||
return True
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Public entry point
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def run(self) -> None:
|
|
||||||
self.dlg.show_all()
|
|
||||||
# Hide remote STT box initially (local is default)
|
|
||||||
self._stt_remote_box.hide()
|
|
||||||
self.dlg.run()
|
|
||||||
self.dlg.destroy()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Convenience: show if this is a fresh install
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def maybe_show(cfg, parent: Gtk.Window | None = None) -> None:
|
|
||||||
"""Show the wizard if setup has never been completed."""
|
|
||||||
if not getattr(cfg, "setup_complete", False):
|
|
||||||
wiz = SetupWizard(cfg, parent=parent)
|
|
||||||
wiz.run()
|
|
||||||
130
linux/blitztext/sound.py
Executable file → Normal file
@ -1,140 +1,38 @@
|
|||||||
"""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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import stat
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_FREEDESKTOP = "/usr/share/sounds/freedesktop/stereo/{name}.oga"
|
_FREEDESKTOP = "/usr/share/sounds/freedesktop/stereo/{name}.oga"
|
||||||
|
_PLAYERS = ("pw-play", "paplay", "aplay")
|
||||||
# (player, extra_args, wav_only)
|
|
||||||
# Ordered: native PipeWire/PulseAudio first, then ffplay/gst as universal fallback.
|
|
||||||
_PLAYERS: list[tuple[str, list[str], bool]] = [
|
|
||||||
("pw-play", [], False),
|
|
||||||
("paplay", [], False),
|
|
||||||
("aplay", [], True), # WAV only
|
|
||||||
("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet"], False),
|
|
||||||
("gst-play-1.0", [], False),
|
|
||||||
]
|
|
||||||
_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:
|
def play(path: str = "", *, fallback: str | None = None) -> None:
|
||||||
"""Validate a user-configured sound file path.
|
"""Play `path` (a WAV/OGA file); if unset/missing, play the freedesktop
|
||||||
|
`fallback` system sound. Returns immediately (fire-and-forget)."""
|
||||||
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 = ""
|
target = ""
|
||||||
if path:
|
if path:
|
||||||
safe = validate_sound_path(path)
|
expanded = os.path.expanduser(path)
|
||||||
if safe is not None:
|
if os.path.exists(expanded):
|
||||||
target = safe
|
target = expanded
|
||||||
if not target and fallback:
|
if not target and fallback:
|
||||||
fd = _FREEDESKTOP.format(name=fallback)
|
fd = _FREEDESKTOP.format(name=fallback)
|
||||||
if os.path.exists(fd):
|
if os.path.exists(fd):
|
||||||
target = fd
|
target = fd
|
||||||
if not target:
|
if not target:
|
||||||
return None
|
return
|
||||||
|
|
||||||
ext = os.path.splitext(target)[1].lower()
|
# aplay only handles WAV; pw-play/paplay handle WAV + OGA, so try them first.
|
||||||
for player, extra, wav_only in _PLAYERS:
|
for player in _PLAYERS:
|
||||||
if not shutil.which(player):
|
if not shutil.which(player):
|
||||||
continue
|
continue
|
||||||
if wav_only and ext != ".wav":
|
if player == "aplay" and not target.lower().endswith(".wav"):
|
||||||
continue
|
|
||||||
if player == "paplay" and ext not in _NATIVE_EXTS:
|
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen(
|
subprocess.Popen([player, target], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
[player] + extra + [target],
|
return
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
return proc
|
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
return None
|
|
||||||
@ -27,7 +27,6 @@ class STTEngine:
|
|||||||
url: str = "" # base URL incl. /v1 for remote, e.g. http://localhost:8010/v1
|
url: str = "" # base URL incl. /v1 for remote, e.g. http://localhost:8010/v1
|
||||||
model: str = "" # remote model id, or local whisper size override
|
model: str = "" # remote model id, or local whisper size override
|
||||||
api_key_env: str = "" # env var holding a bearer key (optional)
|
api_key_env: str = "" # env var holding a bearer key (optional)
|
||||||
timeout: int = 30 # HTTP timeout in seconds; raise for slow servers (WhisperX, etc.)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_local(self) -> bool:
|
def is_local(self) -> bool:
|
||||||
@ -62,169 +61,31 @@ def status(engine: STTEngine, timeout: float = 2.0) -> bool:
|
|||||||
return reachable(engine.url, timeout)
|
return reachable(engine.url, timeout)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ModelMeta:
|
|
||||||
"""Model id plus optional metadata (languages, etc.) from the server."""
|
|
||||||
id: str
|
|
||||||
languages: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
def fmt_languages(langs: list[str]) -> str:
|
|
||||||
"""Compact display string for a language list, e.g. 'en, de, fr +45'."""
|
|
||||||
if not langs:
|
|
||||||
return "—"
|
|
||||||
if len(langs) >= 50:
|
|
||||||
return f"multilingual ({len(langs)})"
|
|
||||||
if len(langs) > 5:
|
|
||||||
return f"{', '.join(langs[:5])} +{len(langs) - 5}"
|
|
||||||
return ", ".join(langs)
|
|
||||||
|
|
||||||
|
|
||||||
def _api_base(url: str) -> str:
|
|
||||||
"""Strip endpoint-specific path suffixes so metadata/model probes hit the right root.
|
|
||||||
|
|
||||||
http://host:8081/v1/transcribe → http://host:8081/v1
|
|
||||||
http://host:8081/transcribe → http://host:8081
|
|
||||||
http://host:8081/v1 → http://host:8081/v1 (unchanged)
|
|
||||||
"""
|
|
||||||
from urllib.parse import urlparse, urlunparse
|
|
||||||
p = urlparse(url.rstrip("/"))
|
|
||||||
path = p.path.rstrip("/")
|
|
||||||
v1_idx = path.find("/v1")
|
|
||||||
if v1_idx >= 0:
|
|
||||||
path = path[:v1_idx + 3] # keep up to and including /v1
|
|
||||||
elif path not in ("", "/"):
|
|
||||||
path = "" # strip unknown custom suffix entirely
|
|
||||||
return urlunparse((p.scheme, p.netloc, path, "", "", ""))
|
|
||||||
|
|
||||||
|
|
||||||
def list_models(base_url: str, api_key_env: str = "", timeout: float = 5.0) -> list[str]:
|
def list_models(base_url: str, api_key_env: str = "", timeout: float = 5.0) -> list[str]:
|
||||||
"""Fetch model ids from an OpenAI-compatible, Ollama-style, or Riva/NIM /models endpoint."""
|
"""Fetch model ids from an OpenAI-compatible (or Ollama-style) /models endpoint."""
|
||||||
return [m.id for m in list_models_meta(base_url, api_key_env, timeout)]
|
|
||||||
|
|
||||||
|
|
||||||
def list_models_meta(base_url: str, api_key_env: str = "", timeout: float = 5.0) -> list[ModelMeta]:
|
|
||||||
"""Like list_models() but returns ModelMeta with language info when available."""
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
if not base_url:
|
if not base_url:
|
||||||
return []
|
return []
|
||||||
base = _api_base(base_url)
|
url = base_url.rstrip("/") + "/models"
|
||||||
headers: dict[str, str] = {}
|
headers = {}
|
||||||
key = os.environ.get(api_key_env) if api_key_env else None
|
key = os.environ.get(api_key_env) if api_key_env else None
|
||||||
if key:
|
if key:
|
||||||
headers["Authorization"] = f"Bearer {key}"
|
headers["Authorization"] = f"Bearer {key}"
|
||||||
|
|
||||||
def _get(url: str) -> dict | None:
|
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, headers=headers)
|
req = urllib.request.Request(url, headers=headers)
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
return json.loads(resp.read().decode("utf-8"))
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
except (urllib.error.URLError, json.JSONDecodeError, OSError):
|
except (urllib.error.URLError, json.JSONDecodeError, OSError):
|
||||||
return None
|
|
||||||
|
|
||||||
# 1. Standard OpenAI /models — faster-whisper-server also returns "language"
|
|
||||||
data = _get(base + "/models")
|
|
||||||
if isinstance(data, dict):
|
|
||||||
items = data.get("data")
|
|
||||||
if isinstance(items, list):
|
|
||||||
result = [ModelMeta(id=m["id"], languages=m.get("language") or [])
|
|
||||||
for m in items if isinstance(m, dict) and m.get("id")]
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
items = data.get("models")
|
|
||||||
if isinstance(items, list): # Ollama shape
|
|
||||||
return [ModelMeta(id=m.get("name") or m.get("model", ""))
|
|
||||||
for m in items if m.get("name") or m.get("model")]
|
|
||||||
|
|
||||||
# 2. NVIDIA Riva / NIM
|
|
||||||
data = _get(base + "/metadata")
|
|
||||||
if isinstance(data, dict):
|
|
||||||
for info in data.get("modelInfo") or []:
|
|
||||||
name = info.get("shortName") or info.get("modelUrl") or ""
|
|
||||||
if name:
|
|
||||||
return [ModelMeta(id=name.split(":")[0])]
|
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
items = data.get("data") if isinstance(data, dict) else None
|
||||||
def probe_server_ram_mb(base_url: str, timeout: float = 3.0) -> float | None:
|
if isinstance(items, list): # OpenAI shape: {"data":[{"id":...}]}
|
||||||
"""Probe a remote server's Prometheus /metrics endpoint for RSS memory.
|
return [m["id"] for m in items if isinstance(m, dict) and m.get("id")]
|
||||||
|
items = data.get("models") if isinstance(data, dict) else None
|
||||||
Returns RSS in MB if the server exposes Prometheus metrics with either
|
if isinstance(items, list): # Ollama shape: {"models":[{"name"/"model":...}]}
|
||||||
``process_resident_memory_bytes`` (standard Python/Go exporter) or
|
return [m.get("name") or m.get("model") for m in items if (m.get("name") or m.get("model"))]
|
||||||
``container_memory_rss`` (cAdvisor). Returns None if not available.
|
return []
|
||||||
|
|
||||||
Tries the server root first (http://host:port/metrics) then the api base
|
|
||||||
path (http://host:port/v1/metrics), because Prometheus endpoints are almost
|
|
||||||
always mounted at root even when the API lives under /v1.
|
|
||||||
"""
|
|
||||||
if not base_url:
|
|
||||||
return None
|
|
||||||
p = urlparse(base_url.rstrip("/"))
|
|
||||||
root = f"{p.scheme}://{p.netloc}"
|
|
||||||
candidates = [root + "/metrics"]
|
|
||||||
api_base_metrics = _api_base(base_url) + "/metrics"
|
|
||||||
if api_base_metrics != candidates[0]:
|
|
||||||
candidates.append(api_base_metrics)
|
|
||||||
|
|
||||||
def _parse(text: str) -> float | None:
|
|
||||||
for line in text.splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if line.startswith("#"):
|
|
||||||
continue
|
|
||||||
for metric in ("process_resident_memory_bytes", "container_memory_rss"):
|
|
||||||
if line.startswith(metric):
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts) >= 2:
|
|
||||||
try:
|
|
||||||
return float(parts[-1]) / (1024 * 1024) # bytes → MB
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
for url in candidates:
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(url)
|
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
||||||
result = _parse(resp.read().decode("utf-8", "replace"))
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def detect_remote_device(base_url: str, timeout: float = 3.0) -> str:
|
|
||||||
"""Best-effort GPU/CPU detection for a remote STT server.
|
|
||||||
|
|
||||||
Tries faster-whisper-server's /info endpoint (returns {"device":"cuda",...}),
|
|
||||||
then NVIDIA NIM /metadata (GPU-only service). Falls back to "remote".
|
|
||||||
"""
|
|
||||||
if not base_url:
|
|
||||||
return "remote"
|
|
||||||
base = _api_base(base_url)
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(base + "/info")
|
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
|
||||||
if isinstance(data, dict):
|
|
||||||
dev = str(data.get("device") or data.get("compute_type") or "")
|
|
||||||
if "cuda" in dev.lower():
|
|
||||||
return "CUDA"
|
|
||||||
if dev:
|
|
||||||
return dev.upper()[:16]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(base + "/metadata")
|
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
|
||||||
if isinstance(data, dict) and data.get("modelInfo"):
|
|
||||||
return "CUDA" # NVIDIA NIM is always GPU
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return "remote"
|
|
||||||
|
|
||||||
|
|
||||||
def _host_port(url: str) -> tuple[str | None, int]:
|
def _host_port(url: str) -> tuple[str | None, int]:
|
||||||
@ -244,7 +105,7 @@ def transcribe(
|
|||||||
language: str = "",
|
language: str = "",
|
||||||
hotwords: str = "",
|
hotwords: str = "",
|
||||||
local_transcriber=None,
|
local_transcriber=None,
|
||||||
timeout: int = 30,
|
timeout: int = 60,
|
||||||
) -> str:
|
) -> str:
|
||||||
if engine.is_local:
|
if engine.is_local:
|
||||||
if local_transcriber is None:
|
if local_transcriber is None:
|
||||||
@ -252,24 +113,15 @@ def transcribe(
|
|||||||
return local_transcriber.transcribe(audio_path, language=language, hotwords=hotwords)
|
return local_transcriber.transcribe(audio_path, language=language, hotwords=hotwords)
|
||||||
if engine.is_streaming:
|
if engine.is_streaming:
|
||||||
raise STTError("Streaming STT engines are live-only. Use a workflow with mode = \"stream\".")
|
raise STTError("Streaming STT engines are live-only. Use a workflow with mode = \"stream\".")
|
||||||
return _transcribe_remote(engine, audio_path, language=language, prompt=hotwords,
|
return _transcribe_remote(engine, audio_path, language=language, prompt=hotwords, timeout=timeout)
|
||||||
timeout=engine.timeout if engine.timeout > 0 else timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def _transcribe_remote(engine: STTEngine, audio_path: Path, *, language: str, prompt: str, timeout: int) -> str:
|
def _transcribe_remote(engine: STTEngine, audio_path: Path, *, language: str, prompt: str, timeout: int) -> str:
|
||||||
import os
|
import os
|
||||||
|
|
||||||
base = engine.url.rstrip("/")
|
base = engine.url.rstrip("/")
|
||||||
# If path is non-standard (not empty / /v1 / /v1.0), treat the full URL as
|
|
||||||
# the endpoint — supports WhisperX (/transcribe) and other custom paths.
|
|
||||||
_path = urlparse(base).path.rstrip("/")
|
|
||||||
if _path in ("", "/v1", "/v1.0"):
|
|
||||||
endpoint = base + "/audio/transcriptions"
|
endpoint = base + "/audio/transcriptions"
|
||||||
else:
|
fields = {"model": engine.model or "whisper-1", "response_format": "json"}
|
||||||
endpoint = base
|
|
||||||
fields: dict[str, str] = {"response_format": "json"}
|
|
||||||
if engine.model:
|
|
||||||
fields["model"] = engine.model
|
|
||||||
if language:
|
if language:
|
||||||
fields["language"] = language
|
fields["language"] = language
|
||||||
if prompt:
|
if prompt:
|
||||||
|
|||||||
@ -54,11 +54,8 @@ class Transcriber:
|
|||||||
log(f"{dev} unavailable ({exc}); trying next device")
|
log(f"{dev} unavailable ({exc}); trying next device")
|
||||||
raise RuntimeError(f"Failed to load Whisper model '{model}': {last_err}")
|
raise RuntimeError(f"Failed to load Whisper model '{model}': {last_err}")
|
||||||
|
|
||||||
def transcribe(self, audio_path: Path, language: str = "", hotwords: str = "",
|
def transcribe(self, audio_path: Path, language: str = "", hotwords: str = "") -> str:
|
||||||
beam_size: int | None = None) -> str:
|
kwargs = dict(language=language or None, beam_size=self.beam_size, vad_filter=True)
|
||||||
kwargs = dict(language=language or None,
|
|
||||||
beam_size=beam_size if beam_size is not None else self.beam_size,
|
|
||||||
vad_filter=True)
|
|
||||||
if hotwords:
|
if hotwords:
|
||||||
# Bias recognition toward the routing keywords so they transcribe
|
# Bias recognition toward the routing keywords so they transcribe
|
||||||
# reliably. Older faster-whisper builds lack `hotwords`; fall back.
|
# reliably. Older faster-whisper builds lack `hotwords`; fall back.
|
||||||
|
|||||||
@ -88,15 +88,6 @@ class Tray:
|
|||||||
|
|
||||||
menu.append(Gtk.SeparatorMenuItem())
|
menu.append(Gtk.SeparatorMenuItem())
|
||||||
|
|
||||||
# Cancel recording — always visible, only sensitive while recording.
|
|
||||||
# Primary escape hatch when wakeword fires on audiobook/TV audio.
|
|
||||||
self.cancel_item = Gtk.MenuItem(label="✕ Cancel recording")
|
|
||||||
self.cancel_item.set_sensitive(False)
|
|
||||||
self.cancel_item.connect(
|
|
||||||
"activate", lambda _i: self.app.daemon.cancel_dictation())
|
|
||||||
menu.append(self.cancel_item)
|
|
||||||
menu.append(Gtk.SeparatorMenuItem())
|
|
||||||
|
|
||||||
# Hands-free wakeword: a reversible pause toggle. Without this, a stale
|
# Hands-free wakeword: a reversible pause toggle. Without this, a stale
|
||||||
# /tmp/wake_muted flag would silently disable detection with no way back.
|
# /tmp/wake_muted flag would silently disable detection with no way back.
|
||||||
if getattr(self.app.cfg, "wakeword_enabled", False):
|
if getattr(self.app.cfg, "wakeword_enabled", False):
|
||||||
@ -129,8 +120,6 @@ class Tray:
|
|||||||
def update_status(self, state: str, message: str) -> None:
|
def update_status(self, state: str, message: str) -> None:
|
||||||
self.indicator.set_icon_full(ICONS.get(state, ICONS["idle"]), state)
|
self.indicator.set_icon_full(ICONS.get(state, ICONS["idle"]), state)
|
||||||
self.status_item.set_label(f"● {message or state.title()}")
|
self.status_item.set_label(f"● {message or state.title()}")
|
||||||
if hasattr(self, "cancel_item"):
|
|
||||||
self.cancel_item.set_sensitive(state in ("recording", "armed", "busy"))
|
|
||||||
|
|
||||||
def pump(self) -> None:
|
def pump(self) -> None:
|
||||||
"""Service pending GLib/GTK events; called from tkinter's loop."""
|
"""Service pending GLib/GTK events; called from tkinter's loop."""
|
||||||
|
|||||||
@ -35,7 +35,7 @@ def set_muted(muted: bool) -> None:
|
|||||||
elif os.path.exists(MUTE_FILE):
|
elif os.path.exists(MUTE_FILE):
|
||||||
os.remove(MUTE_FILE)
|
os.remove(MUTE_FILE)
|
||||||
except OSError as e: # noqa: BLE001 - mute is best-effort, never crash
|
except OSError as e: # noqa: BLE001 - mute is best-effort, never crash
|
||||||
logbuffer.log(f"[wakeword] Could not update mute flag: {e}", level="WARNING")
|
logbuffer.log(f"[wakeword] Could not update mute flag: {e}")
|
||||||
|
|
||||||
|
|
||||||
class WakewordListener:
|
class WakewordListener:
|
||||||
@ -67,7 +67,7 @@ class WakewordListener:
|
|||||||
try:
|
try:
|
||||||
self._stream()
|
self._stream()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logbuffer.log(f"[wakeword] Connection error: {e}", level="WARNING")
|
logbuffer.log(f"[wakeword] Connection error: {e}")
|
||||||
time.sleep(3) # Retry backoff
|
time.sleep(3) # Retry backoff
|
||||||
|
|
||||||
def _stream(self):
|
def _stream(self):
|
||||||
@ -166,133 +166,3 @@ class WakewordListener:
|
|||||||
logbuffer.log(f"[wakeword] Detected '{self.model}'!")
|
logbuffer.log(f"[wakeword] Detected '{self.model}'!")
|
||||||
self._cooldown_until = time.time() + 3.0 # 3s cooldown
|
self._cooldown_until = time.time() + 3.0 # 3s cooldown
|
||||||
self.on_detect()
|
self.on_detect()
|
||||||
|
|
||||||
|
|
||||||
class WakewordActionListener:
|
|
||||||
"""Listens for multiple wakeword models simultaneously and calls per-model callbacks.
|
|
||||||
|
|
||||||
Used during active wakeword recording so that dedicated "cancel" and "send"
|
|
||||||
wakeword phrases trigger :meth:`~blitztext.daemon.Daemon.cancel_dictation` or
|
|
||||||
:meth:`~blitztext.daemon.Daemon.finish_dictation` immediately — much faster
|
|
||||||
than waiting for Whisper to transcribe the whole clip.
|
|
||||||
|
|
||||||
``model_callbacks`` is a ``{model_name: callable}`` dict; only the models
|
|
||||||
present in the dict are requested from the server. No cooldown is applied
|
|
||||||
because the listener is torn down immediately after the first action fires.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, uri: str, model_callbacks: dict, mic: str):
|
|
||||||
self.uri = uri
|
|
||||||
self.model_callbacks = dict(model_callbacks) # {name: callable}
|
|
||||||
self.mic = mic
|
|
||||||
self._stop_event = threading.Event()
|
|
||||||
self._thread: threading.Thread | None = None
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
if not self.model_callbacks:
|
|
||||||
return
|
|
||||||
self._stop_event.clear()
|
|
||||||
self._thread = threading.Thread(
|
|
||||||
target=self._run, daemon=True, name="WakewordActionListener")
|
|
||||||
self._thread.start()
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
self._stop_event.set()
|
|
||||||
if self._thread:
|
|
||||||
self._thread.join(timeout=2.0)
|
|
||||||
self._thread = None
|
|
||||||
|
|
||||||
def _run(self) -> None:
|
|
||||||
while not self._stop_event.is_set():
|
|
||||||
try:
|
|
||||||
self._stream()
|
|
||||||
except Exception as e:
|
|
||||||
logbuffer.log(f"[wakeword-action] Connection error: {e}", level="WARNING")
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
def _stream(self) -> None:
|
|
||||||
parsed = urlparse(self.uri)
|
|
||||||
host = parsed.hostname or "127.0.0.1"
|
|
||||||
port = parsed.port or 10400
|
|
||||||
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
||||||
sock.settimeout(5.0)
|
|
||||||
sock.connect((host, port))
|
|
||||||
logbuffer.log(f"[wakeword-action] Connected — listening for {list(self.model_callbacks)}")
|
|
||||||
|
|
||||||
detect_msg = {"type": "detect", "data": {"names": list(self.model_callbacks)}}
|
|
||||||
sock.sendall((json.dumps(detect_msg) + "\n").encode("utf-8"))
|
|
||||||
|
|
||||||
audio_start = {"type": "audio-start",
|
|
||||||
"data": {"rate": 16000, "width": 2, "channels": 1}}
|
|
||||||
sock.sendall((json.dumps(audio_start) + "\n").encode("utf-8"))
|
|
||||||
|
|
||||||
read_active = True
|
|
||||||
|
|
||||||
def read_loop() -> None:
|
|
||||||
try:
|
|
||||||
sock.settimeout(1.0)
|
|
||||||
while read_active and not self._stop_event.is_set():
|
|
||||||
try:
|
|
||||||
line = b""
|
|
||||||
while not line.endswith(b"\n"):
|
|
||||||
byte = sock.recv(1)
|
|
||||||
if not byte:
|
|
||||||
return
|
|
||||||
line += byte
|
|
||||||
if not line:
|
|
||||||
return
|
|
||||||
msg = json.loads(line.decode("utf-8"))
|
|
||||||
if msg.get("type") == "detection":
|
|
||||||
name = msg.get("data", {}).get("name", "")
|
|
||||||
cb = self.model_callbacks.get(name)
|
|
||||||
if cb is None:
|
|
||||||
# Try partial match — some servers omit the lang suffix
|
|
||||||
for k, v in self.model_callbacks.items():
|
|
||||||
if name.startswith(k) or k.startswith(name):
|
|
||||||
cb = v
|
|
||||||
break
|
|
||||||
if cb:
|
|
||||||
logbuffer.log(
|
|
||||||
f"[wakeword-action] '{name}' detected — firing action")
|
|
||||||
self._stop_event.set() # one-shot: stop after first fire
|
|
||||||
cb()
|
|
||||||
payload_len = msg.get("payload_length", 0)
|
|
||||||
if payload_len > 0:
|
|
||||||
remaining = payload_len
|
|
||||||
while remaining > 0:
|
|
||||||
chunk = sock.recv(min(remaining, 4096))
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
remaining -= len(chunk)
|
|
||||||
except socket.timeout:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
reader = threading.Thread(target=read_loop, daemon=True)
|
|
||||||
reader.start()
|
|
||||||
|
|
||||||
cmd = ["pw-record", "--rate=16000", "--channels=1", "--format=s16", "-"]
|
|
||||||
if self.mic:
|
|
||||||
cmd.extend(["--target", self.mic])
|
|
||||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
||||||
|
|
||||||
try:
|
|
||||||
while not self._stop_event.is_set() and proc.poll() is None:
|
|
||||||
chunk = proc.stdout.read(3200)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
header = {"type": "audio-chunk",
|
|
||||||
"data": {"rate": 16000, "width": 2, "channels": 1},
|
|
||||||
"payload_length": len(chunk)}
|
|
||||||
sock.sendall((json.dumps(header) + "\n").encode("utf-8"))
|
|
||||||
sock.sendall(chunk)
|
|
||||||
finally:
|
|
||||||
read_active = False
|
|
||||||
proc.terminate()
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=1.0)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
proc.kill()
|
|
||||||
reader.join(timeout=1.0)
|
|
||||||
|
|||||||
@ -1,379 +0,0 @@
|
|||||||
"""Benchmark the wakeword detector with synthetic speech.
|
|
||||||
|
|
||||||
Generates short utterances — random filler text with the wakeword spoken in it,
|
|
||||||
plus pure-filler utterances with no wakeword — synthesizes each in a random voice
|
|
||||||
via the *same* (OpenAI-compatible) endpoint configured for speech-to-text, then
|
|
||||||
streams the audio to the running wyoming-openwakeword server and counts what it
|
|
||||||
detects. Reports:
|
|
||||||
|
|
||||||
• recall — share of wakeword utterances that fired at least one detection
|
|
||||||
• false fires — detections during pure-filler utterances (should be zero)
|
|
||||||
• per-voice recall, so you can see which voices your model handles
|
|
||||||
|
|
||||||
TTS reuses the STT engine on purpose: on a typical NIM/OpenAI-compatible setup
|
|
||||||
the same server answers ``/audio/speech``, so there's nothing extra to configure
|
|
||||||
beyond a TTS model id and a voice list.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import socket
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
import wave
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
# Bilingual filler so the synthesized speech is sentence-like (the detector sees
|
|
||||||
# realistic context around the wakeword, not just the bare phrase). Unknown
|
|
||||||
# languages fall back to English text — the voice still speaks it in its accent.
|
|
||||||
_FILLERS = {
|
|
||||||
"en": [
|
|
||||||
"the weather today is unusually calm and bright",
|
|
||||||
"remind me to call the office before noon",
|
|
||||||
"i think the train leaves around half past nine",
|
|
||||||
"could you put the report on my desk later",
|
|
||||||
"we should grab a coffee once this is done",
|
|
||||||
"the package was delivered to the wrong address again",
|
|
||||||
],
|
|
||||||
"de": [
|
|
||||||
"das wetter ist heute ungewöhnlich ruhig und klar",
|
|
||||||
"erinnere mich daran das büro vor mittag anzurufen",
|
|
||||||
"ich glaube der zug fährt gegen halb zehn",
|
|
||||||
"könntest du den bericht später auf meinen tisch legen",
|
|
||||||
"wir sollten einen kaffee trinken wenn das erledigt ist",
|
|
||||||
"das paket wurde wieder an die falsche adresse geliefert",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# OpenAI's stock voices; a sensible default for any OpenAI-compatible TTS. Local
|
|
||||||
# servers (Kokoro, openedai-speech, …) expose their own names — override in the
|
|
||||||
# config / Settings to match what your endpoint actually serves.
|
|
||||||
DEFAULT_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]
|
|
||||||
|
|
||||||
_TARGET_RATE = 16000 # wyoming-openwakeword expects 16 kHz mono s16le
|
|
||||||
_CHUNK_BYTES = 3200 # 100 ms per audio-chunk, matching the live listener
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Utterance:
|
|
||||||
text: str
|
|
||||||
has_wakeword: bool
|
|
||||||
voice: str
|
|
||||||
detections: int = 0
|
|
||||||
ok: bool = False # synthesized + streamed without error
|
|
||||||
error: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BenchResult:
|
|
||||||
utterances: list[Utterance] = field(default_factory=list)
|
|
||||||
seconds: float = 0.0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def wake(self) -> list[Utterance]:
|
|
||||||
return [u for u in self.utterances if u.has_wakeword]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def filler(self) -> list[Utterance]:
|
|
||||||
return [u for u in self.utterances if not u.has_wakeword]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def detected(self) -> int:
|
|
||||||
return sum(1 for u in self.wake if u.ok and u.detections > 0)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def expected(self) -> int:
|
|
||||||
return sum(1 for u in self.wake if u.ok)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def recall(self) -> float:
|
|
||||||
return self.detected / self.expected if self.expected else 0.0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def false_fires(self) -> int:
|
|
||||||
return sum(u.detections for u in self.filler if u.ok)
|
|
||||||
|
|
||||||
def recall_by_voice(self) -> dict[str, tuple[int, int]]:
|
|
||||||
"""voice -> (detected, expected) over wakeword utterances."""
|
|
||||||
out: dict[str, list[int]] = {}
|
|
||||||
for u in self.wake:
|
|
||||||
if not u.ok:
|
|
||||||
continue
|
|
||||||
d = out.setdefault(u.voice, [0, 0])
|
|
||||||
d[1] += 1
|
|
||||||
if u.detections > 0:
|
|
||||||
d[0] += 1
|
|
||||||
return {v: (d[0], d[1]) for v, d in out.items()}
|
|
||||||
|
|
||||||
|
|
||||||
def wakeword_phrase(model: str) -> str:
|
|
||||||
"""Turn a wakeword model id into the phrase to speak (best effort).
|
|
||||||
|
|
||||||
"okay_computer" -> "okay computer", "hey_jarvis" -> "hey jarvis". Strips a
|
|
||||||
trailing version/format suffix like "_v0.1" or ".tflite".
|
|
||||||
"""
|
|
||||||
name = model.rsplit("/", 1)[-1]
|
|
||||||
for ext in (".tflite", ".onnx"):
|
|
||||||
if name.endswith(ext):
|
|
||||||
name = name[: -len(ext)]
|
|
||||||
name = name.replace("_", " ").replace("-", " ")
|
|
||||||
# Drop a trailing token that is just a version like "v0.1".
|
|
||||||
parts = [p for p in name.split() if not (p.startswith("v") and any(c.isdigit() for c in p))]
|
|
||||||
return " ".join(parts).strip() or name.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _filler_pool(language: str) -> list[str]:
|
|
||||||
lang = (language or "").lower()
|
|
||||||
if lang.startswith("de"):
|
|
||||||
return _FILLERS["de"]
|
|
||||||
return _FILLERS["en"]
|
|
||||||
|
|
||||||
|
|
||||||
def build_utterances(phrase: str, count: int, language: str, *,
|
|
||||||
filler_count: int | None = None, voices=None,
|
|
||||||
rng: random.Random | None = None) -> list[Utterance]:
|
|
||||||
"""Build `count` wakeword utterances + `filler_count` pure-filler ones.
|
|
||||||
|
|
||||||
Each wakeword utterance embeds `phrase` at the start, middle, or end of a
|
|
||||||
random filler sentence; voices are assigned round-robin-ish at random so the
|
|
||||||
set covers every configured voice. Deterministic when `rng` is seeded.
|
|
||||||
"""
|
|
||||||
rng = rng or random.Random()
|
|
||||||
voices = list(voices or DEFAULT_VOICES) or DEFAULT_VOICES
|
|
||||||
pool = _filler_pool(language)
|
|
||||||
if filler_count is None:
|
|
||||||
filler_count = max(3, count // 3)
|
|
||||||
|
|
||||||
def a_voice() -> str:
|
|
||||||
return rng.choice(voices)
|
|
||||||
|
|
||||||
out: list[Utterance] = []
|
|
||||||
for _ in range(count):
|
|
||||||
filler = rng.choice(pool)
|
|
||||||
where = rng.choice(("start", "end", "mid"))
|
|
||||||
if where == "start":
|
|
||||||
text = f"{phrase}, {filler}"
|
|
||||||
elif where == "end":
|
|
||||||
text = f"{filler}, {phrase}"
|
|
||||||
else:
|
|
||||||
words = filler.split()
|
|
||||||
cut = len(words) // 2
|
|
||||||
text = " ".join(words[:cut] + [phrase] + words[cut:])
|
|
||||||
out.append(Utterance(text=text, has_wakeword=True, voice=a_voice()))
|
|
||||||
for _ in range(filler_count):
|
|
||||||
out.append(Utterance(text=rng.choice(pool), has_wakeword=False, voice=a_voice()))
|
|
||||||
rng.shuffle(out)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# --- TTS via an OpenAI-compatible /audio/speech endpoint ---------------------
|
|
||||||
def _auth_headers(api_key_env: str) -> dict:
|
|
||||||
key = os.environ.get(api_key_env or "", "")
|
|
||||||
return {"Authorization": f"Bearer {key}"} if key else {}
|
|
||||||
|
|
||||||
|
|
||||||
def synthesize(tts_url: str, text: str, *, model: str, voice: str,
|
|
||||||
api_key_env: str = "", timeout: float = 30.0) -> bytes:
|
|
||||||
"""Return 16 kHz mono s16le PCM for `text` from an OpenAI-compatible TTS.
|
|
||||||
|
|
||||||
`tts_url` is the base incl. /v1 (e.g. http://localhost:8880/v1). Asks for WAV
|
|
||||||
and resamples whatever rate/-channels come back down to 16 kHz mono.
|
|
||||||
"""
|
|
||||||
base = (tts_url or "").rstrip("/")
|
|
||||||
if not base:
|
|
||||||
raise RuntimeError("No TTS URL configured (Settings → Benchmark → TTS URL).")
|
|
||||||
payload = json.dumps({
|
|
||||||
"model": model, "input": text, "voice": voice, "response_format": "wav",
|
|
||||||
}).encode("utf-8")
|
|
||||||
headers = {"Content-Type": "application/json", **_auth_headers(api_key_env)}
|
|
||||||
req = urllib.request.Request(base + "/audio/speech", data=payload, headers=headers, method="POST")
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
||||||
raw = resp.read()
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
detail = exc.read().decode("utf-8", "replace")[:200]
|
|
||||||
raise RuntimeError(f"TTS HTTP {exc.code}: {detail}") from exc
|
|
||||||
except (urllib.error.URLError, OSError) as exc:
|
|
||||||
raise RuntimeError(f"TTS request failed: {exc}") from exc
|
|
||||||
return _wav_to_pcm16k(raw)
|
|
||||||
|
|
||||||
|
|
||||||
def list_voices(tts_url: str, *, api_key_env: str = "", timeout: float = 8.0) -> list[str]:
|
|
||||||
"""Best-effort voice discovery (Kokoro/XTTS expose /audio/voices or /voices).
|
|
||||||
|
|
||||||
Returns [] if the server has no such endpoint — voices are then entered by
|
|
||||||
hand. Accepts the common shapes: ["a", …], {"voices": …}, {"data": [{id}…]}.
|
|
||||||
"""
|
|
||||||
base = (tts_url or "").rstrip("/")
|
|
||||||
if not base:
|
|
||||||
return []
|
|
||||||
for path in ("/audio/voices", "/voices"):
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(base + path, headers=_auth_headers(api_key_env))
|
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
||||||
data = json.loads(resp.read())
|
|
||||||
except Exception: # noqa: BLE001 - endpoint optional/non-standard
|
|
||||||
continue
|
|
||||||
items = data.get("voices") or data.get("data") or [] if isinstance(data, dict) else data
|
|
||||||
out: list[str] = []
|
|
||||||
for it in items or []:
|
|
||||||
if isinstance(it, str):
|
|
||||||
out.append(it)
|
|
||||||
elif isinstance(it, dict):
|
|
||||||
name = it.get("id") or it.get("name") or it.get("voice")
|
|
||||||
if name:
|
|
||||||
out.append(str(name))
|
|
||||||
if out:
|
|
||||||
return out
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def probe(tts_url: str, *, model: str, voice: str, api_key_env: str = "",
|
|
||||||
timeout: float = 20.0) -> tuple[bool, str]:
|
|
||||||
"""Connectivity check: synthesize one short phrase. Returns (ok, message)."""
|
|
||||||
try:
|
|
||||||
pcm = synthesize(tts_url, "connection test", model=model, voice=voice,
|
|
||||||
api_key_env=api_key_env, timeout=timeout)
|
|
||||||
except Exception as exc: # noqa: BLE001 - surface the reason to the user
|
|
||||||
return False, str(exc)
|
|
||||||
return True, f"Connected — “{voice}” returned {len(pcm) / 2 / _TARGET_RATE:.1f}s of audio."
|
|
||||||
|
|
||||||
|
|
||||||
def _wav_to_pcm16k(wav_bytes: bytes) -> bytes:
|
|
||||||
"""Decode a WAV blob to 16 kHz mono signed-16 PCM (linear resample)."""
|
|
||||||
import numpy as np # type: ignore[import-untyped]
|
|
||||||
|
|
||||||
with wave.open(io.BytesIO(wav_bytes), "rb") as w:
|
|
||||||
ch, width, rate, n = w.getnchannels(), w.getsampwidth(), w.getframerate(), w.getnframes()
|
|
||||||
frames = w.readframes(n)
|
|
||||||
if width != 2:
|
|
||||||
raise RuntimeError(f"Unexpected TTS sample width {width*8}-bit (need 16-bit WAV).")
|
|
||||||
a = np.frombuffer(frames, dtype=np.int16).astype(np.float32)
|
|
||||||
if ch > 1:
|
|
||||||
a = a.reshape(-1, ch).mean(axis=1)
|
|
||||||
if rate != _TARGET_RATE and a.size:
|
|
||||||
new_len = int(round(a.size * _TARGET_RATE / rate))
|
|
||||||
if new_len > 0:
|
|
||||||
xp = np.linspace(0.0, 1.0, num=a.size, endpoint=False)
|
|
||||||
x = np.linspace(0.0, 1.0, num=new_len, endpoint=False)
|
|
||||||
a = np.interp(x, xp, a)
|
|
||||||
return np.clip(a, -32768, 32767).astype("<i2").tobytes()
|
|
||||||
|
|
||||||
|
|
||||||
# --- wyoming-openwakeword detection ------------------------------------------
|
|
||||||
def count_detections(uri: str, model: str, pcm: bytes, *, settle: float = 1.5,
|
|
||||||
timeout: float = 15.0) -> int:
|
|
||||||
"""Stream `pcm` (16 kHz mono s16le) to wyoming-openwakeword; count detections.
|
|
||||||
|
|
||||||
Sends a fresh detect/audio-start/…/audio-stop session, then drains detection
|
|
||||||
events until the server falls quiet for `settle` seconds (or `timeout`).
|
|
||||||
"""
|
|
||||||
parsed = urlparse(uri)
|
|
||||||
host, port = parsed.hostname or "127.0.0.1", parsed.port or 10400
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
detections = 0
|
|
||||||
with socket.create_connection((host, port), timeout=5.0) as sock:
|
|
||||||
_send(sock, {"type": "detect", "data": {"names": [model]}})
|
|
||||||
_send(sock, {"type": "audio-start", "data": {"rate": 16000, "width": 2, "channels": 1}})
|
|
||||||
for i in range(0, len(pcm), _CHUNK_BYTES):
|
|
||||||
chunk = pcm[i:i + _CHUNK_BYTES]
|
|
||||||
_send(sock, {"type": "audio-chunk",
|
|
||||||
"data": {"rate": 16000, "width": 2, "channels": 1},
|
|
||||||
"payload_length": len(chunk)}, chunk)
|
|
||||||
_send(sock, {"type": "audio-stop", "data": {}})
|
|
||||||
sock.settimeout(settle)
|
|
||||||
buf = b""
|
|
||||||
while time.time() < deadline:
|
|
||||||
try:
|
|
||||||
data = sock.recv(4096)
|
|
||||||
except socket.timeout:
|
|
||||||
break # server quiet for `settle`s → done
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
buf += data
|
|
||||||
buf, n = _drain_detections(buf)
|
|
||||||
detections += n
|
|
||||||
return detections
|
|
||||||
|
|
||||||
|
|
||||||
def _send(sock: socket.socket, msg: dict, payload: bytes = b"") -> None:
|
|
||||||
sock.sendall((json.dumps(msg) + "\n").encode("utf-8"))
|
|
||||||
if payload:
|
|
||||||
sock.sendall(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def _drain_detections(buf: bytes) -> tuple[bytes, int]:
|
|
||||||
"""Parse whole newline-framed messages from buf; return (rest, detections).
|
|
||||||
|
|
||||||
Consumes each message's binary payload too, so payload bytes are never
|
|
||||||
mistaken for the next header line.
|
|
||||||
"""
|
|
||||||
found = 0
|
|
||||||
while b"\n" in buf:
|
|
||||||
line, rest = buf.split(b"\n", 1)
|
|
||||||
try:
|
|
||||||
msg = json.loads(line.decode("utf-8"))
|
|
||||||
except (ValueError, UnicodeDecodeError):
|
|
||||||
return rest, found
|
|
||||||
plen = msg.get("payload_length", 0) or 0
|
|
||||||
if len(rest) < plen:
|
|
||||||
return buf, found # payload not fully arrived yet; wait for more
|
|
||||||
rest = rest[plen:]
|
|
||||||
if msg.get("type") == "detection":
|
|
||||||
found += 1
|
|
||||||
buf = rest
|
|
||||||
return buf, found
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EngineRun:
|
|
||||||
"""One wakeword engine's benchmark result."""
|
|
||||||
name: str
|
|
||||||
uri: str
|
|
||||||
model: str
|
|
||||||
result: BenchResult
|
|
||||||
|
|
||||||
|
|
||||||
def run(engines, *, tts_url: str, tts_model: str, tts_api_key_env: str = "", voices=None,
|
|
||||||
language: str = "", count: int = 12, seed: int | None = None,
|
|
||||||
progress=None) -> list[EngineRun]:
|
|
||||||
"""Benchmark each wakeword engine against the same synthesized speech.
|
|
||||||
|
|
||||||
`engines` is a list of objects with ``.name`` / ``.uri`` / ``.model`` (e.g.
|
|
||||||
``config.WakewordEngine``). Each engine is scored on `count` utterances of
|
|
||||||
its own wake phrase (+ filler). Audio is synthesized once per (text, voice)
|
|
||||||
and reused across engines that share a phrase, so adding engines is cheap.
|
|
||||||
Calls ``progress(engine_idx, engine_total, engine_name, done, total, u)``.
|
|
||||||
"""
|
|
||||||
cache: dict[tuple[str, str], bytes] = {}
|
|
||||||
runs: list[EngineRun] = []
|
|
||||||
n_eng = len(engines)
|
|
||||||
for ei, eng in enumerate(engines, 1):
|
|
||||||
phrase = wakeword_phrase(eng.model)
|
|
||||||
utterances = build_utterances(phrase, count, language, voices=voices, rng=random.Random(seed))
|
|
||||||
res = BenchResult(utterances=utterances)
|
|
||||||
total = len(utterances)
|
|
||||||
t0 = time.time()
|
|
||||||
for ui, u in enumerate(utterances, 1):
|
|
||||||
try:
|
|
||||||
key = (u.text, u.voice)
|
|
||||||
pcm = cache.get(key)
|
|
||||||
if pcm is None:
|
|
||||||
pcm = synthesize(tts_url, u.text, model=tts_model, voice=u.voice,
|
|
||||||
api_key_env=tts_api_key_env)
|
|
||||||
cache[key] = pcm
|
|
||||||
u.detections = count_detections(eng.uri, eng.model, pcm)
|
|
||||||
u.ok = True
|
|
||||||
except Exception as exc: # noqa: BLE001 - record per-utterance, keep going
|
|
||||||
u.error = str(exc)
|
|
||||||
if progress:
|
|
||||||
progress(ei, n_eng, eng.name or eng.model, ui, total, u)
|
|
||||||
res.seconds = time.time() - t0
|
|
||||||
runs.append(EngineRun(eng.name or eng.model, eng.uri, eng.model, res))
|
|
||||||
return runs
|
|
||||||
@ -1,87 +1,49 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Install Blitztext for Linux (no root needed).
|
# Set up Blitztext for Linux: create a venv, install deps, check host tools,
|
||||||
#
|
# and write the default config. Run from the linux/ directory.
|
||||||
# Creates a self-contained venv at ~/.local/lib/blitztext (outside any CIFS
|
|
||||||
# share, using --copies so no symlinks are needed), copies the package into it,
|
|
||||||
# and writes a launcher to ~/.local/bin/blitztext. The source folder can be
|
|
||||||
# deleted after this script finishes.
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
APPDIR="$HOME/.local/lib/blitztext"
|
VENV=".venv"
|
||||||
VENV="$APPDIR/venv"
|
|
||||||
BIN="$HOME/.local/bin/blitztext"
|
|
||||||
|
|
||||||
echo "==> Checking host tools"
|
echo "==> Checking host tools"
|
||||||
need_pkg=()
|
need_pkg=()
|
||||||
# python3-gi (PyGObject) must come from apt — it can't be pip-installed.
|
command -v xdotool >/dev/null || need_pkg+=("xdotool")
|
||||||
python3 -c "import gi" 2>/dev/null || need_pkg+=(python3-gi python3-gi-cairo gir1.2-gtk-3.0 gir1.2-appindicator3-0.1)
|
command -v notify-send >/dev/null || need_pkg+=("libnotify-bin")
|
||||||
command -v xdotool >/dev/null || need_pkg+=(xdotool)
|
|
||||||
command -v notify-send >/dev/null || need_pkg+=(libnotify-bin)
|
|
||||||
if ! command -v pw-record >/dev/null && ! command -v arecord >/dev/null && ! command -v parecord >/dev/null; then
|
if ! command -v pw-record >/dev/null && ! command -v arecord >/dev/null && ! command -v parecord >/dev/null; then
|
||||||
need_pkg+=(pipewire-bin)
|
need_pkg+=("pipewire-bin (or alsa-utils)")
|
||||||
fi
|
fi
|
||||||
if ((${#need_pkg[@]})); then
|
if ((${#need_pkg[@]})); then
|
||||||
echo " Missing system packages — installing now (requires sudo):"
|
echo " Missing host tools: ${need_pkg[*]}"
|
||||||
echo " sudo apt install ${need_pkg[*]}"
|
echo " On Ubuntu/Debian: sudo apt install xdotool libnotify-bin pipewire-bin"
|
||||||
sudo apt-get install -y "${need_pkg[@]}"
|
echo " (continuing — install them before running the daemon)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --system-site-packages lets the venv see an apt-installed PyGObject (python3-gi)
|
||||||
|
# for the optional system-tray mode; harmless if it's not installed.
|
||||||
echo "==> Creating venv at $VENV"
|
echo "==> Creating venv at $VENV"
|
||||||
mkdir -p "$APPDIR"
|
python3 -m venv --system-site-packages "$VENV"
|
||||||
# --copies avoids lib64->lib symlinks that fail on CIFS/SMB shares.
|
|
||||||
# --system-site-packages lets the venv see apt-installed python3-gi for the tray.
|
|
||||||
python3 -m venv --copies --system-site-packages "$VENV"
|
|
||||||
"$VENV/bin/pip" install --upgrade pip -q
|
"$VENV/bin/pip" install --upgrade pip -q
|
||||||
"$VENV/bin/pip" install -q -r requirements.txt
|
"$VENV/bin/pip" install -r requirements.txt
|
||||||
|
|
||||||
echo "==> Installing blitztext package"
|
|
||||||
# Copy the package into site-packages so this source folder can be deleted.
|
|
||||||
cp -r blitztext "$VENV/lib/python"*/site-packages/
|
|
||||||
|
|
||||||
# On Debian/Ubuntu, python3-gi lives in /usr/lib/python3/dist-packages — the
|
|
||||||
# generic (non-versioned) path that venvs don't include even with
|
|
||||||
# --system-site-packages. A .pth file adds it to sys.path at startup.
|
|
||||||
SITE_PKG="$("$VENV/bin/python" -c "import site; print(site.getsitepackages()[0])")"
|
|
||||||
echo "/usr/lib/python3/dist-packages" > "$SITE_PKG/debian-system-packages.pth"
|
|
||||||
|
|
||||||
echo "==> Writing default config (if absent)"
|
echo "==> Writing default config (if absent)"
|
||||||
"$VENV/bin/python" -m blitztext config-path
|
"$VENV/bin/python" -m blitztext config-path
|
||||||
|
|
||||||
echo "==> Installing launcher → $BIN"
|
|
||||||
mkdir -p "$(dirname "$BIN")"
|
|
||||||
# Use a Python entry-point script (not `python -m`) so the current working
|
|
||||||
# directory is never added to sys.path and can't shadow the installed package.
|
|
||||||
cat > "$BIN" <<EOF
|
|
||||||
#!$VENV/bin/python
|
|
||||||
import sys
|
|
||||||
from blitztext.blitztext import main
|
|
||||||
sys.exit(main())
|
|
||||||
EOF
|
|
||||||
chmod +x "$BIN"
|
|
||||||
|
|
||||||
# Copy icons to hicolor so the app shows up properly in the tray / launcher.
|
|
||||||
ICON_DIR="$HOME/.local/share/icons/hicolor"
|
|
||||||
PKG_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
for s in 32 48 64 128 256; do
|
|
||||||
SRC="$PKG_DIR/packaging/blitztext_${s}.png"
|
|
||||||
if [ -f "$SRC" ]; then
|
|
||||||
DEST="$ICON_DIR/${s}x${s}/apps/blitztext.png"
|
|
||||||
mkdir -p "$(dirname "$DEST")"
|
|
||||||
cp "$SRC" "$DEST"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
gtk-update-icon-cache -qf "$ICON_DIR" 2>/dev/null || true
|
|
||||||
|
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
||||||
Done! Blitztext is installed at $APPDIR
|
Done. To start it:
|
||||||
|
|
||||||
Start it:
|
$VENV/bin/python -m blitztext tray # system tray (needs: sudo apt install python3-gi)
|
||||||
blitztext tray # system tray
|
$VENV/bin/python -m blitztext gui # control-panel window
|
||||||
blitztext gui # control-panel window
|
$VENV/bin/python -m blitztext run # headless, hotkeys only
|
||||||
|
|
||||||
(Make sure ~/.local/bin is in your PATH — it is by default on Ubuntu.)
|
Edit your config (hotkeys, Whisper model, rewrite endpoint) at:
|
||||||
|
|
||||||
You can now delete the source folder if you cloned it just for installation.
|
\$($VENV/bin/python -m blitztext config-path)
|
||||||
|
|
||||||
|
For the rewrite workflows, export your key first, e.g.:
|
||||||
|
|
||||||
|
export OPENAI_API_KEY=sk-...
|
||||||
|
|
||||||
|
To run it in the background on login, see blitztext.service in this folder.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@ -6,7 +6,6 @@ Comment=Linux voice dictation with local, remote, and realtime STT engines
|
|||||||
Exec=blitztext tray
|
Exec=blitztext tray
|
||||||
Icon=blitztext
|
Icon=blitztext
|
||||||
Terminal=false
|
Terminal=false
|
||||||
StartupWMClass=blitztext
|
|
||||||
Categories=Utility;AudioVideo;Accessibility;
|
Categories=Utility;AudioVideo;Accessibility;
|
||||||
Keywords=dictation;speech;voice;transcription;whisper;stt;riva;nim;
|
Keywords=dictation;speech;voice;transcription;whisper;stt;riva;nim;
|
||||||
StartupNotify=false
|
StartupNotify=false
|
||||||
|
|||||||
@ -44,9 +44,6 @@ rm -rf "$VENV/lib/python"*/site-packages/pip "$VENV/lib/python"*/site-packages/p
|
|||||||
"$VENV"/bin/pip* "$VENV/lib/python"*/site-packages/setuptools* 2>/dev/null || true
|
"$VENV"/bin/pip* "$VENV/lib/python"*/site-packages/setuptools* 2>/dev/null || true
|
||||||
|
|
||||||
cp "$LINUX_DIR/README.md" "$LINUX_DIR/CHANGELOG.md" "$APPDIR/"
|
cp "$LINUX_DIR/README.md" "$LINUX_DIR/CHANGELOG.md" "$APPDIR/"
|
||||||
# MANUAL.md lives at repo root (one level above linux/)
|
|
||||||
REPO_MANUAL="$(dirname "$LINUX_DIR")/MANUAL.md"
|
|
||||||
[ -f "$REPO_MANUAL" ] && cp "$REPO_MANUAL" "$APPDIR/MANUAL.md"
|
|
||||||
|
|
||||||
# 2) Launcher, desktop entry, icon, docs ------------------------------------
|
# 2) Launcher, desktop entry, icon, docs ------------------------------------
|
||||||
install -Dm755 /dev/stdin "$ROOT/usr/bin/$PKG" <<'EOF'
|
install -Dm755 /dev/stdin "$ROOT/usr/bin/$PKG" <<'EOF'
|
||||||
@ -107,9 +104,4 @@ chmod 755 "$ROOT/DEBIAN/postrm"
|
|||||||
mkdir -p "$OUT_DIR"
|
mkdir -p "$OUT_DIR"
|
||||||
DEB="$OUT_DIR/${PKG}_${VER}_${ARCH}.deb"
|
DEB="$OUT_DIR/${PKG}_${VER}_${ARCH}.deb"
|
||||||
fakeroot dpkg-deb --build --root-owner-group "$ROOT" "$DEB" >/dev/null
|
fakeroot dpkg-deb --build --root-owner-group "$ROOT" "$DEB" >/dev/null
|
||||||
chmod 644 "$DEB"
|
|
||||||
# Also copy to ~ so `sudo apt install ~/blitztext_*.deb` works without a separate cp
|
|
||||||
DEST="$HOME/$(basename "$DEB")"
|
|
||||||
install -m644 "$DEB" "$DEST"
|
|
||||||
echo "==> Built: $DEB ($(du -h "$DEB" | cut -f1))"
|
echo "==> Built: $DEB ($(du -h "$DEB" | cut -f1))"
|
||||||
echo "==> Copied: $DEST"
|
|
||||||
|
|||||||
@ -108,41 +108,6 @@ def test_matched_preset_announced_even_hands_free(monkeypatch):
|
|||||||
assert shown == [], "no announcement when 'Announce matched preset' is off"
|
assert shown == [], "no announcement when 'Announce matched preset' is off"
|
||||||
|
|
||||||
|
|
||||||
def test_overlay_fuses_notifications(monkeypatch):
|
|
||||||
"""With an overlay (routing_cb wired), the on-screen bubble narrates every
|
|
||||||
phase, so informational desktop notifications are suppressed — but errors
|
|
||||||
still pop a bubble, and the routing match is shown on the overlay, not a
|
|
||||||
notification."""
|
|
||||||
calls = []
|
|
||||||
monkeypatch.setattr(daemon_mod, "notify", lambda *a, **k: calls.append((a, k)))
|
|
||||||
monkeypatch.setattr(daemon_mod, "detect_recorder", lambda pref="auto": "pw-record")
|
|
||||||
d = Daemon(Config(), routing_cb=lambda *a: None)
|
|
||||||
assert d._overlay is True
|
|
||||||
d._session_silent = False
|
|
||||||
|
|
||||||
d._dnotify("⌛ Nicer email", "Rewriting…") # informational
|
|
||||||
assert calls == [], "informational notifications fuse into the overlay"
|
|
||||||
|
|
||||||
d._dnotify("Error", "boom", urgency="critical") # errors still notify
|
|
||||||
assert len(calls) == 1, "errors must still pop a desktop bubble"
|
|
||||||
|
|
||||||
calls.clear()
|
|
||||||
d.cfg.notify_routing = True
|
|
||||||
d._rnotify("⚡ Nicer email", "matched: “nicer email”")
|
|
||||||
assert calls == [], "the match is shown on the overlay banner, not as a notification"
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_overlay_keeps_notifications(monkeypatch):
|
|
||||||
"""Headless / overlay-off: notifications remain the only feedback."""
|
|
||||||
calls = []
|
|
||||||
monkeypatch.setattr(daemon_mod, "notify", lambda *a, **k: calls.append((a, k)))
|
|
||||||
d = _make_daemon(monkeypatch) # no routing_cb
|
|
||||||
assert d._overlay is False
|
|
||||||
d._session_silent = False
|
|
||||||
d._dnotify("⌛ Nicer email", "Rewriting…")
|
|
||||||
assert len(calls) == 1, "without an overlay, informational notifications still show"
|
|
||||||
|
|
||||||
|
|
||||||
def test_wakeword_while_busy_does_not_notify(monkeypatch):
|
def test_wakeword_while_busy_does_not_notify(monkeypatch):
|
||||||
"""The away-from-keyboard "Busy" storm: a detection arriving while the
|
"""The away-from-keyboard "Busy" storm: a detection arriving while the
|
||||||
previous clip is still being processed must be ignored silently."""
|
previous clip is still being processed must be ignored silently."""
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
"""Streaming the LLM rewrite into the overlay (so you watch the model write).
|
|
||||||
|
|
||||||
`llm.chat(..., on_token=cb)` must parse an OpenAI-style SSE stream, hand each
|
|
||||||
content delta to the callback, and still return the complete text.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import io
|
|
||||||
|
|
||||||
import blitztext.llm as llm
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_stream_accumulates_and_calls_back():
|
|
||||||
sse = (
|
|
||||||
'data: {"choices":[{"delta":{"content":"Hel"}}]}\n'
|
|
||||||
"\n" # keep-alive blank line
|
|
||||||
'data: {"choices":[{"delta":{"content":"lo"}}]}\n'
|
|
||||||
'data: {"choices":[{"delta":{}}]}\n' # role-only / empty delta
|
|
||||||
": comment line\n" # SSE comment, ignored
|
|
||||||
"data: [DONE]\n"
|
|
||||||
'data: {"choices":[{"delta":{"content":"X"}}]}\n' # after DONE -> ignored
|
|
||||||
)
|
|
||||||
tokens = []
|
|
||||||
out = llm._read_stream(io.BytesIO(sse.encode("utf-8")), tokens.append)
|
|
||||||
|
|
||||||
assert out == "Hello"
|
|
||||||
assert tokens == ["Hel", "lo"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_stream_survives_callback_errors():
|
|
||||||
sse = (
|
|
||||||
'data: {"choices":[{"delta":{"content":"a"}}]}\n'
|
|
||||||
'data: {"choices":[{"delta":{"content":"b"}}]}\n'
|
|
||||||
"data: [DONE]\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
def boom(_delta):
|
|
||||||
raise RuntimeError("UI exploded")
|
|
||||||
|
|
||||||
# A failing UI callback must not break accumulation / delivery.
|
|
||||||
out = llm._read_stream(io.BytesIO(sse.encode("utf-8")), boom)
|
|
||||||
assert out == "ab"
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
"""Spoken abort: a cancel word discards a clip before it is acted on.
|
|
||||||
|
|
||||||
Rescues accidentally triggered (e.g. wakeword) dictations — the transcript is
|
|
||||||
recognised but nothing is routed, rewritten, or typed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import blitztext.daemon as dm
|
|
||||||
from blitztext.config import Config, Workflow
|
|
||||||
from blitztext.daemon import Daemon
|
|
||||||
from blitztext.routing import is_cancel
|
|
||||||
|
|
||||||
|
|
||||||
# -- matcher --------------------------------------------------------------------
|
|
||||||
def test_is_cancel_matches_edges_only():
|
|
||||||
kws = ["abbrechen", "cancel"]
|
|
||||||
assert is_cancel("abbrechen", kws) == "abbrechen" # whole utterance
|
|
||||||
assert is_cancel("ok das ganze bitte abbrechen", kws) == "abbrechen" # end edge
|
|
||||||
assert is_cancel("cancel this please", kws) == "cancel" # start edge
|
|
||||||
assert is_cancel("abbrechn", ["abbrechen"]) == "abbrechen" # ASR drift, fuzzy
|
|
||||||
# Word buried mid-sentence must NOT cancel a legitimate dictation.
|
|
||||||
assert is_cancel("ich will den vorgang abbrechen weil es spaet ist", ["abbrechen"]) is None
|
|
||||||
assert is_cancel("hallo welt", ["abbrechen"]) is None
|
|
||||||
assert is_cancel("abbrechen", []) is None # disabled
|
|
||||||
assert is_cancel("", ["abbrechen"]) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_cancel_keywords_round_trip(tmp_path):
|
|
||||||
from blitztext.config import load, save
|
|
||||||
p = tmp_path / "config.toml"
|
|
||||||
cfg = load(p)
|
|
||||||
assert cfg.cancel_keywords == ["abbrechen", "cancel"] # shipped default
|
|
||||||
cfg.cancel_keywords = ["nein doch nicht", "scrap that"]
|
|
||||||
save(cfg, p)
|
|
||||||
assert load(p).cancel_keywords == ["nein doch nicht", "scrap that"]
|
|
||||||
|
|
||||||
|
|
||||||
# -- pipeline -------------------------------------------------------------------
|
|
||||||
def _wire_clean_pipeline(monkeypatch):
|
|
||||||
monkeypatch.setattr(dm, "detect_recorder", lambda pref="auto": "pw-record")
|
|
||||||
monkeypatch.setattr(dm, "notify", lambda *a, **k: None)
|
|
||||||
monkeypatch.setattr(dm.quality, "analyze_wav", lambda p: (2.0, 0.5))
|
|
||||||
monkeypatch.setattr(dm.quality, "too_quiet", lambda *a, **k: False)
|
|
||||||
monkeypatch.setattr(dm.quality, "clean", lambda t, **k: t)
|
|
||||||
monkeypatch.setattr(dm.quality, "is_hallucination", lambda *a, **k: False)
|
|
||||||
delivered = []
|
|
||||||
monkeypatch.setattr(dm, "deliver", lambda *a, **k: delivered.append((a, k)))
|
|
||||||
return delivered
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_discards_when_cancel_spoken(monkeypatch, tmp_path):
|
|
||||||
delivered = _wire_clean_pipeline(monkeypatch)
|
|
||||||
monkeypatch.setattr(dm.stt, "transcribe", lambda *a, **k: "ok das ganze bitte abbrechen")
|
|
||||||
d = Daemon(Config())
|
|
||||||
d._prepared = True
|
|
||||||
d.cfg.cancel_keywords = ["abbrechen", "cancel"]
|
|
||||||
|
|
||||||
audio = tmp_path / "clip.wav"
|
|
||||||
audio.write_bytes(b"x")
|
|
||||||
d._process(audio, Workflow(name="Transcribe", hotkey="", mode="transcribe"), None)
|
|
||||||
|
|
||||||
assert delivered == [], "a voice-cancelled clip must never be typed"
|
|
||||||
assert not audio.exists(), "the temp clip is still cleaned up afterwards"
|
|
||||||
assert d._busy is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_delivers_without_cancel_word(monkeypatch, tmp_path):
|
|
||||||
"""Control: the same path with no cancel word still types normally."""
|
|
||||||
delivered = _wire_clean_pipeline(monkeypatch)
|
|
||||||
monkeypatch.setattr(dm.stt, "transcribe", lambda *a, **k: "hallo welt")
|
|
||||||
d = Daemon(Config())
|
|
||||||
d._prepared = True
|
|
||||||
d.cfg.cancel_keywords = ["abbrechen", "cancel"]
|
|
||||||
|
|
||||||
audio = tmp_path / "clip.wav"
|
|
||||||
audio.write_bytes(b"x")
|
|
||||||
d._process(audio, Workflow(name="Transcribe", hotkey="", mode="transcribe"), None)
|
|
||||||
|
|
||||||
assert len(delivered) == 1, "a normal clip must still be delivered"
|
|
||||||
assert delivered[0][0][0] == "hallo welt"
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
"""Spoken send: a configured word delivers the clip AND presses Enter.
|
|
||||||
|
|
||||||
The spoken equivalent of stop+paste+Enter — mainly hands-free. The keyword is
|
|
||||||
stripped from the transcript before the rest is delivered and submitted.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import blitztext.daemon as dm
|
|
||||||
import blitztext.paste as paste
|
|
||||||
from blitztext.config import Config, Workflow
|
|
||||||
from blitztext.daemon import Daemon
|
|
||||||
from blitztext.routing import match_send
|
|
||||||
|
|
||||||
|
|
||||||
# -- matcher --------------------------------------------------------------------
|
|
||||||
def test_match_send_strips_edges_only():
|
|
||||||
kws = ["computer send", "computer abschicken"]
|
|
||||||
assert match_send("computer send", kws)[0] == "computer send" # whole utterance
|
|
||||||
kw, text = match_send("hey team the build is green computer send", kws)
|
|
||||||
assert kw == "computer send" and text == "hey team the build is green" # end edge, stripped
|
|
||||||
kw, text = match_send("computer send hey team the build is green", kws)
|
|
||||||
assert kw == "computer send" and text == "hey team the build is green" # start edge, stripped
|
|
||||||
# A bare word inside a sentence must NOT submit — the phrase is distinctive.
|
|
||||||
assert match_send("please send me the report tomorrow", kws)[0] is None
|
|
||||||
assert match_send("computer send", [])[0] is None # disabled
|
|
||||||
assert match_send("", kws)[0] is None
|
|
||||||
|
|
||||||
|
|
||||||
# -- pipeline -------------------------------------------------------------------
|
|
||||||
def _wire_clean_pipeline(monkeypatch):
|
|
||||||
monkeypatch.setattr(dm, "detect_recorder", lambda pref="auto": "pw-record")
|
|
||||||
monkeypatch.setattr(dm, "notify", lambda *a, **k: None)
|
|
||||||
monkeypatch.setattr(dm.quality, "analyze_wav", lambda p: (2.0, 0.5))
|
|
||||||
monkeypatch.setattr(dm.quality, "too_quiet", lambda *a, **k: False)
|
|
||||||
monkeypatch.setattr(dm.quality, "clean", lambda t, **k: t)
|
|
||||||
monkeypatch.setattr(dm.quality, "is_hallucination", lambda *a, **k: False)
|
|
||||||
delivered = []
|
|
||||||
monkeypatch.setattr(dm, "deliver", lambda *a, **k: delivered.append((a, k)))
|
|
||||||
enters = []
|
|
||||||
monkeypatch.setattr(paste, "press_enter", lambda win=None: enters.append(win))
|
|
||||||
return delivered, enters
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_sends_with_enter_when_send_spoken(monkeypatch, tmp_path):
|
|
||||||
delivered, enters = _wire_clean_pipeline(monkeypatch)
|
|
||||||
monkeypatch.setattr(dm.stt, "transcribe", lambda *a, **k: "die nachricht ist fertig computer send")
|
|
||||||
d = Daemon(Config())
|
|
||||||
d._prepared = True
|
|
||||||
d.cfg.send_keywords = ["computer send"]
|
|
||||||
d.cfg.cancel_keywords = []
|
|
||||||
|
|
||||||
audio = tmp_path / "clip.wav"; audio.write_bytes(b"x")
|
|
||||||
d._process(audio, Workflow(name="Transcribe", hotkey="", mode="transcribe"), None)
|
|
||||||
|
|
||||||
assert len(delivered) == 1
|
|
||||||
assert delivered[0][0][0] == "die nachricht ist fertig" # keyword stripped
|
|
||||||
assert len(enters) == 1, "send keyword must press Enter"
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_no_enter_without_send_word(monkeypatch, tmp_path):
|
|
||||||
"""Control: the same path without a send word delivers but never hits Enter."""
|
|
||||||
delivered, enters = _wire_clean_pipeline(monkeypatch)
|
|
||||||
monkeypatch.setattr(dm.stt, "transcribe", lambda *a, **k: "die nachricht ist fertig")
|
|
||||||
d = Daemon(Config())
|
|
||||||
d._prepared = True
|
|
||||||
d.cfg.send_keywords = ["computer send"]
|
|
||||||
d.cfg.cancel_keywords = []
|
|
||||||
|
|
||||||
audio = tmp_path / "clip.wav"; audio.write_bytes(b"x")
|
|
||||||
d._process(audio, Workflow(name="Transcribe", hotkey="", mode="transcribe"), None)
|
|
||||||
|
|
||||||
assert len(delivered) == 1
|
|
||||||
assert delivered[0][0][0] == "die nachricht ist fertig"
|
|
||||||
assert enters == [], "no send word → no Enter"
|
|
||||||
|
|
||||||
|
|
||||||
def test_send_keywords_round_trip(tmp_path):
|
|
||||||
from blitztext.config import load, save
|
|
||||||
p = tmp_path / "config.toml"
|
|
||||||
cfg = load(p)
|
|
||||||
assert cfg.send_keywords == [] # shipped default: off
|
|
||||||
cfg.send_keywords = ["computer send", "computer abschicken"]
|
|
||||||
save(cfg, p)
|
|
||||||
assert load(p).send_keywords == ["computer send", "computer abschicken"]
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
"""Wakeword benchmark: deterministic parts (script, audio decode, framing, scoring).
|
|
||||||
|
|
||||||
The network parts (TTS /audio/speech, streaming to wyoming-openwakeword) need
|
|
||||||
live servers and are exercised manually from Settings → Benchmark; here we cover
|
|
||||||
everything that can be checked offline.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import random
|
|
||||||
import wave
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from blitztext import wakeword_bench as wb
|
|
||||||
|
|
||||||
|
|
||||||
def test_wakeword_phrase():
|
|
||||||
assert wb.wakeword_phrase("okay_computer") == "okay computer"
|
|
||||||
assert wb.wakeword_phrase("hey_jarvis") == "hey jarvis"
|
|
||||||
assert wb.wakeword_phrase("models/okay_nabu.tflite") == "okay nabu"
|
|
||||||
assert wb.wakeword_phrase("alexa_v0.1") == "alexa"
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_utterances_deterministic_and_covers_voices():
|
|
||||||
a = wb.build_utterances("computer", 8, "de", voices=["nova", "onyx"], rng=random.Random(7))
|
|
||||||
b = wb.build_utterances("computer", 8, "de", voices=["nova", "onyx"], rng=random.Random(7))
|
|
||||||
key = lambda us: [(u.text, u.has_wakeword, u.voice) for u in us]
|
|
||||||
assert key(a) == key(b) # seeded → reproducible
|
|
||||||
assert sum(u.has_wakeword for u in a) == 8
|
|
||||||
assert all(u.voice in ("nova", "onyx") for u in a)
|
|
||||||
assert all("computer" in u.text for u in a if u.has_wakeword)
|
|
||||||
assert any(not u.has_wakeword for u in a) # filler added for false-fire check
|
|
||||||
|
|
||||||
|
|
||||||
def test_wav_to_pcm_resamples_to_16k_mono():
|
|
||||||
buf = io.BytesIO()
|
|
||||||
with wave.open(buf, "wb") as w:
|
|
||||||
w.setnchannels(2); w.setsampwidth(2); w.setframerate(48000)
|
|
||||||
n = 48000 # 1.0 s stereo @ 48 kHz
|
|
||||||
tone = (np.sin(2 * np.pi * 440 * np.linspace(0, 1, n, endpoint=False)) * 8000).astype("<i2")
|
|
||||||
w.writeframes(np.repeat(tone, 2).tobytes())
|
|
||||||
pcm = wb._wav_to_pcm16k(buf.getvalue())
|
|
||||||
assert len(pcm) == 16000 * 2 # 1.0 s of 16 kHz mono s16le
|
|
||||||
|
|
||||||
|
|
||||||
def test_drain_detections_handles_payloads():
|
|
||||||
det = (json.dumps({"type": "detection", "data": {}}) + "\n").encode()
|
|
||||||
chunk = (json.dumps({"type": "audio-chunk", "payload_length": 3}) + "\n").encode() + b"abc"
|
|
||||||
rest, n = wb._drain_detections(chunk + det)
|
|
||||||
assert n == 1 and rest == b"" # payload skipped, detection counted
|
|
||||||
held = (json.dumps({"type": "x", "payload_length": 10}) + "\n").encode() + b"ab"
|
|
||||||
rest2, n2 = wb._drain_detections(held)
|
|
||||||
assert n2 == 0 and rest2 == held # incomplete payload held, not misparsed
|
|
||||||
|
|
||||||
|
|
||||||
def test_bench_result_metrics():
|
|
||||||
r = wb.BenchResult(utterances=[
|
|
||||||
wb.Utterance("a", True, "nova", detections=1, ok=True),
|
|
||||||
wb.Utterance("b", True, "nova", detections=0, ok=True),
|
|
||||||
wb.Utterance("c", True, "onyx", detections=2, ok=True),
|
|
||||||
wb.Utterance("d", False, "onyx", detections=1, ok=True), # false fire
|
|
||||||
wb.Utterance("e", True, "x", ok=False, error="boom"), # failed → excluded
|
|
||||||
])
|
|
||||||
assert r.expected == 3 and r.detected == 2
|
|
||||||
assert abs(r.recall - 2 / 3) < 1e-9
|
|
||||||
assert r.false_fires == 1
|
|
||||||
assert r.recall_by_voice() == {"nova": (1, 2), "onyx": (1, 1)}
|
|
||||||
@ -1,8 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extraPaths": [
|
"extraPaths": [
|
||||||
"/usr/lib/python3/dist-packages"
|
"/usr/lib/python3/dist-packages"
|
||||||
],
|
]
|
||||||
"venvPath": "linux",
|
|
||||||
"venv": ".venv",
|
|
||||||
"reportMissingModuleSource": "none"
|
|
||||||
}
|
}
|
||||||
|
|||||||