diff --git a/.github/workflows/unified-pipeline.yml b/.github/workflows/unified-pipeline.yml
index 914c50b..0970a4c 100644
--- a/.github/workflows/unified-pipeline.yml
+++ b/.github/workflows/unified-pipeline.yml
@@ -124,6 +124,10 @@ jobs:
run: |
uv run --locked --only-group lint isort --check-only --diff --profile black src/ tests/
+ - name: Type check (mypy)
+ run: |
+ uv run --locked --only-group lint mypy src/
+
# --locked above fails when uv.lock drifts from pyproject.toml; nothing
# covered the step after that one. The exports are hash-pinned copies of
# the lock written by `just lock`, and no workflow ever re-ran it, so one
diff --git a/AGENTS.md b/AGENTS.md
index e3048a9..2388314 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -37,7 +37,7 @@ Rules:
| GTK | GTK 3 via distro `python3-gi` (PyGObject). Never pip-install it |
| uv | `>=0.12,<0.13` (`[tool.uv]` in `pyproject.toml`). `uv.lock` is the source of truth; CI runs `uv sync --locked` / `uv run --locked`, which fails on drift |
| just | https://just.systems or distro package `just` |
-| Format / lint / types | Black + isort (line length 100), flake8 (`E9,F63,F7,F82` only), mypy `src/` (targets 3.11). The three linters live in the `lint` dependency group, not the `dev` extra, so CI can install them without building the project |
+| Format / lint / types | Black + isort (line length 100), flake8 (`E9,F63,F7,F82` only), mypy `src/` (targets 3.11 and runs as a CI lint gate). The tools live in the `lint` dependency group, not the `dev` extra, so CI can install them without building the project |
| Website | Next.js / TypeScript in `web/` — see `web/AGENTS.md` |
Two virtualenvs, on purpose:
@@ -75,7 +75,7 @@ uv pip install -e ".[dev,vad]"
```bash
just lint # flake8 (critical) + black --check + isort --check
just format # black + isort
-just typecheck # mypy src/
+just typecheck # mypy src/ (CI lint gate)
just test # pytest -v
just test-cov # pytest --cov=src --cov-report=html
just deps # sync .venv with dev+vad extras and the lint group
@@ -141,33 +141,42 @@ Optional extras: `vosk`, `whisper`, `vad`, `dev`.
```
src/vocalinux/
-├── main.py, version.py, common_types.py
+├── main.py, version.py, common_types.py, dbus_service.py, post_processor.py
├── audio/
+│ ├── capture.py # PortAudio capture sources and device handling
+│ ├── pipewire.py # PipeWire system-audio capture
│ └── playback_ducker.py # lower default sink while dictating
├── single_instance.py # $XDG_DATA_HOME/vocalinux/instance.lock
├── auto_pause_monitor.py # unload model while configured apps run
├── model_keepalive.py # idle unload
├── suspend_handler.py # logind PrepareForSleep
├── custom_dictionary.py # terms bias + transcript correction file contracts
+├── gateway_embed/ # optional local VocaGateway lifecycle
├── speech_recognition/
-│ ├── recognition_manager.py # whisper.cpp / Whisper / Vosk / remote
+│ ├── recognition_manager.py # VAD, segmentation, and engine dispatch
+│ ├── engines/ # engine implementations and availability
│ ├── command_processor.py # voice commands
+│ ├── command_phrases.py # voice command phrases and localized forms
│ ├── diarization.py # TinyDiarize per-speaker file transcription (--transcribe-file / tray)
│ ├── silero_vad.py
│ └── data/ # bundled silero_vad.onnx
├── text_injection/
-│ ├── text_injector.py # X11 / clipboard / xdotool
+│ ├── text_injector.py # DesktopEnvironment backend selection
+│ ├── focused_window.py # focused application detection
+│ ├── remote_desktop_portal.py # Wayland Remote Desktop Portal support
│ └── ibus_engine.py # Wayland IBus injection
├── ui/
-│ ├── tray_indicator.py, settings_dialog.py, first_run_dialog.py
+│ ├── tray_indicator.py, settings_dialog.py, settings_about.py
+│ ├── settings_engines.py, first_run_dialog.py, dictation_overlay.py
+│ ├── dictation_pad.py
│ ├── transcript_dialog.py # speaker-tagged transcript viewer/export
│ ├── config_manager.py, action_handler.py, audio_feedback.py
-│ ├── autostart_manager.py, keyboard_shortcuts.py
-│ ├── logging_dialog.py, logging_manager.py
+│ ├── autostart_manager.py, keyboard_shortcuts.py, notifications.py
+│ ├── logging_dialog.py, logging_manager.py, transcription_history.py
│ └── keyboard_backends/ # pynput (X11), evdev (Wayland)
├── utils/
-│ ├── paths.py, resource_manager.py
-│ ├── update_checker.py, update_monitor.py
+│ ├── paths.py, resource_manager.py, host_process.py
+│ ├── update_checker.py, update_monitor.py, gtk_color_scheme.py
│ ├── pywhispercpp_loader.py # bundled-lib preloader for source-built installs
│ └── whispercpp_model_info.py, vosk_model_info.py
└── resources/ # SVG icons + WAV cues (also repo resources/)
@@ -182,7 +191,7 @@ names; `install.d/package_map.sh` is generated by `just distro-packages`.
|---|---|
| Voice command | `speech_recognition/command_processor.py` |
| Engine / models | `speech_recognition/recognition_manager.py` |
-| Text injection | `text_injection/text_injector.py`, `ibus_engine.py` |
+| Text injection | `text_injection/text_injector.py`, `focused_window.py`, `remote_desktop_portal.py`, `ibus_engine.py` |
| Settings | `ui/config_manager.py`, `ui/settings_dialog.py` |
| Hotkeys | `ui/keyboard_shortcuts.py`, `ui/keyboard_backends/` |
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..6587f8b
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,111 @@
+# Architecture
+
+Vocalinux is a GTK 3 desktop application. `main.py` wires together the
+configuration manager, recognition manager, tray UI, D-Bus service, and text
+injector. Audio capture and recognition run outside the GTK event loop.
+
+## Module map
+
+- `main.py` composes the application services and callbacks.
+- `audio/capture.py` and `audio/pipewire.py` provide capture sources.
+- `speech_recognition/` owns VAD, segment processing, command handling, and
+ engine implementations under `engines/`.
+- `post_processor.py` runs configured transcript processing scripts.
+- `text_injection/` contains `TextInjector`, focused-window detection, IBus,
+ and Remote Desktop Portal support.
+- `ui/` contains the tray, Settings, dictation pad, history, and keyboard
+ backend modules.
+- `dbus_service.py` exposes desktop control methods. `gateway_embed/` manages
+ the optional local VocaGateway integration.
+
+## Dictation data flow
+
+```text
+PortAudio or PipeWire
+ |
+ v
+audio/capture.py and audio/pipewire.py
+ |
+ v
+recognition_manager.py: VAD and silence segmentation
+ | |
+ | Silero VAD, when available
+ | amplitude VAD otherwise
+ v
+bounded segment queue
+ |
+ v
+recognition worker -> selected speech engine -> corrections and commands
+ |
+ v
+text callback -> post_processor.py -> TextInjector backend
+```
+
+`PortAudioCaptureSource` and the PipeWire source provide mono 16 kHz PCM chunks.
+The recognition manager owns dictation segmentation. It uses Silero VAD when
+available and amplitude thresholds otherwise, then places finalized segments
+on a bounded queue. A recognition worker consumes the queue, dispatches each
+segment to the selected engine, applies dictionary corrections and voice
+command parsing, and invokes callbacks.
+
+The text callback submits work to one ordered post-processing worker. That
+worker runs the configured post-processing script, then injects text or places
+it in the in-app dictation pad. `TextInjector` selects a backend from the
+detected `DesktopEnvironment`: X11 uses xdotool, Wayland can use wtype or
+ydotool, and the IBus and Remote Desktop Portal paths support their respective
+sessions.
+
+## Threads and state ownership
+
+- The GTK main thread owns widgets, dialog state, tray state, and the GLib main
+ loop. UI-facing state callbacks use `GLib.idle_add` where needed; the D-Bus
+ control service also marshals Toggle, Start, and Stop calls to this loop.
+- The audio capture thread reads the capture source, tracks the current audio
+ buffer and VAD state, and enqueues completed segments. The recognition worker
+ owns queue consumption and transcription callback execution.
+- The recognition manager's model lock protects engine and recognizer access
+ during transcription, initialization, and cleanup. The segment queue is
+ thread-safe; capture-buffer handoff uses its buffer lock.
+- Text callbacks enqueue work on a single post-processing worker so script
+ execution and injection stay in spoken order. Clipboard restoration can run
+ on a separate worker. `TextInjector` protects its injection and clipboard
+ restore bookkeeping with locks and generation state.
+- Keyboard backends use listener or monitor threads for pynput and evdev.
+ IBus and Remote Desktop Portal injection also use worker threads for their
+ input and D-Bus work.
+- GLib timeouts drive UI polling and progress updates. Recognition and capture
+ workers use their own queue and event waits rather than GTK timers.
+
+## Speech engines
+
+`common_types.SpeechEngine` defines the runtime-checkable engine contract.
+`speech_recognition/engines/available_engines()` reports optional engines in
+Settings display order without importing the large Whisper packages just to
+probe availability. `SettingsDialog` retains a compatibility wrapper for its
+legacy engine-to-availability mapping.
+
+`SpeechRecognitionManager._process_audio_buffer()` uses an engine-name dispatch
+table to call the existing per-engine transcription methods. The manager owns
+engine selection, model lifecycle, segment language snapshots, and the model
+lock. Optional engine packages are loaded only when their engine is selected.
+
+## Configuration and Settings
+
+`ui/config_manager.py` defines `DEFAULT_CONFIG`, loads the user's JSON config,
+and recursively overlays saved values on those defaults. It persists changes
+through `save_config()`. Settings controls generally apply changes as they are
+made and use callbacks to update the running engine or related services; values
+that require a model reload take effect during that reload.
+
+The Settings UI is split across `settings_dialog.py`, `settings_about.py`, and
+`settings_engines.py`. Other UI responsibilities live in focused modules such
+as `tray_indicator.py`, `dictation_overlay.py`, `transcription_history.py`,
+`keyboard_shortcuts.py`, and `keyboard_backends/`.
+
+## Services and optional integrations
+
+`dbus_service.py` owns the `com.vocalinux.Vocalinux` session-bus name and
+provides Toggle, Start, and Stop methods for external controls.
+`gateway_embed/` manages the optional local VocaGateway container lifecycle,
+status, pairing, and configuration. It is separate from the default
+on-device whisper.cpp engine.
diff --git a/docs/README.md b/docs/README.md
index aae7511..0062c05 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -11,6 +11,11 @@ User and maintainer documentation for Vocalinux.
| [UPDATE.md](UPDATE.md) | Users | How to upgrade; release notes by version |
| [DISTRO_COMPATIBILITY.md](DISTRO_COMPATIBILITY.md) | Users | Distro matrix, session caveats |
| [HTTP_REMOTE.md](HTTP_REMOTE.md) | Power users | Remote HTTP transcription |
+| [FLATPAK_REMOTE.md](FLATPAK_REMOTE.md) | Users / maintainers | Self-hosted Flatpak remote and publishing |
+| [GATEWAY_EMBED.md](GATEWAY_EMBED.md) | Users | Optional local VocaGateway integration |
+| [CUSTOM_DICTIONARY_DESIGN.md](CUSTOM_DICTIONARY_DESIGN.md) | Contributors | Custom terms and correction design |
+| [ARCHITECTURE.md](ARCHITECTURE.md) | Contributors | Application modules, data flow, and threads |
+| [Performance benchmarks](benchmarks/README.md) | Contributors | Before and after charts for the performance pass |
| [AUR.md](AUR.md) | Arch users / maintainers | AUR packages and publish secrets |
| [RELEASE_PROCESS.md](RELEASE_PROCESS.md) | Maintainers | Version bump, checklist, tagging |
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index 7bfde58..17d242b 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -34,6 +34,8 @@ When Vocalinux injects through the clipboard (the usual Wayland / ydotool path),
On non-US layouts such as German Neo, that chord uses the key that types **v** on the active layout (not physical KEY_V). Nested terminal panels inside an IDE are often invisible to window-class detection. If paste lands as a literal `^V` or does nothing, open **Settings → Dictation → Clipboard Paste Shortcut** and choose **Ctrl+Shift+V**.
+On X11, **Paste long dictations** opts in to clipboard-pasting dictations of 30 or more characters instead of typing them key by key. It is off by default. Vocalinux restores the previous clipboard contents after pasting unless **Copy to Clipboard** is enabled. If the clipboard contains an image or other non-text content, Vocalinux types the dictation and leaves that content alone.
+
**Other audio.** Settings → Audio → Other audio can lower speakers and headphones while the microphone is open, then put that volume back. It stays off until you turn it on. Level while dictating is a percent of the current volume (0 is silent). Quitting mid-dictation puts the volume back; if the app crashes first, the next launch does.
## Shortcuts
diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md
new file mode 100644
index 0000000..f1c0f43
--- /dev/null
+++ b/docs/benchmarks/README.md
@@ -0,0 +1,56 @@
+# Performance benchmarks
+
+Before and after numbers for the performance pass in
+[#968](https://github.com/VocaHQ/vocalinux/pull/968). "main" is the code before
+that PR. All runs used the same box: 8 vCPU Intel Xeon Platinum 8559C, Python
+3.13, pywhispercpp 1.5.1, X11.
+
+## Settings first open
+
+
+
+The Settings dialog used to import every installed engine package (Vosk,
+OpenAI Whisper, faster-whisper) just to see whether it was installed. With all
+optional engines installed, the first open took 1.38 s and the process kept an
+extra 606 MB for the rest of its life. Probing with `importlib.util.find_spec`
+brings that down to 214 ms and 50 MB.
+
+## Text injection on X11
+
+
+
+Measured by injecting text into KWrite through `TextInjector.inject_text`,
+mean of 3 runs. Dropping the 0.1 s sleep after every 20 characters saves
+about 2 s per 420 characters. Turning on Settings > "Paste long dictations"
+sends text of 30 or more characters through the clipboard, which takes about
+40 ms at any length; the previous clipboard content is restored afterwards.
+Typing speed itself is set by xdotool's per-key delay, so absolute typed
+times vary by machine.
+
+## whisper.cpp after a GPU init failure
+
+
+
+When Vulkan or CUDA fails to initialise, the CPU fallback used to keep the
+GPU thread count (cores / 4, so 2 here) instead of the CPU policy
+(cores - 2, capped at 8, so 6 here). Times are for an 11 s clip.
+
+## Silence timeout
+
+
+
+200 LibriSpeech dev-clean utterances (5 per speaker, 26 min of audio) were
+replayed through the same segmentation `_record_audio` uses: 1024-sample
+chunks, Silero VAD at the default sensitivity, and a split whenever silence
+exceeds the timeout. Below 1.2 s some sentences get cut in two at a natural
+pause; at 1.2 s none were. The default is now 1.2 s instead of 2.0 s, so each
+dictation finishes 0.8 s sooner. Read speech pauses less than live dictation,
+so raise the timeout in Settings if you pause mid-sentence to think.
+
+## Where the time goes
+
+On an 11 s utterance with the tiny model, the Python code around the
+transcription took 0.3 ms of a 389 ms call; the rest is native whisper.cpp.
+A standalone `whisper-cli` built from the same whisper.cpp commit was no
+faster, so rewriting the app in another language would not speed up
+transcription.
diff --git a/docs/benchmarks/gpu_fallback.svg b/docs/benchmarks/gpu_fallback.svg
new file mode 100644
index 0000000..22ad54b
--- /dev/null
+++ b/docs/benchmarks/gpu_fallback.svg
@@ -0,0 +1,1356 @@
+
+
+
diff --git a/docs/benchmarks/settings.svg b/docs/benchmarks/settings.svg
new file mode 100644
index 0000000..abf0161
--- /dev/null
+++ b/docs/benchmarks/settings.svg
@@ -0,0 +1,1370 @@
+
+
+
diff --git a/docs/benchmarks/silence_timeout.svg b/docs/benchmarks/silence_timeout.svg
new file mode 100644
index 0000000..5310adb
--- /dev/null
+++ b/docs/benchmarks/silence_timeout.svg
@@ -0,0 +1,1372 @@
+
+
+
diff --git a/docs/benchmarks/typing.svg b/docs/benchmarks/typing.svg
new file mode 100644
index 0000000..df2f7f3
--- /dev/null
+++ b/docs/benchmarks/typing.svg
@@ -0,0 +1,1360 @@
+
+
+
diff --git a/justfile b/justfile
index 3c68954..deccf2a 100644
--- a/justfile
+++ b/justfile
@@ -82,7 +82,7 @@ format: _tooling
# Run type checking (mypy)
typecheck: _tooling
@echo "Running mypy..."
- uv run --no-sync mypy src/
+ uv run --locked --no-sync --only-group lint mypy src/
# Build distribution packages
build:
diff --git a/pyproject.toml b/pyproject.toml
index 7bf24fc..1f07a52 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -102,6 +102,7 @@ lint = [
"black>=23.0.0",
"isort>=5.12.0",
"flake8>=6.0.0",
+ "mypy>=1.0.0",
]
[project.scripts]
diff --git a/src/vocalinux/audio/capture.py b/src/vocalinux/audio/capture.py
index 3274678..ebb6283 100644
--- a/src/vocalinux/audio/capture.py
+++ b/src/vocalinux/audio/capture.py
@@ -15,7 +15,7 @@ import ctypes
import logging
import subprocess
import time
-from typing import TYPE_CHECKING, Any, Optional
+from typing import TYPE_CHECKING, Any, Optional, cast
from .pipewire import (
PIPEWIRE_INDEX_BASE,
@@ -280,7 +280,7 @@ def _resolve_valid_input_device(audio: Any, preferred_index: Optional[int] = Non
Returns:
A valid device index with input channels, or None if none found.
"""
- input_device_indices = []
+ input_device_indices: list[int] = []
default_input_index = None
try:
@@ -345,7 +345,7 @@ def _resolve_valid_input_device(audio: Any, preferred_index: Optional[int] = Non
)
if default_input_index is not None and default_input_index in input_device_indices:
- return default_input_index
+ return cast(int, default_input_index)
return input_device_indices[0]
@@ -644,8 +644,8 @@ def test_audio_input(device_index: Optional[int] = None, duration: float = 1.0)
"error": None,
}
- if is_pipewire_device_index(device_index):
- return _test_pipewire_input(device_index, duration)
+ if is_pipewire_device_index(cast(int, device_index)):
+ return _test_pipewire_input(cast(int, device_index), duration)
try:
import numpy as np
@@ -700,12 +700,12 @@ def test_audio_input(device_index: Optional[int] = None, duration: float = 1.0)
return result
# Record and analyze
- all_amplitudes = []
+ all_amplitudes: list[Any] = []
frames_to_read = int(RATE * duration / CHUNK)
for _ in range(frames_to_read):
try:
- data = stream.read(CHUNK, exception_on_overflow=False)
+ data = cast(Any, stream).read(CHUNK, exception_on_overflow=False)
audio_data = np.frombuffer(data, dtype=np.int16)
amplitudes = np.abs(audio_data)
all_amplitudes.extend(amplitudes)
@@ -724,7 +724,7 @@ def test_audio_input(device_index: Optional[int] = None, duration: float = 1.0)
result["mean_amplitude"] = float(np.mean(all_amplitudes))
# Signal present if max amplitude is above typical digital noise floor
# 16-bit audio has max value of 32768, noise floor is typically < 100
- result["has_signal"] = result["max_amplitude"] > 200
+ result["has_signal"] = cast(float, result["max_amplitude"]) > 200
except ImportError as e:
result["error"] = f"Missing dependency: {e}"
diff --git a/src/vocalinux/auto_pause_monitor.py b/src/vocalinux/auto_pause_monitor.py
index baced9a..1d78e85 100644
--- a/src/vocalinux/auto_pause_monitor.py
+++ b/src/vocalinux/auto_pause_monitor.py
@@ -119,7 +119,7 @@ class AutoPauseMonitor:
self._paused = False
self._timeout_id: Optional[int] = None
self._running = False
- self._last_poll_interval = DEFAULT_POLL_INTERVAL_SECONDS
+ self._last_poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS
@property
def paused(self) -> bool:
diff --git a/src/vocalinux/common_types.py b/src/vocalinux/common_types.py
index b9a21dc..cf1656e 100644
--- a/src/vocalinux/common_types.py
+++ b/src/vocalinux/common_types.py
@@ -4,7 +4,7 @@ This module provides type definitions to avoid circular imports.
"""
from enum import Enum, auto
-from typing import Any, Callable, Iterable, Optional, Protocol # noqa: F401
+from typing import Any, Callable, Iterable, Optional, Protocol, runtime_checkable # noqa: F401
class RecognitionState(Enum):
@@ -51,10 +51,40 @@ class Engine(Protocol):
...
+@runtime_checkable
+class SpeechEngine(Protocol):
+ """Runtime-checkable contract for speech transcription engines."""
+
+ def init(self) -> None:
+ """Initialize the engine and load any required models."""
+ ...
+
+ def is_ready(self) -> bool:
+ """Return True if the engine is initialized and ready to transcribe."""
+ ...
+
+ def transcribe(
+ self,
+ audio_buffer: list[bytes],
+ language: Optional[str] = None,
+ initial_prompt: Optional[str] = None,
+ ) -> str:
+ """Transcribe audio chunks with optional per-call language and prompt."""
+ ...
+
+ def cleanup(self) -> None:
+ """Release any engine resources."""
+ ...
+
+
class SpeechRecognitionManagerProtocol(Protocol):
"""Protocol defining the interface for SpeechRecognitionManager."""
state: RecognitionState
+ state_callbacks: list[Callable[[RecognitionState], None]]
+ buffer_during_reload: bool
+ test_capture_floor: Optional[float]
+ test_capture_ceiling: Optional[float]
def start_recognition(self, mode: str = "toggle") -> bool:
"""Start the speech recognition process. Returns True if listening began."""
@@ -76,6 +106,14 @@ class SpeechRecognitionManagerProtocol(Protocol):
"""Register a callback for recognized text."""
...
+ def get_text_callbacks(self) -> list[Callable[[str], None]]:
+ """Return registered transcript callbacks."""
+ ...
+
+ def set_text_callbacks(self, callbacks: list[Callable[[str], None]]) -> None:
+ """Replace registered transcript callbacks."""
+ ...
+
def register_segment_callback(self, callback: Callable[[str, float], None]) -> None:
"""Register a callback for recognized text with its audio-capture start time."""
...
@@ -123,6 +161,12 @@ class SpeechRecognitionManagerProtocol(Protocol):
"""Register a callback for download progress (fraction, mbps, status)."""
...
+ def set_audio_device(
+ self, device_index: Optional[int], device_name: Optional[str] = None
+ ) -> None:
+ """Set the preferred audio input device."""
+ ...
+
def download_whispercpp_model(self, model_name: str) -> None:
"""Download a catalog whisper.cpp model without changing engine config."""
...
diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py
index 551b2db..bb8ef70 100644
--- a/src/vocalinux/main.py
+++ b/src/vocalinux/main.py
@@ -527,7 +527,7 @@ def main():
logger.info(f"Using model={model_size} (saved for engine {engine})")
vad_sensitivity = saved_settings.get("vad_sensitivity", 3)
- silence_timeout = saved_settings.get("silence_timeout", 2.0)
+ silence_timeout = saved_settings.get("silence_timeout", 1.2)
stop_sound_guard_ms = saved_settings.get("stop_sound_guard_ms", 200)
voice_commands_enabled = saved_settings.get("voice_commands_enabled") # None = auto
audio_device_index = audio_settings.get("device_index", None)
diff --git a/src/vocalinux/speech_recognition/command_phrases.py b/src/vocalinux/speech_recognition/command_phrases.py
index 458b84e..a703d1d 100644
--- a/src/vocalinux/speech_recognition/command_phrases.py
+++ b/src/vocalinux/speech_recognition/command_phrases.py
@@ -7,7 +7,7 @@ punctuation / line-break aliases keyed by Whisper ISO language codes
from __future__ import annotations
-from typing import Optional
+from typing import Optional, cast
from vocalinux.utils.vosk_model_info import SUPPORTED_LANGUAGES
@@ -192,8 +192,9 @@ def normalize_command_language(language: Optional[str]) -> str:
if not language or language == "auto":
return "en"
info = SUPPORTED_LANGUAGES.get(language)
- if info is not None and info.get("whisper"):
- return info["whisper"]
+ language_info = cast(Optional[dict[str, str]], info)
+ if language_info is not None and language_info.get("whisper"):
+ return language_info["whisper"]
if language.startswith("en"):
return "en"
return language
diff --git a/src/vocalinux/speech_recognition/command_processor.py b/src/vocalinux/speech_recognition/command_processor.py
index b43a517..d8d61e0 100644
--- a/src/vocalinux/speech_recognition/command_processor.py
+++ b/src/vocalinux/speech_recognition/command_processor.py
@@ -111,7 +111,7 @@ class CommandProcessor:
}
# Active format modifiers
- self.active_formats = set()
+ self.active_formats: set[str] = set()
# Compile regex patterns for faster matching
self._compile_patterns()
diff --git a/src/vocalinux/speech_recognition/engines/__init__.py b/src/vocalinux/speech_recognition/engines/__init__.py
index b34fb7a..80f0c99 100644
--- a/src/vocalinux/speech_recognition/engines/__init__.py
+++ b/src/vocalinux/speech_recognition/engines/__init__.py
@@ -4,10 +4,14 @@ New engine implementations are registered here so the recognition manager can
discover them without hard-coding every backend.
"""
+import importlib.util
+import logging
from typing import Type
from ...common_types import Engine, EngineType
+logger = logging.getLogger(__name__)
+
ENGINES: dict[EngineType, Type[Engine]] = {}
# Register faster-whisper when available.
@@ -18,4 +22,48 @@ try:
except Exception:
pass
-__all__ = ["ENGINES"]
+
+def _module_available(name: str) -> bool:
+ try:
+ return importlib.util.find_spec(name) is not None
+ except (ImportError, ValueError):
+ return False
+
+
+def available_engines() -> list[str]:
+ """Return installed speech engines in the Settings display order."""
+ engines = {
+ "vosk": _module_available("vosk"),
+ "whisper": _module_available("whisper"),
+ "whisper_cpp": False,
+ "parakeet": False,
+ "faster_whisper": _module_available("faster_whisper"),
+ "remote_api": False,
+ }
+
+ try:
+ from pywhispercpp.model import Model
+
+ engines["whisper_cpp"] = True
+ except ImportError:
+ pass
+
+ try:
+ import sherpa_onnx # noqa: F401
+
+ engines["parakeet"] = True
+ except ImportError:
+ pass
+
+ try:
+ import requests # noqa: F401
+
+ engines["remote_api"] = True
+ except ImportError:
+ pass
+
+ logger.debug("Available engines: %s", engines)
+ return [name for name, available in engines.items() if available]
+
+
+__all__ = ["ENGINES", "available_engines"]
diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py
index 56a1fde..c00fe5a 100644
--- a/src/vocalinux/speech_recognition/recognition_manager.py
+++ b/src/vocalinux/speech_recognition/recognition_manager.py
@@ -10,6 +10,7 @@ import importlib.util
import inspect
import json
import logging
+import multiprocessing
import os
import queue
import re
@@ -18,7 +19,7 @@ import sys
import threading
import time
from pathlib import Path
-from typing import TYPE_CHECKING, Callable, Optional, Union
+from typing import TYPE_CHECKING, Any, Callable, Optional, Union, cast
if TYPE_CHECKING:
import numpy as np
@@ -74,6 +75,7 @@ from ..utils.whisper_model_info import (
from ..utils.whispercpp_model_info import (
NON_DICTATION_MODELS,
WHISPERCPP_MODEL_INFO,
+ ComputeBackend,
get_model_path,
is_english_only_model,
is_model_downloaded,
@@ -145,8 +147,9 @@ def resolve_whisper_language(language: str) -> Optional[str]:
if language == "auto":
return None
info = SUPPORTED_LANGUAGES.get(language)
- if info is not None and "whisper" in info:
- return info["whisper"]
+ language_info = cast(Optional[dict[str, str]], info)
+ if language_info is not None and "whisper" in language_info:
+ return language_info["whisper"]
# Legacy fallback for codes not present in the catalog.
if language == "en-us":
return "en"
@@ -409,9 +412,9 @@ class SpeechRecognitionManager:
)
self._vosk_dictionary_warned = False
self.state = RecognitionState.IDLE
- self.audio_thread = None
- self.recognition_thread = None
- self.model = None
+ self.audio_thread: Optional[threading.Thread] = None
+ self.recognition_thread: Optional[threading.Thread] = None
+ self.model: Any = None
self.recognizer = None # Added for VOSK
self.command_processor = CommandProcessor(language=self.language)
@@ -461,7 +464,7 @@ class SpeechRecognitionManager:
# Speech detection parameters (load defaults, will be overridden by configure)
self.vad_sensitivity = kwargs.get("vad_sensitivity", 3)
- self.silence_timeout = kwargs.get("silence_timeout", 2.0)
+ self.silence_timeout = kwargs.get("silence_timeout", 1.2)
# Silero VAD (neural-network-based, falls back to amplitude if unavailable)
self._silero_vad = load_silero_vad()
@@ -486,7 +489,8 @@ class SpeechRecognitionManager:
self.whispercpp_entropy_thold = kwargs.get("whispercpp_entropy_thold", 2.4)
self.whispercpp_logprob_thold = kwargs.get("whispercpp_logprob_thold", -1.0)
self.whispercpp_no_speech_thold = kwargs.get("whispercpp_no_speech_thold", 0.6)
- self.whispercpp_n_threads = kwargs.get("whispercpp_n_threads", None)
+ self.whispercpp_n_threads = cast(Optional[int], kwargs.get("whispercpp_n_threads", None))
+ self._whispercpp_effective_threads: Optional[int] = None
self.whispercpp_gpu_device = kwargs.get("whispercpp_gpu_device", None)
# Remote API settings
@@ -496,7 +500,7 @@ class SpeechRecognitionManager:
self.remote_api_model = kwargs.get("remote_api_model", "whisper-1")
self._http_session = None
- self._faster_whisper_engine = None
+ self._faster_whisper_engine: Any = None
# Audio diagnostics tracking
self._last_audio_level = 0.0
@@ -505,14 +509,14 @@ class SpeechRecognitionManager:
# Recording control flags
self.should_record = False
self._recognition_mode = "toggle" # "toggle" or "push_to_talk"
- self.audio_buffer = []
+ self.audio_buffer: list[bytes] = []
# Monotonic time the current buffer's first chunk was captured,
# propagated onto each enqueued _AudioSegment.
self._segment_started_at: Optional[float] = None
self._recording_segment_has_speech = False
self._buffer_lock = threading.Lock() # Thread safety for audio_buffer
self._model_lock = threading.Lock() # Thread safety for model/recognizer access
- self._segment_queue = queue.Queue(maxsize=32)
+ self._segment_queue: queue.Queue[tuple[_AudioSegment, str]] = queue.Queue(maxsize=32)
# Reliability improvements - Issue #92
self._max_buffer_size = 5000 # Maximum number of audio chunks in buffer
@@ -1124,7 +1128,7 @@ class SpeechRecognitionManager:
else:
logger.debug("Whisper returned empty transcription")
- return text
+ return cast(str, text)
except (RuntimeError, OSError, ValueError) as e:
logger.error(f"Error in Whisper transcription: {e}", exc_info=True)
@@ -1335,16 +1339,19 @@ class SpeechRecognitionManager:
logger.warning("faster-whisper engine not ready during transcription")
return ""
- return self._faster_whisper_engine.transcribe(
- audio_buffer,
- language=self._dictation_language(language),
- initial_prompt=self._get_dictionary_prompt(),
+ return cast(
+ str,
+ self._faster_whisper_engine.transcribe(
+ audio_buffer,
+ language=self._dictation_language(language),
+ initial_prompt=self._get_dictionary_prompt(),
+ ),
)
except (RuntimeError, OSError, ValueError) as e:
logger.error(f"Error in faster-whisper transcription: {e}", exc_info=True)
return ""
- def _init_whispercpp(self):
+ def _init_whispercpp(self) -> None:
"""Initialize the whisper.cpp speech recognition engine."""
try:
_preload_pywhispercpp_shared_libraries()
@@ -1434,9 +1441,15 @@ class SpeechRecognitionManager:
logger.info("Removed the unverified model; it will be downloaded again")
return False
- def _build_whispercpp_model_kwargs(self, n_threads: int) -> dict:
+ def _whispercpp_cpu_thread_count(self) -> int:
+ if self.whispercpp_n_threads is not None and self.whispercpp_n_threads > 0:
+ return self.whispercpp_n_threads
+ return max(1, min(multiprocessing.cpu_count() - 2, 8))
+
+ def _build_whispercpp_model_kwargs(self, n_threads: int) -> dict[str, object]:
model_kwargs = {
"n_threads": n_threads,
+ "print_progress": False,
"suppress_blank": True,
"no_speech_thold": self.whispercpp_no_speech_thold,
"entropy_thold": self.whispercpp_entropy_thold,
@@ -1565,11 +1578,7 @@ class SpeechRecognitionManager:
Args:
model_path: Filesystem path to the GGML model file.
"""
- import multiprocessing
- import time
-
from ..utils.whispercpp_model_info import (
- ComputeBackend,
_prefer_discrete_vulkan_device,
detect_compute_backend,
detect_cpu_info,
@@ -1671,13 +1680,14 @@ class SpeechRecognitionManager:
has_gpu_libs = actual_gpu_backend in ("vulkan", "cuda")
- if self.whispercpp_n_threads is not None and self.whispercpp_n_threads > 0:
- n_threads = self.whispercpp_n_threads
- elif has_gpu_libs:
+ if has_gpu_libs and not (
+ self.whispercpp_n_threads is not None and self.whispercpp_n_threads > 0
+ ):
n_threads = max(1, multiprocessing.cpu_count() // 4)
else:
- n_threads = max(1, min(multiprocessing.cpu_count() - 2, 8))
+ n_threads = self._whispercpp_cpu_thread_count()
+ self._whispercpp_effective_threads = n_threads
load_start_time = time.time()
loaded_backend = runtime_backend
@@ -1692,23 +1702,30 @@ class SpeechRecognitionManager:
model_error, model_path, model_kwargs, ComputeBackend.CPU
)
+ effective_threads = self._whispercpp_effective_threads
+ if effective_threads is None:
+ effective_threads = n_threads
+
load_duration = time.time() - load_start_time
- if has_gpu_libs:
- logger.info(
- f"whisper.cpp configured with n_threads={n_threads} "
- f"(GPU backend: {actual_gpu_backend})"
- )
- else:
- logger.info(
- f"whisper.cpp configured with n_threads={n_threads} "
- f"(CPU-only; pywhispercpp lacks GPU libraries)"
- )
- logger.info(f"whisper.cpp model loaded in {load_duration:.2f}s ({loaded_backend} backend)")
+ logger.info(
+ f"whisper.cpp configured with n_threads={effective_threads} "
+ f"({loaded_backend} backend)"
+ )
+ logger.info(
+ f"whisper.cpp model loaded in {load_duration:.2f}s "
+ f"({loaded_backend} backend, n_threads={effective_threads})"
+ )
self._model_initialized = True
logger.info("whisper.cpp engine initialized successfully.")
- def _handle_gpu_fallback(self, error, model_path: str, model_kwargs: dict, cpu_backend):
+ def _handle_gpu_fallback(
+ self,
+ error: RuntimeError,
+ model_path: str,
+ model_kwargs: dict[str, object],
+ cpu_backend: str,
+ ) -> str:
"""Handle GPU backend failure by falling back to CPU.
Args:
@@ -1741,7 +1758,12 @@ class SpeechRecognitionManager:
# Force CPU backend by disabling GPU backends
os.environ["GGML_VULKAN"] = "0"
os.environ["GGML_CUDA"] = "0"
- self.model = self._load_model_with_compatible_params(model_path, model_kwargs)
+ cpu_model_kwargs = model_kwargs.copy()
+ cpu_thread_count = self._whispercpp_cpu_thread_count()
+ cpu_model_kwargs["n_threads"] = cpu_thread_count
+ self._whispercpp_effective_threads = cpu_thread_count
+ self.model = self._load_model_with_compatible_params(model_path, cpu_model_kwargs)
+ logger.info("Reloaded on CPU with n_threads=%d", cpu_thread_count)
logger.info("Successfully loaded model with CPU backend")
return cpu_backend
@@ -1815,7 +1837,10 @@ class SpeechRecognitionManager:
",".join(unsupported),
)
if candidate_probs:
- lang = max(candidate_probs, key=candidate_probs.get)
+ lang = max(
+ candidate_probs,
+ key=cast(Callable[[str], float], candidate_probs.get),
+ )
logger.info(
"whisper.cpp restricted language detection: detected=%s %.3f, candidates=%s, using=%s %.3f",
detected[0],
@@ -2107,7 +2132,7 @@ class SpeechRecognitionManager:
stream = self.model.create_stream()
stream.accept_waveform(16000, audio_float)
self.model.decode_stream(stream)
- return stream.result.text.strip()
+ return cast(str, stream.result.text.strip())
except (RuntimeError, OSError, ValueError) as e:
audio_info = (
@@ -2370,7 +2395,7 @@ class SpeechRecognitionManager:
result = response.json()
# whisper.cpp server format returns {"text": "..."}
- return result.get("text", "")
+ return cast(str, result.get("text", ""))
except requests.exceptions.ConnectionError as e:
logger.error(f"Cannot connect to remote server {url}: {e}")
@@ -2644,7 +2669,7 @@ class SpeechRecognitionManager:
system_model_path = os.path.join(system_dir, model_name)
if os.path.exists(system_model_path):
logger.info(f"Found pre-installed model at: {system_model_path}")
- return system_model_path
+ return cast(str, system_model_path)
# If not found anywhere, return the user path (will be created if needed)
logger.debug(f"No existing model found, will use: {user_model_path}")
@@ -3663,6 +3688,29 @@ class SpeechRecognitionManager:
self._process_audio_buffer(audio_buffer)
+ def _transcribe_with_vosk(self, audio_buffer: list[bytes]) -> Optional[str]:
+ """Transcribe one audio segment with the initialized Vosk recognizer."""
+ with self._model_lock:
+ if self.recognizer is None:
+ logger.warning("Recognizer is None during processing, returning empty result")
+ return None
+ for data in audio_buffer:
+ self.recognizer.AcceptWaveform(data)
+
+ result = json.loads(self.recognizer.FinalResult())
+ return result.get("text", "")
+
+ def _transcribe_remote_api_segment(
+ self, audio_buffer: list[bytes], language: Optional[str]
+ ) -> Optional[str]:
+ """Snapshot the HTTP session and transcribe one remote API segment."""
+ with self._model_lock:
+ session = self._http_session
+ if session is None:
+ logger.error("Remote API HTTP session not initialized")
+ return None
+ return self._transcribe_with_remote_api(audio_buffer, session, language)
+
def _process_audio_buffer(
self, audio_buffer: list[bytes], language: Optional[str] = None
) -> None:
@@ -3678,48 +3726,27 @@ class SpeechRecognitionManager:
language = getattr(audio_buffer, "language", None)
dictation_language = self._dictation_language(language)
- if self.engine == "vosk":
- # Lock recognizer access to prevent race condition with reconfigure
- with self._model_lock:
- # Check if recognizer is still valid
- if self.recognizer is None:
- logger.warning("Recognizer is None during processing, returning empty result")
- return
- for data in audio_buffer:
- self.recognizer.AcceptWaveform(data)
-
- result = json.loads(self.recognizer.FinalResult())
- text = result.get("text", "")
-
- elif self.engine == "whisper":
- text = self._transcribe_with_whisper(audio_buffer, dictation_language)
-
- elif self.engine == "whisper_cpp":
- text = self._transcribe_with_whispercpp(audio_buffer, dictation_language)
-
- elif self.engine == "parakeet":
- text = self._transcribe_with_parakeet(audio_buffer)
-
- elif self.engine == "faster_whisper":
- text = self._transcribe_with_faster_whisper(audio_buffer, dictation_language)
-
- elif self.engine == "remote_api":
- # Snapshot the HTTP session under lock to prevent race with
- # reconfigure() / reinitialize_after_resume() which close/recreate
- # the session under _model_lock. The snapshot (a local reference)
- # remains valid even if another thread closes the old session —
- # urllib3's PoolManager keeps existing connections alive until the
- # in-flight request completes.
- with self._model_lock:
- session = self._http_session
- if session is None:
- logger.error("Remote API HTTP session not initialized")
- return
- text = self._transcribe_with_remote_api(audio_buffer, session, dictation_language)
-
- else:
+ transcribers: dict[str, Callable[[], Optional[str]]] = {
+ "vosk": lambda: self._transcribe_with_vosk(audio_buffer),
+ "whisper": lambda: self._transcribe_with_whisper(audio_buffer, dictation_language),
+ "whisper_cpp": lambda: self._transcribe_with_whispercpp(
+ audio_buffer, dictation_language
+ ),
+ "parakeet": lambda: self._transcribe_with_parakeet(audio_buffer),
+ "faster_whisper": lambda: self._transcribe_with_faster_whisper(
+ audio_buffer, dictation_language
+ ),
+ "remote_api": lambda: self._transcribe_remote_api_segment(
+ audio_buffer, dictation_language
+ ),
+ }
+ transcriber = transcribers.get(self.engine)
+ if transcriber is None:
logger.error(f"Unknown engine: {self.engine}")
return
+ text = transcriber()
+ if text is None:
+ return
# Process text - either with voice commands or pass through directly
logger.debug(f"_process_audio_buffer got text='{text[:50] if text else '(empty)'}...'")
diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py
index 887ade3..327e29b 100644
--- a/src/vocalinux/text_injection/ibus_engine.py
+++ b/src/vocalinux/text_injection/ibus_engine.py
@@ -25,7 +25,7 @@ import sys
import threading
import time
from pathlib import Path
-from typing import Callable, Optional
+from typing import Any, Callable, Mapping, Optional
# Package import when loaded normally; absolute / inline fallbacks when this
# file is executed by path (Vocalinux start_engine_process / IBus component exec).
@@ -39,7 +39,7 @@ except ImportError: # pragma: no cover - exercised via script-path subprocess
except ImportError:
# Last resort: same semantics as the vocalinux.utils originals, except
# that host_env cannot strip a bundle it can no longer import.
- def host_env(base=None):
+ def host_env(base: Optional[Mapping[str, str]] = None) -> dict[str, str]:
return dict(os.environ if base is None else base)
def xdg_data_home() -> str:
@@ -1135,7 +1135,10 @@ def stop_engine_process(wait_timeout: float = 2.0) -> None:
PID_FILE.unlink()
-class VocalinuxEngine(IBus.Engine if IBUS_AVAILABLE else object):
+IBusEngineBase: Any = IBus.Engine if IBUS_AVAILABLE else object
+
+
+class VocalinuxEngine(IBusEngineBase):
"""
IBus proxy engine for Vocalinux text injection.
@@ -1464,7 +1467,7 @@ class IBusTextInjector:
# Empty until a real X11 layout is captured. A non-empty default (e.g.
# "us") would make stop()/restore re-apply a layout that was never
# captured, flipping non-US users to us. See issue #474.
- self._previous_xkb_layout: tuple = ("", "", "")
+ self._previous_xkb_layout: Optional[tuple[str, str, str]] = ("", "", "")
if auto_activate:
self._setup_engine()
@@ -1598,6 +1601,7 @@ class IBusTextInjector:
when the live engine is Vocalinux or unavailable (failed inject restore).
"""
current = get_current_engine()
+ restore_engine: Optional[str]
if current and current != ENGINE_NAME:
restore_engine = current
else:
diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py
index 2da8e6a..995af70 100644
--- a/src/vocalinux/text_injection/text_injector.py
+++ b/src/vocalinux/text_injection/text_injector.py
@@ -14,7 +14,7 @@ import subprocess
import threading
import time
from enum import Enum
-from typing import List, Optional, Tuple
+from typing import List, Optional, Tuple, cast
from ..utils.host_process import host_env
from ..utils.paths import config_dir
@@ -32,6 +32,18 @@ from .remote_desktop_portal import (
)
_PORTAL_SUBMIT_TIMEOUT_S = 195.0 # portal's _START_TIMEOUT_S + _REQUEST_TIMEOUT_S
+_X11_PASTE_MIN_CHARS = 30
+_X11_TEXT_CLIPBOARD_TARGETS = frozenset(
+ {
+ "utf8_string",
+ "string",
+ "text",
+ "text/plain",
+ "text/plain;charset=utf-8",
+ "compound_text",
+ }
+)
+_X11_CLIPBOARD_META_TARGETS = frozenset({"targets", "timestamp", "multiple", "save_targets"})
logger = logging.getLogger(__name__)
@@ -149,7 +161,7 @@ class TextInjector:
self._state_lock = threading.Lock()
self._abort_injections = threading.Event()
self.last_typed_count = 0
- self._clipboard_tool_health = {}
+ self._clipboard_tool_health: dict[str, bool] = {}
self._clipboard_timeout = 0.35
# Overlapping ydotool pastes: bump generation to cancel stale restores;
# target keeps the original pre-injection clipboard across the window.
@@ -1564,7 +1576,9 @@ class TextInjector:
if os.path.exists(config_path):
with open(config_path, "r") as f:
config = json.load(f)
- return config.get("text_injection", {}).get("copy_to_clipboard", False)
+ return cast(
+ bool, config.get("text_injection", {}).get("copy_to_clipboard", False)
+ )
except Exception as e:
logger.debug(f"Could not read copy_to_clipboard setting: {e}")
return False
@@ -1785,7 +1799,7 @@ class TextInjector:
logger.warning("Could not import audio feedback module")
return False
- def _inject_with_xdotool(self, text: str):
+ def _inject_with_xdotool(self, text: str) -> None:
"""
Inject text using xdotool for X11 environments.
@@ -1845,6 +1859,21 @@ class TextInjector:
except Exception as e:
logger.debug(f"Window focus command failed: {e}")
+ if (
+ self.environment == DesktopEnvironment.X11
+ and len(text) >= _X11_PASTE_MIN_CHARS
+ and self._paste_long_text_enabled()
+ ):
+ self._wait_for_modifiers_released()
+ if self._clipboard_holds_non_text():
+ logger.info(
+ "Clipboard holds non-text content; typing instead of pasting to preserve it"
+ )
+ elif self._inject_via_clipboard_paste(text, env=env):
+ return
+ else:
+ logger.warning("Long-text clipboard paste failed; falling back to xdotool typing")
+
# Inject text using xdotool
try:
max_retries = 2
@@ -1921,10 +1950,6 @@ class TextInjector:
)
typed += 1 # the '\n' the Return produced
- # Add a larger delay between chunks
- if i + chunk_size < len(text):
- time.sleep(0.1)
-
logger.info(
f"Text injected using xdotool: '{text[:20]}...' ({len(text)} chars)"
)
@@ -1996,10 +2021,14 @@ class TextInjector:
Returns the text, "" if a tool reports a verifiably empty clipboard,
or None if unreadable as text (non-text data, no tool, or error).
- On WAYLAND_XDOTOOL this reads the X11 CLIPBOARD (xclip/xsel), matching
- the selection the xdotool paste wrote. wl-paste is a different clipboard.
+ On X11 and WAYLAND_XDOTOOL this reads the X11 CLIPBOARD (xclip/xsel),
+ matching the selection the xdotool paste wrote. wl-paste is a different
+ clipboard.
"""
- prefer_x11 = self.environment == DesktopEnvironment.WAYLAND_XDOTOOL
+ prefer_x11 = self.environment in (
+ DesktopEnvironment.X11,
+ DesktopEnvironment.WAYLAND_XDOTOOL,
+ )
host_is_wayland = (
self._session_environment == DesktopEnvironment.WAYLAND
or os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland"
@@ -2041,6 +2070,40 @@ class TextInjector:
return "" if saw_empty else None
+ def _clipboard_holds_non_text(self) -> bool:
+ """Return whether the X11 clipboard has no readable text target."""
+ if self.environment != DesktopEnvironment.X11:
+ return False
+ if not shutil.which("xclip"):
+ return self._read_clipboard() is None
+
+ try:
+ result = subprocess.run(
+ ["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ timeout=1.0,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ env=host_env(),
+ )
+ except (subprocess.TimeoutExpired, OSError, UnicodeDecodeError):
+ logger.debug("Could not inspect X11 clipboard targets")
+ return self._read_clipboard() is None
+
+ if result.returncode != 0 or not result.stdout.strip():
+ return False
+
+ targets = {
+ target.strip().casefold() for target in result.stdout.splitlines() if target.strip()
+ }
+ if not targets:
+ return False
+
+ content_targets = targets.difference(_X11_CLIPBOARD_META_TARGETS)
+ return not any(target in _X11_TEXT_CLIPBOARD_TARGETS for target in content_targets)
+
def _paste_shortcut_preference(self) -> str:
"""Return the configured clipboard-paste shortcut id."""
from ..ui.config_manager import DEFAULT_PASTE_SHORTCUT, normalize_paste_shortcut
@@ -2059,6 +2122,24 @@ class TextInjector:
logger.debug(f"Could not read paste_shortcut setting: {exc}")
return DEFAULT_PASTE_SHORTCUT
+ def _paste_long_text_enabled(self) -> bool:
+ """Return whether long X11 dictations should use clipboard paste."""
+ try:
+ import json
+
+ config_path = os.path.join(config_dir(), "config.json")
+ if not os.path.exists(config_path):
+ return False
+ with open(config_path, "r") as handle:
+ config = json.load(handle)
+ text_injection = config.get("text_injection", {})
+ if not isinstance(text_injection, dict):
+ return False
+ return bool(text_injection.get("paste_long_text", False))
+ except (OSError, ValueError, TypeError, AttributeError) as exc:
+ logger.debug("Could not read paste_long_text setting: %s", exc)
+ return False
+
def _should_use_terminal_paste(self) -> bool:
"""Return True when clipboard injection should send Ctrl+Shift+V.
@@ -2085,12 +2166,12 @@ class TextInjector:
that chord instead. Workaround for ydotool's US-ASCII-only key events
(see issue #362). The chord itself is sent with wtype keysyms when
that tool is usable, otherwise with a layout-resolved ydotool keycode
- for Latin 'v' (issue #787). On WAYLAND_XDOTOOL the copy uses xclip/xsel
- and the chord is xdotool's, because the focused XWayland window pastes
- the X11 CLIPBOARD (#657). Saves the previous clipboard and restores it
- after a short delay. Overlapping pastes share one restore target
- (pre-first-injection content) and a generation counter so stale restore
- threads exit.
+ for Latin 'v' (issue #787). On X11 and WAYLAND_XDOTOOL the copy uses
+ xclip/xsel and the chord is xdotool's, because the focused X11/XWayland
+ window pastes the X11 CLIPBOARD (#657). Saves the previous clipboard and
+ restores it after a short delay. Overlapping pastes share one restore
+ target (pre-first-injection content) and a generation counter so stale
+ restore threads exit.
Args:
text: The text to inject.
@@ -2100,12 +2181,12 @@ class TextInjector:
Returns:
True if successful, False otherwise
"""
- logger.debug(
- "Using clipboard-paste injection for non-ASCII text "
- "(saving clipboard to restore after paste)"
- )
+ logger.debug("Using clipboard-paste injection (saving clipboard to restore after paste)")
- prefer_x11 = self.environment == DesktopEnvironment.WAYLAND_XDOTOOL
+ prefer_x11 = self.environment in (
+ DesktopEnvironment.X11,
+ DesktopEnvironment.WAYLAND_XDOTOOL,
+ )
tools = self._x11_clipboard_tools() if prefer_x11 else None
# Inherit a pending restore target so a second paste in the delay window
@@ -2133,7 +2214,7 @@ class TextInjector:
):
self._clipboard_restore_target = previous_clipboard
- # Simulate paste. XWayland uses xdotool against the X11 CLIPBOARD;
+ # Simulate paste. X11/XWayland uses xdotool against the X11 CLIPBOARD;
# native Wayland prefers wtype keysyms, then layout-resolved ydotool.
# ydotool syntax differs by version:
# - 0.1.x (distro packages): named sequences, e.g. ctrl+v
@@ -2408,11 +2489,14 @@ class TextInjector:
def _clipboard_paste_command(self, *, terminal: bool = False) -> list:
"""Return argv for the clipboard paste chord.
- On WAYLAND_XDOTOOL, xdotool against the X11 CLIPBOARD (what an XWayland
- window pastes from). Otherwise prefers wtype keysyms, then ydotool
- with a layout-resolved keycode for 'v'.
+ On X11 and WAYLAND_XDOTOOL, xdotool pastes from the X11 CLIPBOARD.
+ Otherwise prefer wtype keysyms, then ydotool with a layout-resolved
+ keycode for 'v'.
"""
- if self.environment == DesktopEnvironment.WAYLAND_XDOTOOL:
+ if self.environment in (
+ DesktopEnvironment.X11,
+ DesktopEnvironment.WAYLAND_XDOTOOL,
+ ):
chord = "ctrl+shift+v" if terminal else "ctrl+v"
return ["xdotool", "key", "--clearmodifiers", chord]
if self._wtype_usable_for_paste():
@@ -3190,8 +3274,11 @@ class TextInjector:
except (TypeError, ValueError):
return 0.012
- def _log_current_window_info(self):
+ def _log_current_window_info(self) -> None:
"""Log information about the current window/application for debugging."""
+ if not logger.isEnabledFor(logging.DEBUG):
+ return
+
try:
if (
self.environment == DesktopEnvironment.X11
@@ -3203,7 +3290,7 @@ class TextInjector:
except Exception as e:
logger.debug(f"Could not get window info: {e}")
- def _log_x11_window_info(self):
+ def _log_x11_window_info(self) -> None:
"""Log X11 window information."""
env = os.environ.copy()
@@ -3238,7 +3325,7 @@ class TextInjector:
timeout=2,
)
window_name = result.stdout.strip()
- logger.info(f"Target window: '{window_name}' (ID: {window_id})")
+ logger.debug(f"Target window: '{window_name}' (ID: {window_id})")
# Prefer xprop; xdotool classname is last-resort when xprop is missing
# (see focused_window.read_wm_class).
@@ -3262,12 +3349,12 @@ class TextInjector:
try:
with open(f"/proc/{window_pid}/comm", "r") as f:
process_name = f.read().strip()
- logger.info(f"Target process: {process_name} (PID: {window_pid})")
+ logger.debug(f"Target process: {process_name} (PID: {window_pid})")
except Exception:
pass
except subprocess.TimeoutExpired:
- logger.warning("Timeout getting window information")
+ logger.debug("Timeout getting window information")
except subprocess.CalledProcessError as e:
logger.debug(f"xdotool command failed: {e.stderr}")
except Exception as e:
diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py
index a9bc06b..e827a30 100644
--- a/src/vocalinux/ui/config_manager.py
+++ b/src/vocalinux/ui/config_manager.py
@@ -9,7 +9,7 @@ import json
import logging
import os
import threading
-from typing import Any, Optional
+from typing import Any, Optional, cast
from ..utils.paths import config_dir
from ..utils.vosk_model_info import SUPPORTED_LANGUAGES
@@ -126,7 +126,7 @@ DEFAULT_CONFIG = {
"parakeet_model_size": "v3-european", # Parakeet TDT 0.6B v3 (25 European languages)
"faster_whisper_model_size": "tiny", # Default model for faster-whisper engine
"vad_sensitivity": 3, # Voice Activity Detection sensitivity (1-5)
- "silence_timeout": 2.0, # Seconds of silence before stopping
+ "silence_timeout": 1.2, # Seconds of silence before stopping
"stop_sound_guard_ms": 200, # Small tail trim to avoid the stop sound without clipping speech
"voice_commands_enabled": None, # None = auto (enabled for VOSK, disabled for Whisper)
"remote_api_url": "", # Remote speech recognition server URL (e.g. http://192.168.1.100:8080)
@@ -196,6 +196,7 @@ DEFAULT_CONFIG = {
# Clipboard-paste chord: auto-detect terminals, or force Ctrl+V /
# Ctrl+Shift+V when a nested terminal panel is not detected.
"paste_shortcut": "auto",
+ "paste_long_text": False, # Faster clipboard paste for long X11 dictations
# Text-injection backend: "auto" autodetects, or pin "ibus"/"wtype"/
# "ydotool"/"xdotool" when autodetection is wrong (#476).
# VOCALINUX_FORCE_BACKEND overrides this for a single run.
@@ -346,7 +347,8 @@ def resolve_whispercpp_variant(
# The "layout" sentinel is deliberately not in the catalog, so it reads as
# non-English here and derives the multilingual variant -- which is what a
# mode that can land on any language needs (#821).
- language_is_english = SUPPORTED_LANGUAGES.get(language_id, {}).get("whisper") == "en"
+ language_info = cast(dict[str, str], SUPPORTED_LANGUAGES.get(language_id, {}))
+ language_is_english = language_info.get("whisper") == "en"
def stand_in(variant: str) -> str:
# Only language-derived results reach this helper; an explicit pick is
@@ -429,7 +431,7 @@ class ConfigManager:
return
try:
- detected = detect_system_language(SUPPORTED_LANGUAGES)
+ detected = detect_system_language(cast(dict[str, dict[str, str]], SUPPORTED_LANGUAGES))
except Exception as exc: # pragma: no cover - detection must never block startup
logger.debug(f"Language detection failed: {exc}")
return
@@ -743,7 +745,7 @@ class ConfigManager:
def get_settings(self) -> dict[str, Any]:
"""Get the entire configuration dictionary."""
- return self.config
+ return cast(dict[str, Any], self.config)
def get_model_size_for_engine(self, engine: str) -> str:
"""Return the model id the engine should load for ``engine``.
@@ -763,7 +765,7 @@ class ConfigManager:
saved = sr_config.get("model_size", "small" if engine == "vosk" else "tiny")
if engine.lower() != "whisper_cpp":
- return saved
+ return cast(str, saved)
# Unpinned configs store the bare size; the engine still needs the
# language-derived loadable id (and leftover ``{size}.en`` must not
@@ -825,9 +827,9 @@ class ConfigManager:
if enabled is None:
# Auto mode: enabled for VOSK, disabled for Whisper engines
engine = sr_config.get("engine", "whisper_cpp")
- return engine == "vosk"
+ return bool(engine == "vosk")
- return enabled
+ return cast(bool, enabled)
def update_speech_recognition_settings(self, settings: dict[str, Any]) -> None:
"""Update multiple speech recognition settings at once."""
@@ -913,6 +915,16 @@ class ConfigManager:
self.config["text_injection"] = {}
self.config["text_injection"]["paste_shortcut"] = normalize_paste_shortcut(shortcut)
+ def get_paste_long_text(self) -> bool:
+ """Return whether long X11 dictations should use clipboard paste."""
+ return self.get_bool("text_injection", "paste_long_text", False)
+
+ def set_paste_long_text(self, enabled: bool) -> None:
+ """Save whether long X11 dictations should use clipboard paste."""
+ if "text_injection" not in self.config:
+ self.config["text_injection"] = {}
+ self.config["text_injection"]["paste_long_text"] = bool(enabled)
+
def get_language_shortcuts(self) -> list[dict[str, str]]:
"""Return validated ``[{"shortcut", "language"}]`` bindings (#805).
diff --git a/src/vocalinux/ui/keyboard_backends/base.py b/src/vocalinux/ui/keyboard_backends/base.py
index 8f3ecec..438da65 100644
--- a/src/vocalinux/ui/keyboard_backends/base.py
+++ b/src/vocalinux/ui/keyboard_backends/base.py
@@ -8,7 +8,7 @@ the required methods.
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass
-from typing import Callable, Optional, Tuple
+from typing import Callable, Optional, Tuple, cast
Callback = Callable[[], None]
@@ -392,7 +392,7 @@ def format_shortcut_label(spec: ShortcutSpec) -> str:
return SHORTCUT_DISPLAY_NAMES[legacy_id]
return _MODIFIER_LABELS.get(spec.primary_modifier, spec.primary_modifier)
parts = [_MODIFIER_LABELS.get(m, m) for m in spec.modifiers]
- parts.append(_main_key_label(spec.key))
+ parts.append(_main_key_label(cast(str, spec.key)))
return "+".join(parts)
diff --git a/src/vocalinux/ui/keyboard_backends/evdev_backend.py b/src/vocalinux/ui/keyboard_backends/evdev_backend.py
index df2d0ea..ab51e2a 100644
--- a/src/vocalinux/ui/keyboard_backends/evdev_backend.py
+++ b/src/vocalinux/ui/keyboard_backends/evdev_backend.py
@@ -26,7 +26,7 @@ import select
import threading
import time
import weakref
-from typing import Any, Mapping, Optional, Sequence, TextIO
+from typing import Any, Mapping, Optional, Sequence, TextIO, cast
# Try to import evdev
try:
@@ -165,7 +165,7 @@ def _is_post_grab(event: InputEvent, grab_ts: float) -> bool:
unreadable is treated as pre-grab — refusing the grab is the safe side.
"""
try:
- return event.timestamp() >= grab_ts
+ return cast(bool, event.timestamp() >= grab_ts)
except (AttributeError, TypeError):
return False
@@ -1610,8 +1610,8 @@ class EvdevKeyboardBackend(KeyboardBackend):
self._hub._known_engines.add(self)
self._hub.reset_if_idle()
- self.last_trigger_time = 0
- self.last_key_press_time = 0
+ self.last_trigger_time: float = 0.0
+ self.last_key_press_time: float = 0.0
self.double_tap_threshold = 0.3 # seconds
self.key_pressed_devices: set[int] = set()
diff --git a/src/vocalinux/ui/keyboard_backends/pynput_backend.py b/src/vocalinux/ui/keyboard_backends/pynput_backend.py
index 693f1c9..d7cf715 100644
--- a/src/vocalinux/ui/keyboard_backends/pynput_backend.py
+++ b/src/vocalinux/ui/keyboard_backends/pynput_backend.py
@@ -8,7 +8,7 @@ Works on X11 and XWayland, but NOT on pure Wayland.
import logging
import threading
import time
-from typing import Optional
+from typing import Any, Optional, cast
# Try to import pynput
try:
@@ -24,14 +24,14 @@ from .base import DEFAULT_SHORTCUT, DEFAULT_SHORTCUT_MODE, KeyboardBackend
logger = logging.getLogger(__name__)
-def _optional_key(key_name: str):
+def _optional_key(key_name: str) -> Any:
if not PYNPUT_AVAILABLE:
return None
return getattr(keyboard.Key, key_name, None)
-def _key_set(*key_names: str) -> set:
- keys = set()
+def _key_set(*key_names: str) -> set[Any]:
+ keys: set[Any] = set()
for key_name in key_names:
key = _optional_key(key_name)
if key is not None:
@@ -149,10 +149,10 @@ def pynput_key_token(key) -> Optional[str]:
return None
token = _PYNPUT_NAMED_TOKENS.get(key)
if token is not None:
- return token
+ return cast(str, token)
char = getattr(key, "char", None)
if char:
- lowered = char.lower()
+ lowered = cast(str, char.lower())
if len(lowered) == 1 and lowered.isalnum():
return lowered
code = ord(char)
@@ -179,16 +179,16 @@ class PynputKeyboardBackend(KeyboardBackend):
mode: The shortcut mode ("toggle" or "push_to_talk")
"""
super().__init__(shortcut, mode)
- self.listener = None
- self.last_trigger_time = 0
- self.last_key_press_time = 0
+ self.listener: Optional[Any] = None
+ self.last_trigger_time: float = 0.0
+ self.last_key_press_time: float = 0.0
self.double_tap_threshold = 0.3 # seconds
- self.current_keys = set()
+ self.current_keys: set[Any] = set()
# Combo (modifier+key) state.
- self._combo_required_modifier_keys: list = []
+ self._combo_required_modifier_keys: list[Any] = []
self._combo_key_token: Optional[str] = None
- self._combo_held_keys: set = set()
+ self._combo_held_keys: set[Any] = set()
self._combo_active = False
self._resolve_combo_targets()
@@ -242,11 +242,11 @@ class PynputKeyboardBackend(KeyboardBackend):
return False
normalized_key = self._normalize_modifier_key(key)
- return normalized_key == target_key
+ return cast(bool, normalized_key == target_key)
def is_available(self) -> bool:
"""Check if pynput is available."""
- return PYNPUT_AVAILABLE
+ return cast(bool, PYNPUT_AVAILABLE)
def get_permission_hint(self) -> Optional[str]:
"""
diff --git a/src/vocalinux/ui/logging_dialog.py b/src/vocalinux/ui/logging_dialog.py
index d96332b..a7ccf59 100644
--- a/src/vocalinux/ui/logging_dialog.py
+++ b/src/vocalinux/ui/logging_dialog.py
@@ -696,7 +696,7 @@ class LoggingDialog(Gtk.Dialog):
dialog.format_secondary_text("Do you want to replace it?")
dialog.add_button("_Cancel", Gtk.ResponseType.CANCEL)
dialog.add_button("_Replace", Gtk.ResponseType.OK)
- confirmed = dialog.run() == Gtk.ResponseType.OK
+ confirmed = bool(dialog.run() == Gtk.ResponseType.OK)
dialog.destroy()
return confirmed
diff --git a/src/vocalinux/ui/logging_manager.py b/src/vocalinux/ui/logging_manager.py
index 83e095f..6a8d3bd 100644
--- a/src/vocalinux/ui/logging_manager.py
+++ b/src/vocalinux/ui/logging_manager.py
@@ -217,12 +217,12 @@ class LoggingManager:
return {"total": 0, "by_level": {}, "by_module": {}, "oldest": None, "newest": None}
# Count by level
- by_level = {}
+ by_level: dict[str, int] = {}
for record in records:
by_level[record.level] = by_level.get(record.level, 0) + 1
# Count by module
- by_module = {}
+ by_module: dict[str, int] = {}
for record in records:
module = (
record.logger_name.split(".")[0]
diff --git a/src/vocalinux/ui/settings_about.py b/src/vocalinux/ui/settings_about.py
new file mode 100644
index 0000000..59f74d6
--- /dev/null
+++ b/src/vocalinux/ui/settings_about.py
@@ -0,0 +1,522 @@
+"""About page content and trusted family links for Settings."""
+
+import logging
+import os
+from typing import Any, Optional
+
+import gi
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk # noqa: E402
+
+from ..utils.update_checker import is_trusted_release_url, normalize_channel
+from ..version import __copyright__, __url__, __version__
+
+logger = logging.getLogger(__name__)
+
+VOCALINUX_SITE_URL = "https://vocalinux.com"
+VOCAHQ_SITE_URL = "https://vocahq.com"
+VOCAMAC_SITE_URL = "https://vocamac.com"
+VOCAWIN_SITE_URL = "https://vocawin.com"
+VOCAPHONE_SITE_URL = "https://vocaphone.vocahq.com"
+VOCAGATEWAY_SITE_URL = "https://vocagateway.vocahq.com"
+GITHUB_REPO_URL = __url__
+GITHUB_ISSUES_URL = "https://github.com/VocaHQ/vocalinux/issues"
+VOCAHQ_DISCORD_URL = "https://discord.gg/t6muquAJbm"
+VOCAHQ_X_URL = "https://x.com/vocahq"
+VOCAHQ_MAILTO_URL = "mailto:hello@vocahq.com"
+
+# About page links that are not GitHub release URLs.
+_ABOUT_OPEN_URLS = frozenset(
+ {
+ VOCALINUX_SITE_URL,
+ VOCAHQ_SITE_URL,
+ VOCAMAC_SITE_URL,
+ VOCAWIN_SITE_URL,
+ VOCAPHONE_SITE_URL,
+ VOCAGATEWAY_SITE_URL,
+ GITHUB_REPO_URL,
+ GITHUB_ISSUES_URL,
+ VOCAHQ_DISCORD_URL,
+ VOCAHQ_X_URL,
+ VOCAHQ_MAILTO_URL,
+ }
+)
+
+# Official Talk-to-us marks from VocaHQ/.github brand/vocahq/social (commit 61c8eee).
+# Do not redraw. fill is currentColor; paint to match the dialog foreground so
+# the marks stay visible on both light and dark surfaces.
+_ABOUT_INK = "#14231C"
+_ABOUT_INK_ON_DARK = "#E6E1D8"
+_ABOUT_ICON_TEXT_PX = 18
+_FAMILY_ICON_PX = 22
+
+
+def _color_luminance(color: Any) -> float:
+ """Relative luminance of a Gdk.RGBA (0 = black, 1 = white)."""
+ return float(0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue)
+
+
+def _rgba_to_hex(color: Any) -> str:
+ """Format a Gdk.RGBA as #rrggbb."""
+ r = max(0, min(255, int(round(color.red * 255))))
+ g = max(0, min(255, int(round(color.green * 255))))
+ b = max(0, min(255, int(round(color.blue * 255))))
+ return f"#{r:02x}{g:02x}{b:02x}"
+
+
+def _about_surface_is_dark() -> bool:
+ """Return True when GTK is using a dark theme."""
+ settings = Gtk.Settings.get_default()
+ if settings is not None:
+ try:
+ if bool(settings.get_property("gtk-application-prefer-dark-theme")):
+ return True
+ except Exception:
+ pass
+ try:
+ theme = str(settings.get_property("gtk-theme-name") or "").lower()
+ if "dark" in theme:
+ return True
+ except Exception:
+ pass
+ try:
+ from ..utils.gtk_color_scheme import read_os_color_scheme
+
+ if read_os_color_scheme() == "prefer-dark":
+ return True
+ except Exception:
+ pass
+ return False
+
+
+def _about_ink_hex(
+ widget: Optional[Gtk.Widget] = None,
+ dark: Optional[bool] = None,
+) -> str:
+ """Ink for currentColor marks: theme fg when known, else light/dark defaults."""
+ if dark is None:
+ dark = _about_surface_is_dark()
+ if widget is not None:
+ ctx = widget.get_style_context()
+ for name in ("theme_fg_color", "theme_text_color", "fg_color"):
+ try:
+ found, color = ctx.lookup_color(name)
+ except Exception:
+ continue
+ if not found:
+ continue
+ lum = _color_luminance(color)
+ if dark and lum >= 0.45:
+ return _rgba_to_hex(color)
+ if not dark and lum <= 0.40:
+ return _ABOUT_INK
+ return _ABOUT_INK_ON_DARK if dark else _ABOUT_INK
+
+
+# Official platform marks from VocaHQ/.github brand/promo/cards/platform
+# (currentColor copies live in web/public/brand/platforms). Home is a simple
+# house so HQ does not reuse a product lockup.
+_VOCAHQ_FAMILY_LINKS = (
+ (
+ VOCAHQ_SITE_URL,
+ "VocaHQ",
+ "Family site",
+ ("platform-home",),
+ "Open vocahq.com",
+ ),
+ (
+ VOCALINUX_SITE_URL,
+ "Vocalinux",
+ "Linux, available now",
+ ("platform-linux",),
+ "Open vocalinux.com",
+ ),
+ (
+ VOCAMAC_SITE_URL,
+ "VocaMac",
+ "macOS, available now",
+ ("platform-apple",),
+ "Open vocamac.com",
+ ),
+ (
+ VOCAWIN_SITE_URL,
+ "VocaWin",
+ "Windows, unsigned beta",
+ ("platform-windows",),
+ "Open vocawin.com",
+ ),
+ (
+ VOCAPHONE_SITE_URL,
+ "VocaPhone",
+ "Android beta / iOS TestFlight",
+ ("platform-android", "platform-apple"),
+ "Open vocaphone.vocahq.com",
+ ),
+ (
+ VOCAGATEWAY_SITE_URL,
+ "VocaGateway",
+ "Self-hosted, headless",
+ ("platform-server",),
+ "Open vocagateway.vocahq.com",
+ ),
+)
+
+
+def _can_open_url(url: str) -> bool:
+ """Return True for GitHub project URLs or the About page allowlist."""
+ return bool(url) and (url in _ABOUT_OPEN_URLS or is_trusted_release_url(url))
+
+
+class AboutSectionMixin(Gtk.Dialog):
+ """Build About content on the Settings dialog."""
+
+ def _about_mark_image(
+ self: Any,
+ icon_name: str,
+ pixel_size: int = _ABOUT_ICON_TEXT_PX,
+ dark: Optional[bool] = None,
+ ) -> Optional[Gtk.Image]:
+ """Load a currentColor SVG and paint it to match the dialog foreground."""
+ from gi.repository import GdkPixbuf
+
+ from ..utils.resource_manager import ResourceManager
+
+ path = ResourceManager().get_icon_path(icon_name)
+ if not os.path.exists(path):
+ return None
+ try:
+ ink = _about_ink_hex(self, dark)
+ with open(path, encoding="utf-8") as handle:
+ svg = handle.read().replace("currentColor", ink)
+ loader = GdkPixbuf.PixbufLoader.new_with_type("svg")
+ loader.set_size(pixel_size, pixel_size)
+ loader.write(svg.encode("utf-8"))
+ loader.close()
+ pixbuf = loader.get_pixbuf()
+ if pixbuf is None:
+ return None
+ image = Gtk.Image.new_from_pixbuf(pixbuf)
+ image.set_pixel_size(pixel_size)
+ return image
+ except Exception as exc:
+ logger.warning("Failed to load About mark %s: %s", icon_name, exc)
+ return None
+
+ def _about_open_button(
+ self: Any,
+ url: str,
+ tooltip: str,
+ label: str = "Open",
+ icon_name: Optional[str] = None,
+ width: int = 100,
+ dark: Optional[bool] = None,
+ ) -> Gtk.Button:
+ """Button that opens a trusted About URL.
+
+ Unique labels (Website, Source code, Report a bug or idea) are the
+ accessible name. Shared "Open" labels use the tooltip instead.
+ """
+ from .settings_dialog import _set_accessible_name
+
+ button = Gtk.Button(label=label)
+ button.set_size_request(width, -1)
+ button.set_tooltip_text(tooltip)
+ if label == "Open":
+ _set_accessible_name(button, tooltip)
+ if icon_name:
+ image = self._about_mark_image(icon_name, dark=dark)
+ if image is not None:
+ image.set_margin_end(8)
+ button.set_image(image)
+ button.set_always_show_image(True)
+ button.get_style_context().add_class("about-mark-button")
+ button.connect("clicked", lambda *_args, dest=url: self._open_web_url(dest))
+ return button
+
+ def _family_tile(
+ self: Any,
+ url: str,
+ title: str,
+ subtitle: str,
+ icon_names: tuple[str, ...],
+ tooltip: str,
+ dark: Optional[bool] = None,
+ ) -> Gtk.Button:
+ """Compact icon + name tile that opens a family site."""
+ from .settings_dialog import _set_accessible_name
+
+ inner = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16)
+ marks = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+ marks.set_valign(Gtk.Align.CENTER)
+ for icon_name in icon_names:
+ icon = self._about_mark_image(
+ icon_name,
+ pixel_size=_FAMILY_ICON_PX,
+ dark=dark,
+ )
+ if icon is not None:
+ marks.pack_start(icon, False, False, 0)
+ inner.pack_start(marks, False, False, 0)
+ text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ name_label = Gtk.Label(label=title, xalign=0)
+ name_label.get_style_context().add_class("family-tile-title")
+ status_label = Gtk.Label(label=subtitle, xalign=0)
+ status_label.get_style_context().add_class("family-tile-subtitle")
+ status_label.set_line_wrap(True)
+ text.pack_start(name_label, False, False, 0)
+ text.pack_start(status_label, False, False, 0)
+ inner.pack_start(text, True, True, 0)
+
+ button = Gtk.Button()
+ button.add(inner)
+ button.get_style_context().add_class("family-tile")
+ button.set_tooltip_text(tooltip)
+ button.set_hexpand(True)
+ button.set_halign(Gtk.Align.FILL)
+ _set_accessible_name(button, tooltip)
+ button.connect("clicked", lambda *_args, dest=url: self._open_web_url(dest))
+ return button
+
+ def _build_about_section(self: Any) -> None:
+ """Build the About page using the same PreferenceRow cards as other pages."""
+ about_tab = getattr(self, "about_tab")
+ config_manager = getattr(self, "config_manager")
+ on_check_updates_clicked = getattr(self, "_on_check_updates_clicked")
+ on_update_channel_changed = getattr(self, "_on_update_channel_changed")
+ on_open_release_clicked = getattr(self, "_on_open_release_clicked")
+
+ from gi.repository import GdkPixbuf
+
+ from ..utils.resource_manager import ResourceManager
+ from .settings_dialog import (
+ _CONTROL_WIDTH,
+ DEFAULT_UPDATE_CHANNEL,
+ PreferenceRow,
+ PreferencesGroup,
+ _combo_chrome,
+ _combo_with_suffix,
+ _set_accessible_name,
+ _style_action_button,
+ )
+
+ dark = _about_surface_is_dark()
+ about_icon = None
+ logo_path = ResourceManager().get_icon_path("vocalinux")
+ if os.path.exists(logo_path):
+ try:
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file(logo_path)
+ scaled = pixbuf.scale_simple(48, 48, GdkPixbuf.InterpType.BILINEAR)
+ about_icon = Gtk.Image.new_from_pixbuf(scaled)
+ about_icon.set_pixel_size(48)
+ except Exception as exc:
+ logger.warning("Failed to load About icon: %s", exc)
+
+ app_group = PreferencesGroup(
+ title="Vocalinux",
+ description=(
+ "Available now on Linux (X11 and Wayland). After a model is downloaded, "
+ "speech is processed on this PC."
+ ),
+ keywords=("about", "version", "app"),
+ header_icon=about_icon,
+ )
+
+ self.about_version_label = Gtk.Label(label=__version__)
+ self.about_version_label.set_selectable(True)
+ self.about_version_label.get_style_context().add_class("preference-row-subtitle")
+ version_row = PreferenceRow(
+ title="Version",
+ subtitle="Currently installed Vocalinux build",
+ widget=self.about_version_label,
+ keywords=("version", "build"),
+ )
+ app_group.add_row(version_row)
+
+ link_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+ link_box.pack_start(
+ self._about_open_button(
+ VOCALINUX_SITE_URL,
+ "Open vocalinux.com website",
+ label="Website",
+ width=110,
+ dark=dark,
+ ),
+ False,
+ False,
+ 0,
+ )
+ link_box.pack_start(
+ self._about_open_button(
+ GITHUB_REPO_URL,
+ "Open the Vocalinux GitHub repository",
+ label="Source code",
+ width=120,
+ dark=dark,
+ ),
+ False,
+ False,
+ 0,
+ )
+ links_row = PreferenceRow(
+ title="Open source",
+ subtitle="Product site at vocalinux.com, source on GitHub.",
+ widget=link_box,
+ keywords=("website", "vocalinux.com", "github", "source", "repo", "code", "agpl"),
+ )
+ app_group.add_row(links_row)
+
+ license_row = PreferenceRow(
+ title="License",
+ subtitle=f"GNU AGPL v3 · {__copyright__}",
+ keywords=("license", "agpl", "gpl", "copyright"),
+ )
+ app_group.add_row(license_row)
+ about_tab.pack_start(app_group, False, False, 0)
+
+ talk_group = PreferencesGroup(
+ title="Talk to us",
+ description=(
+ "Bugs, feedback, and feature ideas open a GitHub issue. You pick the "
+ "template on the next screen."
+ ),
+ keywords=("github", "discord", "contact", "bug", "feedback", "email"),
+ )
+ report_btn = self._about_open_button(
+ GITHUB_ISSUES_URL,
+ "Open GitHub issues for a bug or idea",
+ label="Report a bug or idea",
+ icon_name="github",
+ width=200,
+ dark=dark,
+ )
+ talk_group.add_row(
+ PreferenceRow(
+ title="GitHub issues",
+ subtitle="https://github.com/VocaHQ/vocalinux/issues",
+ widget=report_btn,
+ keywords=("github", "issue", "bug", "idea"),
+ )
+ )
+ contact_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+ for url, label, tooltip, icon_name, width in (
+ (VOCAHQ_DISCORD_URL, "Join Discord", "Open the VocaHQ Discord", "discord", 140),
+ (VOCAHQ_X_URL, "Follow on X", "Open X @vocahq", "x", 130),
+ (VOCAHQ_MAILTO_URL, "Email us", "Email hello@vocahq.com", "mail", 120),
+ ):
+ contact_box.pack_start(
+ self._about_open_button(
+ url,
+ tooltip,
+ label=label,
+ icon_name=icon_name,
+ width=width,
+ dark=dark,
+ ),
+ False,
+ False,
+ 0,
+ )
+ talk_group.add_row(
+ PreferenceRow(
+ title="Community",
+ subtitle="Discord, X, and email",
+ widget=contact_box,
+ keywords=("discord", "x", "twitter", "email", "mail"),
+ )
+ )
+ about_tab.pack_start(talk_group, False, False, 0)
+
+ updates_group = PreferencesGroup(
+ title="Updates",
+ description="Stable for numbered releases. Nightly for dated development builds.",
+ keywords=("update", "version", "release", "upgrade", "appimage", "nightly", "channel"),
+ )
+
+ self.update_channel_combo = Gtk.ComboBoxText()
+ self.update_channel_combo.append("stable", "Stable")
+ self.update_channel_combo.append("nightly", "Nightly")
+ _combo_chrome(
+ self.update_channel_combo,
+ tooltip=(
+ "Stable uses the latest numbered release. Nightly uses the newest "
+ "nightly-YYYY-MM-DD build."
+ ),
+ )
+ saved_channel = normalize_channel(
+ config_manager.get_str("updates", "channel", DEFAULT_UPDATE_CHANNEL)
+ )
+ self.update_channel_combo.set_active_id(saved_channel)
+
+ self.check_updates_btn = Gtk.Button(label="Check")
+ self.check_updates_btn.get_style_context().add_class("suffix-button")
+ _style_action_button(self.check_updates_btn, width=80)
+ self.check_updates_btn.set_tooltip_text(
+ "Look up the latest release for this channel on GitHub"
+ )
+ self.check_updates_btn.connect("clicked", on_check_updates_clicked)
+
+ self.update_status_row = PreferenceRow(
+ title="Channel",
+ subtitle="Not checked yet",
+ widget=_combo_with_suffix(
+ self.update_channel_combo,
+ self.check_updates_btn,
+ combo_width=_CONTROL_WIDTH - 88,
+ ),
+ keywords=("channel", "stable", "nightly", "beta", "status", "check"),
+ )
+ updates_group.add_row(self.update_status_row)
+ about_tab.pack_start(updates_group, False, False, 0)
+
+ self.update_channel_combo.connect("changed", on_update_channel_changed)
+
+ self.release_notes_group = PreferencesGroup(
+ title="What's New",
+ keywords=("changelog", "what's new", "release notes", "latest", "download"),
+ )
+ self.open_release_btn = Gtk.Button(label="Open")
+ _style_action_button(self.open_release_btn)
+ self.open_release_btn.set_sensitive(False)
+ self.open_release_btn.set_tooltip_text(
+ "Open the release page in your browser for download links and install steps"
+ )
+ _set_accessible_name(
+ self.open_release_btn,
+ "Open the latest release page",
+ )
+ self.open_release_btn.connect("clicked", on_open_release_clicked)
+ self.release_notes_row = PreferenceRow(
+ title="Release notes",
+ subtitle="Notes appear here after a successful update check.",
+ widget=self.open_release_btn,
+ keywords=("notes", "changelog", "latest"),
+ )
+ self.latest_release_row = self.release_notes_row
+ if self.release_notes_row.subtitle_label is not None:
+ self.release_notes_row.subtitle_label.set_max_width_chars(72)
+ self.release_notes_group.add_row(self.release_notes_row)
+ about_tab.pack_start(self.release_notes_group, False, False, 0)
+ self.release_notes_group.hide()
+
+ family_group = PreferencesGroup(
+ title="Part of VocaHQ",
+ description="Private dictation for Linux, Mac, Windows, and phone.",
+ keywords=("vocahq", "vocamac", "vocawin", "vocaphone", "vocagateway", "family"),
+ )
+ family_grid = Gtk.Grid()
+ family_grid.set_column_spacing(8)
+ family_grid.set_row_spacing(4)
+ family_grid.set_column_homogeneous(True)
+ family_grid.set_margin_start(8)
+ family_grid.set_margin_end(8)
+ family_grid.set_margin_top(4)
+ family_grid.set_margin_bottom(8)
+ for index, (url, title, subtitle, icon_names, open_name) in enumerate(_VOCAHQ_FAMILY_LINKS):
+ tile = self._family_tile(url, title, subtitle, icon_names, open_name, dark=dark)
+ family_grid.attach(tile, index % 2, index // 2, 1, 1)
+ family_row = Gtk.ListBoxRow()
+ family_row.set_activatable(False)
+ family_row.add(family_grid)
+ family_group.add_row(family_row)
+ about_tab.pack_start(family_group, False, False, 0)
diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py
index f2a9e68..9f10287 100644
--- a/src/vocalinux/ui/settings_dialog.py
+++ b/src/vocalinux/ui/settings_dialog.py
@@ -35,11 +35,14 @@ gi.require_version("Gdk", "3.0")
# Need GLib for idle_add
from gi.repository import Gdk, GLib, GObject, Gtk, Pango # noqa: E402
-from ..common_types import RecognitionState, _EvdevCaptureDevice # noqa: E402
+from ..common_types import ( # noqa: E402
+ RecognitionState,
+ SpeechRecognitionManagerProtocol,
+ _EvdevCaptureDevice,
+)
from ..custom_dictionary import normalize_corrections # noqa: E402
from ..gateway_embed import GatewayStatus, get_gateway_embed_manager # noqa: E402
from ..speech_recognition.silero_vad import is_silero_available # noqa: E402
-from ..utils import parakeet_model_info as parakeet # noqa: E402
from ..utils.faster_whisper_model_info import delete_model as delete_faster_whisper_model
from ..utils.faster_whisper_model_info import ( # noqa: E402
get_recommended_model as get_recommended_faster_whisper_model,
@@ -80,8 +83,6 @@ from ..utils.update_checker import ( # noqa: E402
normalize_channel,
)
from ..utils.vosk_model_info import ( # noqa: E402
- SUPPORTED_LANGUAGES,
- VOSK_MODEL_INFO,
delete_vosk_model,
list_downloaded_vosk_models,
vosk_model_dirname,
@@ -90,7 +91,6 @@ from ..utils.whisper_model_info import ( # noqa: E402
migrate_legacy_checkpoint_names,
whisper_model_file,
)
-from ..utils.whispercpp_model_info import MODEL_SIZES as WHISPERCPP_MODEL_SIZES
from ..utils.whispercpp_model_info import delete_model as delete_whispercpp_model
from ..utils.whispercpp_model_info import (
detect_compute_backend,
@@ -106,7 +106,7 @@ from ..utils.whispercpp_model_info import (
list_downloaded_models as list_downloaded_whispercpp_models,
)
from ..utils.whispercpp_model_info import on_disk_stand_in as whispercpp_on_disk_stand_in
-from ..version import __copyright__, __url__, __version__ # noqa: E402
+from ..version import __url__, __version__ # noqa: E402
from .config_manager import ( # noqa: E402
DEFAULT_CONFIG,
DEFAULT_PASTE_SHORTCUT,
@@ -128,16 +128,44 @@ from .keyboard_backends import ( # noqa: E402
parse_shortcut_spec,
)
from .keyboard_backends.evdev_backend import MODIFIER_KEY_CODES # noqa: E402
-from .transcription_history import sanitize_max_items # noqa: E402
-
-from ..utils.faster_whisper_model_info import ( # isort:skip
+from .settings_about import ( # noqa: F401
+ _ABOUT_ICON_TEXT_PX,
+ _ABOUT_INK,
+ _ABOUT_INK_ON_DARK,
+ _ABOUT_OPEN_URLS,
+ _FAMILY_ICON_PX,
+ _VOCAHQ_FAMILY_LINKS,
+ GITHUB_ISSUES_URL,
+ GITHUB_REPO_URL,
+ VOCAGATEWAY_SITE_URL,
+ VOCAHQ_DISCORD_URL,
+ VOCAHQ_MAILTO_URL,
+ VOCAHQ_SITE_URL,
+ VOCAHQ_X_URL,
+ VOCALINUX_SITE_URL,
+ VOCAMAC_SITE_URL,
+ VOCAPHONE_SITE_URL,
+ VOCAWIN_SITE_URL,
+ AboutSectionMixin,
+ _about_ink_hex,
+ _about_surface_is_dark,
+ _can_open_url,
+ _color_luminance,
+ _rgba_to_hex,
+)
+from .settings_engines import ( # noqa: F401
FASTER_WHISPER_MODEL_INFO,
-)
-from ..utils.whispercpp_model_info import ( # isort:skip
NON_DICTATION_MODELS,
+ SUPPORTED_LANGUAGES,
+ VOSK_MODEL_INFO,
+ WHISPER_MODEL_INFO,
WHISPERCPP_MODEL_INFO,
+ WHISPERCPP_MODEL_SIZES,
default_variant_for_size,
+ get_available_engines,
+ parakeet,
)
+from .transcription_history import sanitize_max_items # noqa: E402
# Avoid circular imports for type checking
if TYPE_CHECKING:
@@ -180,7 +208,7 @@ def _resolve_audio_device_selection(
if saved_raw_name:
for idx, name, _is_default in devices:
if name == saved_raw_name:
- return idx
+ return int(idx)
if saved_index is not None:
for idx, _name, _is_default in devices:
if idx == saved_index:
@@ -363,12 +391,14 @@ def _language_for_whispercpp_variant(dialog: Any) -> Optional[str]:
and getattr(dialog, "_initializing", False) is True
):
return LANGUAGE_FOLLOWS_LAYOUT
- return dialog.language_combo.get_active_id() or dialog.language
+ value = dialog.language_combo.get_active_id() or dialog.language
+ return str(value)
def _language_is_english(language_id: str) -> bool:
"""Return whether a language ID maps to English for Whisper."""
- return SUPPORTED_LANGUAGES.get(language_id, {}).get("whisper") == "en"
+ info = SUPPORTED_LANGUAGES.get(language_id, {})
+ return isinstance(info, dict) and info.get("whisper") == "en"
def _decode_simple_languages(primary: str, wants_second: bool, secondary: Optional[str]) -> str:
@@ -563,150 +593,6 @@ def _combo_with_suffix(
return box
-VOCALINUX_SITE_URL = "https://vocalinux.com"
-VOCAHQ_SITE_URL = "https://vocahq.com"
-VOCAMAC_SITE_URL = "https://vocamac.com"
-VOCAWIN_SITE_URL = "https://vocawin.com"
-VOCAPHONE_SITE_URL = "https://vocaphone.vocahq.com"
-VOCAGATEWAY_SITE_URL = "https://vocagateway.vocahq.com"
-GITHUB_REPO_URL = __url__
-GITHUB_ISSUES_URL = "https://github.com/VocaHQ/vocalinux/issues"
-VOCAHQ_DISCORD_URL = "https://discord.gg/t6muquAJbm"
-VOCAHQ_X_URL = "https://x.com/vocahq"
-VOCAHQ_MAILTO_URL = "mailto:hello@vocahq.com"
-
-# About page links that are not GitHub release URLs.
-_ABOUT_OPEN_URLS = frozenset(
- {
- VOCALINUX_SITE_URL,
- VOCAHQ_SITE_URL,
- VOCAMAC_SITE_URL,
- VOCAWIN_SITE_URL,
- VOCAPHONE_SITE_URL,
- VOCAGATEWAY_SITE_URL,
- GITHUB_REPO_URL,
- GITHUB_ISSUES_URL,
- VOCAHQ_DISCORD_URL,
- VOCAHQ_X_URL,
- VOCAHQ_MAILTO_URL,
- }
-)
-
-# Official Talk-to-us marks from VocaHQ/.github brand/vocahq/social (commit 61c8eee).
-# Do not redraw. fill is currentColor; paint to match the dialog foreground so
-# the marks stay visible on both light and dark surfaces.
-_ABOUT_INK = "#14231C"
-_ABOUT_INK_ON_DARK = "#E6E1D8"
-_ABOUT_ICON_TEXT_PX = 18
-_FAMILY_ICON_PX = 22
-
-
-def _color_luminance(color: Any) -> float:
- """Relative luminance of a Gdk.RGBA (0 = black, 1 = white)."""
- return 0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue
-
-
-def _rgba_to_hex(color: Any) -> str:
- """Format a Gdk.RGBA as #rrggbb."""
- r = max(0, min(255, int(round(color.red * 255))))
- g = max(0, min(255, int(round(color.green * 255))))
- b = max(0, min(255, int(round(color.blue * 255))))
- return f"#{r:02x}{g:02x}{b:02x}"
-
-
-def _about_surface_is_dark() -> bool:
- """Return True when GTK is using a dark theme."""
- settings = Gtk.Settings.get_default()
- if settings is not None:
- try:
- if bool(settings.get_property("gtk-application-prefer-dark-theme")):
- return True
- except Exception:
- pass
- try:
- theme = str(settings.get_property("gtk-theme-name") or "").lower()
- if "dark" in theme:
- return True
- except Exception:
- pass
- try:
- from ..utils.gtk_color_scheme import read_os_color_scheme
-
- if read_os_color_scheme() == "prefer-dark":
- return True
- except Exception:
- pass
- return False
-
-
-def _about_ink_hex(widget: Optional[Gtk.Widget] = None) -> str:
- """Ink for currentColor marks: theme fg when known, else light/dark defaults."""
- dark = _about_surface_is_dark()
- if widget is not None:
- ctx = widget.get_style_context()
- for name in ("theme_fg_color", "theme_text_color", "fg_color"):
- try:
- found, color = ctx.lookup_color(name)
- except Exception:
- continue
- if not found:
- continue
- lum = _color_luminance(color)
- if dark and lum >= 0.45:
- return _rgba_to_hex(color)
- if not dark and lum <= 0.40:
- return _ABOUT_INK
- return _ABOUT_INK_ON_DARK if dark else _ABOUT_INK
-
-
-# Official platform marks from VocaHQ/.github brand/promo/cards/platform
-# (currentColor copies live in web/public/brand/platforms). Home is a simple
-# house so HQ does not reuse a product lockup.
-_VOCAHQ_FAMILY_LINKS = (
- (
- VOCAHQ_SITE_URL,
- "VocaHQ",
- "Family site",
- ("platform-home",),
- "Open vocahq.com",
- ),
- (
- VOCALINUX_SITE_URL,
- "Vocalinux",
- "Linux, available now",
- ("platform-linux",),
- "Open vocalinux.com",
- ),
- (
- VOCAMAC_SITE_URL,
- "VocaMac",
- "macOS, available now",
- ("platform-apple",),
- "Open vocamac.com",
- ),
- (
- VOCAWIN_SITE_URL,
- "VocaWin",
- "Windows, unsigned beta",
- ("platform-windows",),
- "Open vocawin.com",
- ),
- (
- VOCAPHONE_SITE_URL,
- "VocaPhone",
- "Android beta / iOS TestFlight",
- ("platform-android", "platform-apple"),
- "Open vocaphone.vocahq.com",
- ),
- (
- VOCAGATEWAY_SITE_URL,
- "VocaGateway",
- "Self-hosted, headless",
- ("platform-server",),
- "Open vocagateway.vocahq.com",
- ),
-)
-
# Settings combo labels. The row subtitle already explains the behavior, so
# these stay short enough to match Shortcut Key.
_SHORTCUT_MODE_COMBO_LABELS = {
@@ -715,11 +601,6 @@ _SHORTCUT_MODE_COMBO_LABELS = {
}
-def _can_open_url(url: str) -> bool:
- """Return True for GitHub project URLs or the About page allowlist."""
- return bool(url) and (url in _ABOUT_OPEN_URLS or is_trusted_release_url(url))
-
-
def _set_accessible_name(widget: Gtk.Widget, name: str) -> None:
"""Set the ATK name so identical visible labels stay distinguishable."""
accessible = widget.get_accessible()
@@ -800,81 +681,6 @@ def _model_specialization_tooltip(model_name: str) -> str:
)
-# Whisper model metadata for display
-WHISPER_MODEL_INFO = {
- "tiny": {"size_mb": 75, "desc": "Fastest, lowest accuracy", "params": "39M"},
- "base": {"size_mb": 142, "desc": "Fast, good for basic use", "params": "74M"},
- "small": {"size_mb": 466, "desc": "Balanced speed/accuracy", "params": "244M"},
- "medium": {"size_mb": 1500, "desc": "High accuracy, slower", "params": "769M"},
- "large": {"size_mb": 2900, "desc": "Highest accuracy, slowest", "params": "1550M"},
-}
-
-
-def get_available_engines():
- """
- Detect which speech recognition engines are available/installed.
- Returns a dictionary of engine_name -> availability (bool).
- """
- engines = {
- "vosk": False,
- "whisper": False,
- "whisper_cpp": False,
- "parakeet": False,
- "faster_whisper": False,
- "remote_api": False,
- }
-
- # Check VOSK
- try:
- import vosk
-
- engines["vosk"] = True
- except ImportError:
- pass
-
- # Check OpenAI Whisper
- try:
- import whisper
-
- engines["whisper"] = True
- except ImportError:
- pass
-
- # Check whisper.cpp (pywhispercpp)
- try:
- from pywhispercpp.model import Model
-
- engines["whisper_cpp"] = True
- except ImportError:
- pass
-
- # Check Parakeet (sherpa-onnx)
- try:
- import sherpa_onnx # noqa: F401
-
- engines["parakeet"] = True
- except ImportError:
- pass
-
- try:
- from faster_whisper import WhisperModel # noqa: F401
-
- engines["faster_whisper"] = True
- except ImportError:
- pass
-
- # Remote API is always available (only requires requests package)
- try:
- import requests # noqa: F401
-
- engines["remote_api"] = True
- except ImportError:
- pass
-
- logger.debug(f"Available engines: {engines}")
- return engines
-
-
# Models directory
MODELS_DIR = models_dir()
SYSTEM_MODELS_DIRS = [
@@ -1633,10 +1439,11 @@ def _is_vosk_model_downloaded(size: str, language: str) -> bool:
return False
# Auto-detect is not supported by VOSK, fall back to en-us
- if language == "auto" or language not in VOSK_MODEL_INFO[size]["languages"]:
+ languages = cast(dict[str, str], VOSK_MODEL_INFO[size]["languages"])
+ if language == "auto" or language not in languages:
language = "en-us"
- model_name = VOSK_MODEL_INFO[size]["languages"][language]
+ model_name = languages[language]
# Check user's local models directory
user_model_path = os.path.join(MODELS_DIR, model_name)
@@ -2129,7 +1936,7 @@ class PreferencesGroup(Gtk.Box):
self.title = title
self.description = description
self.keywords = tuple(keywords)
- self.rows = []
+ self.rows: list[Gtk.Widget] = []
# Header with title (optional icon aligned to the top-right)
if title:
@@ -2399,8 +2206,8 @@ class SettingsPage:
self.name = name
self.title = title
self.icon_name = icon_name
- self.groups = []
- self.extras = []
+ self.groups: list[Any] = []
+ self.extras: list[Any] = []
self.sidebar_row = None
self.match_count_label = None
self.update_badge_label = None
@@ -2615,18 +2422,18 @@ class ModelDownloadDialog(Gtk.Dialog):
self.add_button("OK", Gtk.ResponseType.OK)
-class SettingsDialog(Gtk.Dialog):
+class SettingsDialog(AboutSectionMixin):
"""Modern GTK Dialog for configuring Vocalinux settings."""
def __init__(
self,
parent: Gtk.Window,
config_manager: "ConfigManager",
- speech_engine: "SpeechRecognitionManager",
- shortcut_update_callback: callable = None,
+ speech_engine: "SpeechRecognitionManagerProtocol",
+ shortcut_update_callback: Optional[Callable[..., Any]] = None,
initial_page: Optional[str] = None,
pending_update: Optional[ReleaseInfo] = None,
- update_status_callback: callable = None,
+ update_status_callback: Optional[Callable[..., Any]] = None,
overlay_enabled_callback: Optional[Callable[[bool], None]] = None,
hotkey_listener_update_callback: Optional[Callable[[], None]] = None,
language_shortcuts_update_callback: Optional[Callable[[], None]] = None,
@@ -2654,7 +2461,9 @@ class SettingsDialog(Gtk.Dialog):
# populated by _build_language_shortcuts_section.
self._language_shortcut_rows: list[dict] = []
self._language_shortcut_add_row: Optional[PreferenceRow] = None
- self._recording_shortcut_target = None
+ self._recording_shortcut_target: Optional[tuple[Any, Callable[[str], None], Any, Any]] = (
+ None
+ )
self._test_active = False
self._test_result = ""
self._initializing = True # Flag to prevent auto-apply during initialization
@@ -2665,13 +2474,13 @@ class SettingsDialog(Gtk.Dialog):
# Set while simple mode steers the advanced controls, so their own change
# handlers do not each trigger a separate engine reload.
self._simple_driving = False
- self.advanced_box = None
+ self.advanced_box: Any = None
self.advanced_island = None
- self.advanced_expander = None
+ self.advanced_expander: Any = None
self.unused_island = None
self.unused_expander = None
self.simple_page = None
- self.simple_group = None
+ self.simple_group: Any = None
self.engine_group = None
self._processing_language_change = (
False # Flag to prevent recursive language change handling
@@ -2680,7 +2489,7 @@ class SettingsDialog(Gtk.Dialog):
# engine-coerced fallbacks (e.g. Vosk forcing en-us for Greek). Parakeet
# forces language=auto for honesty; this restores the preference when the
# user returns to Whisper/cpp. Do not store engine-coerced fallbacks here.
- self._last_non_parakeet_language = None
+ self._last_non_parakeet_language: Optional[str] = None
self._engine_for_language_memory = None
# Set from the saved config in _load_and_apply_settings; the switch itself
# is the source of truth once the dialog is built (#821).
@@ -2886,7 +2695,7 @@ class SettingsDialog(Gtk.Dialog):
# Restore the saved mode and point the simple questions at the live model
# before the first visibility pass, so nothing flashes the wrong group.
self._sync_simple_from_advanced()
- self.advanced_expander.set_expanded(
+ cast(Any, self.advanced_expander).set_expanded(
bool(self.config_manager.get("speech_recognition", "show_advanced", False))
)
@@ -4509,6 +4318,17 @@ class SettingsDialog(Gtk.Dialog):
logger.info(f"Paste shortcut set to {self.config_manager.get_paste_shortcut()}")
return False
+ def _on_paste_long_text_toggled(self, _widget: Gtk.Switch, state: bool) -> bool:
+ """Handle the long-dictation clipboard-paste switch."""
+ if _handlers_suppressed(self):
+ return False
+
+ enabled = bool(state)
+ self.config_manager.set_paste_long_text(enabled)
+ self.config_manager.save_settings()
+ logger.info("Paste long dictations toggled: %s", enabled)
+ return False
+
def _update_sound_effects_sensitivity(self, enabled: bool) -> None:
"""Gray out the tone picker while sound effects are disabled."""
self.tone_row.set_sensitive(enabled)
@@ -5083,6 +4903,19 @@ class SettingsDialog(Gtk.Dialog):
)
output_group.add_row(paste_shortcut_row)
+ self.paste_long_text_switch = Gtk.Switch()
+ paste_long_text_row = PreferenceRow(
+ title="Paste long dictations",
+ subtitle=(
+ "X11: paste text of 30+ characters through the clipboard instead of typing "
+ "it key by key. Much faster; restores the clipboard unless Copy to Clipboard "
+ "is enabled."
+ ),
+ widget=self.paste_long_text_switch,
+ keywords=("paste", "long text", "X11", "clipboard", "speed"),
+ )
+ output_group.add_row(paste_long_text_row)
+
self.recognition_settings_tab.pack_start(output_group, False, False, 0)
self.copy_to_clipboard_switch.connect("state-set", self._on_copy_to_clipboard_toggled)
self.dictation_pad_switch.connect("state-set", self._on_dictation_pad_toggled)
@@ -5091,6 +4924,7 @@ class SettingsDialog(Gtk.Dialog):
"state-set", self._on_append_trailing_space_toggled
)
self.paste_shortcut_combo.connect("changed", self._on_paste_shortcut_changed)
+ self.paste_long_text_switch.connect("state-set", self._on_paste_long_text_toggled)
if not silero_active:
vad_info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
@@ -5239,7 +5073,9 @@ class SettingsDialog(Gtk.Dialog):
# names (entry, apply_fn, button, hint_label) so one recorder serves the
# main shortcut and every per-language row (#805).
self._recording_shortcut = False
- self._recording_shortcut_target = None
+ self._recording_shortcut_target: Optional[tuple[Any, Callable[[str], None], Any, Any]] = (
+ None
+ )
self._evdev_shortcut_recorder = None
self.connect("key-press-event", self._on_shortcut_key_press)
self.connect("destroy", self._on_shortcut_recorder_destroy)
@@ -6218,100 +6054,6 @@ class SettingsDialog(Gtk.Dialog):
self.power_user_switch.connect("state-set", self._on_power_user_toggled)
- def _about_mark_image(
- self, icon_name: str, pixel_size: int = _ABOUT_ICON_TEXT_PX
- ) -> Optional[Gtk.Image]:
- """Load a currentColor SVG and paint it to match the dialog foreground."""
- from gi.repository import GdkPixbuf
-
- from ..utils.resource_manager import ResourceManager
-
- path = ResourceManager().get_icon_path(icon_name)
- if not os.path.exists(path):
- return None
- try:
- ink = _about_ink_hex(self)
- with open(path, encoding="utf-8") as handle:
- svg = handle.read().replace("currentColor", ink)
- loader = GdkPixbuf.PixbufLoader.new_with_type("svg")
- loader.set_size(pixel_size, pixel_size)
- loader.write(svg.encode("utf-8"))
- loader.close()
- pixbuf = loader.get_pixbuf()
- if pixbuf is None:
- return None
- image = Gtk.Image.new_from_pixbuf(pixbuf)
- image.set_pixel_size(pixel_size)
- return image
- except Exception as exc:
- logger.warning("Failed to load About mark %s: %s", icon_name, exc)
- return None
-
- def _about_open_button(
- self,
- url: str,
- tooltip: str,
- label: str = "Open",
- icon_name: Optional[str] = None,
- width: int = 100,
- ) -> Gtk.Button:
- """Button that opens a trusted About URL.
-
- Unique labels (Website, Source code, Report a bug or idea) are the
- accessible name. Shared "Open" labels use the tooltip instead.
- """
- button = Gtk.Button(label=label)
- button.set_size_request(width, -1)
- button.set_tooltip_text(tooltip)
- if label == "Open":
- _set_accessible_name(button, tooltip)
- if icon_name:
- image = self._about_mark_image(icon_name)
- if image is not None:
- image.set_margin_end(8)
- button.set_image(image)
- button.set_always_show_image(True)
- button.get_style_context().add_class("about-mark-button")
- button.connect("clicked", lambda *_args, dest=url: self._open_web_url(dest))
- return button
-
- def _family_tile(
- self,
- url: str,
- title: str,
- subtitle: str,
- icon_names: tuple[str, ...],
- tooltip: str,
- ) -> Gtk.Button:
- """Compact icon + name tile that opens a family site."""
- inner = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16)
- marks = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
- marks.set_valign(Gtk.Align.CENTER)
- for icon_name in icon_names:
- icon = self._about_mark_image(icon_name, pixel_size=_FAMILY_ICON_PX)
- if icon is not None:
- marks.pack_start(icon, False, False, 0)
- inner.pack_start(marks, False, False, 0)
- text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
- name_label = Gtk.Label(label=title, xalign=0)
- name_label.get_style_context().add_class("family-tile-title")
- status_label = Gtk.Label(label=subtitle, xalign=0)
- status_label.get_style_context().add_class("family-tile-subtitle")
- status_label.set_line_wrap(True)
- text.pack_start(name_label, False, False, 0)
- text.pack_start(status_label, False, False, 0)
- inner.pack_start(text, True, True, 0)
-
- button = Gtk.Button()
- button.add(inner)
- button.get_style_context().add_class("family-tile")
- button.set_tooltip_text(tooltip)
- button.set_hexpand(True)
- button.set_halign(Gtk.Align.FILL)
- _set_accessible_name(button, tooltip)
- button.connect("clicked", lambda *_args, dest=url: self._open_web_url(dest))
- return button
-
def _build_proxy_section(self) -> None:
"""Build the Proxy page: Off / System / Manual modes (#655).
@@ -6556,228 +6298,6 @@ class SettingsDialog(Gtk.Dialog):
threading.Thread(target=test_connection, daemon=True).start()
- def _build_about_section(self):
- """Build the About page using the same PreferenceRow cards as other pages."""
- from gi.repository import GdkPixbuf
-
- from ..utils.resource_manager import ResourceManager
-
- about_icon = None
- logo_path = ResourceManager().get_icon_path("vocalinux")
- if os.path.exists(logo_path):
- try:
- pixbuf = GdkPixbuf.Pixbuf.new_from_file(logo_path)
- scaled = pixbuf.scale_simple(48, 48, GdkPixbuf.InterpType.BILINEAR)
- about_icon = Gtk.Image.new_from_pixbuf(scaled)
- about_icon.set_pixel_size(48)
- except Exception as exc:
- logger.warning("Failed to load About icon: %s", exc)
-
- app_group = PreferencesGroup(
- title="Vocalinux",
- description=(
- "Available now on Linux (X11 and Wayland). After a model is downloaded, "
- "speech is processed on this PC."
- ),
- keywords=("about", "version", "app"),
- header_icon=about_icon,
- )
-
- self.about_version_label = Gtk.Label(label=__version__)
- self.about_version_label.set_selectable(True)
- self.about_version_label.get_style_context().add_class("preference-row-subtitle")
- version_row = PreferenceRow(
- title="Version",
- subtitle="Currently installed Vocalinux build",
- widget=self.about_version_label,
- keywords=("version", "build"),
- )
- app_group.add_row(version_row)
-
- link_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
- link_box.pack_start(
- self._about_open_button(
- VOCALINUX_SITE_URL,
- "Open vocalinux.com website",
- label="Website",
- width=110,
- ),
- False,
- False,
- 0,
- )
- link_box.pack_start(
- self._about_open_button(
- GITHUB_REPO_URL,
- "Open the Vocalinux GitHub repository",
- label="Source code",
- width=120,
- ),
- False,
- False,
- 0,
- )
- links_row = PreferenceRow(
- title="Open source",
- subtitle="Product site at vocalinux.com, source on GitHub.",
- widget=link_box,
- keywords=("website", "vocalinux.com", "github", "source", "repo", "code", "agpl"),
- )
- app_group.add_row(links_row)
-
- license_row = PreferenceRow(
- title="License",
- subtitle=f"GNU AGPL v3 · {__copyright__}",
- keywords=("license", "agpl", "gpl", "copyright"),
- )
- app_group.add_row(license_row)
- self.about_tab.pack_start(app_group, False, False, 0)
-
- talk_group = PreferencesGroup(
- title="Talk to us",
- description=(
- "Bugs, feedback, and feature ideas open a GitHub issue. You pick the "
- "template on the next screen."
- ),
- keywords=("github", "discord", "contact", "bug", "feedback", "email"),
- )
- report_btn = self._about_open_button(
- GITHUB_ISSUES_URL,
- "Open GitHub issues for a bug or idea",
- label="Report a bug or idea",
- icon_name="github",
- width=200,
- )
- talk_group.add_row(
- PreferenceRow(
- title="GitHub issues",
- subtitle="https://github.com/VocaHQ/vocalinux/issues",
- widget=report_btn,
- keywords=("github", "issue", "bug", "idea"),
- )
- )
- contact_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
- for url, label, tooltip, icon_name, width in (
- (VOCAHQ_DISCORD_URL, "Join Discord", "Open the VocaHQ Discord", "discord", 140),
- (VOCAHQ_X_URL, "Follow on X", "Open X @vocahq", "x", 130),
- (VOCAHQ_MAILTO_URL, "Email us", "Email hello@vocahq.com", "mail", 120),
- ):
- contact_box.pack_start(
- self._about_open_button(
- url,
- tooltip,
- label=label,
- icon_name=icon_name,
- width=width,
- ),
- False,
- False,
- 0,
- )
- talk_group.add_row(
- PreferenceRow(
- title="Community",
- subtitle="Discord, X, and email",
- widget=contact_box,
- keywords=("discord", "x", "twitter", "email", "mail"),
- )
- )
- self.about_tab.pack_start(talk_group, False, False, 0)
-
- updates_group = PreferencesGroup(
- title="Updates",
- description="Stable for numbered releases. Nightly for dated development builds.",
- keywords=("update", "version", "release", "upgrade", "appimage", "nightly", "channel"),
- )
-
- self.update_channel_combo = Gtk.ComboBoxText()
- self.update_channel_combo.append("stable", "Stable")
- self.update_channel_combo.append("nightly", "Nightly")
- _combo_chrome(
- self.update_channel_combo,
- tooltip=(
- "Stable uses the latest numbered release. Nightly uses the newest "
- "nightly-YYYY-MM-DD build."
- ),
- )
- saved_channel = normalize_channel(
- self.config_manager.get_str("updates", "channel", DEFAULT_UPDATE_CHANNEL)
- )
- self.update_channel_combo.set_active_id(saved_channel)
-
- self.check_updates_btn = Gtk.Button(label="Check")
- self.check_updates_btn.get_style_context().add_class("suffix-button")
- _style_action_button(self.check_updates_btn, width=80)
- self.check_updates_btn.set_tooltip_text(
- "Look up the latest release for this channel on GitHub"
- )
- self.check_updates_btn.connect("clicked", self._on_check_updates_clicked)
-
- self.update_status_row = PreferenceRow(
- title="Channel",
- subtitle="Not checked yet",
- widget=_combo_with_suffix(
- self.update_channel_combo,
- self.check_updates_btn,
- combo_width=_CONTROL_WIDTH - 88,
- ),
- keywords=("channel", "stable", "nightly", "beta", "status", "check"),
- )
- updates_group.add_row(self.update_status_row)
- self.about_tab.pack_start(updates_group, False, False, 0)
-
- self.update_channel_combo.connect("changed", self._on_update_channel_changed)
-
- self.release_notes_group = PreferencesGroup(
- title="What's New",
- keywords=("changelog", "what's new", "release notes", "latest", "download"),
- )
- self.open_release_btn = Gtk.Button(label="Open")
- _style_action_button(self.open_release_btn)
- self.open_release_btn.set_sensitive(False)
- self.open_release_btn.set_tooltip_text(
- "Open the release page in your browser for download links and install steps"
- )
- _set_accessible_name(
- self.open_release_btn,
- "Open the latest release page",
- )
- self.open_release_btn.connect("clicked", self._on_open_release_clicked)
- self.release_notes_row = PreferenceRow(
- title="Release notes",
- subtitle="Notes appear here after a successful update check.",
- widget=self.open_release_btn,
- keywords=("notes", "changelog", "latest"),
- )
- self.latest_release_row = self.release_notes_row
- if self.release_notes_row.subtitle_label is not None:
- self.release_notes_row.subtitle_label.set_max_width_chars(72)
- self.release_notes_group.add_row(self.release_notes_row)
- self.about_tab.pack_start(self.release_notes_group, False, False, 0)
- self.release_notes_group.hide()
-
- family_group = PreferencesGroup(
- title="Part of VocaHQ",
- description="Private dictation for Linux, Mac, Windows, and phone.",
- keywords=("vocahq", "vocamac", "vocawin", "vocaphone", "vocagateway", "family"),
- )
- family_grid = Gtk.Grid()
- family_grid.set_column_spacing(8)
- family_grid.set_row_spacing(4)
- family_grid.set_column_homogeneous(True)
- family_grid.set_margin_start(8)
- family_grid.set_margin_end(8)
- family_grid.set_margin_top(4)
- family_grid.set_margin_bottom(8)
- for index, (url, title, subtitle, icon_names, open_name) in enumerate(_VOCAHQ_FAMILY_LINKS):
- tile = self._family_tile(url, title, subtitle, icon_names, open_name)
- family_grid.attach(tile, index % 2, index // 2, 1, 1)
- family_row = Gtk.ListBoxRow()
- family_row.set_activatable(False)
- family_row.add(family_grid)
- family_group.add_row(family_row)
- self.about_tab.pack_start(family_group, False, False, 0)
-
def _set_about_update_badge(self, visible: bool) -> None:
"""Show or hide the green New badge on the About sidebar row."""
about_page = next((page for page in self._pages if page.name == "about"), None)
@@ -7654,7 +7174,7 @@ class SettingsDialog(Gtk.Dialog):
self._engine_for_language_memory = self.current_engine
self.current_model_size = settings["model_size"]
self.current_vad = settings.get("vad_sensitivity", 3)
- self.current_silence = settings.get("silence_timeout", 2.0)
+ self.current_silence = settings.get("silence_timeout", 1.2)
logger.info(
f"Starting dialog with settings: engine={self.current_engine}, model={self.current_model_size}"
@@ -7683,6 +7203,7 @@ class SettingsDialog(Gtk.Dialog):
auto_capitalize = text_injection_settings.get("auto_capitalize", True)
append_trailing_space = text_injection_settings.get("append_trailing_space", True)
paste_shortcut = self.config_manager.get_paste_shortcut()
+ paste_long_text = self.config_manager.get_paste_long_text()
history_enabled = bool(history_settings.get("enabled", True))
# A hand-edited config.json can hold a non-numeric value; sanitize so a
# bad preference cannot crash the settings dialog.
@@ -7696,6 +7217,7 @@ class SettingsDialog(Gtk.Dialog):
self.dictation_pad_switch.set_active(dictate_to_pad)
self.auto_capitalize_switch.set_active(auto_capitalize)
self.append_trailing_space_switch.set_active(append_trailing_space)
+ self.paste_long_text_switch.set_active(paste_long_text)
if not self.paste_shortcut_combo.set_active_id(paste_shortcut):
self.paste_shortcut_combo.set_active_id(DEFAULT_PASTE_SHORTCUT)
sound_effects_enabled = self.config_manager.is_sound_effects_enabled()
@@ -7851,7 +7373,7 @@ class SettingsDialog(Gtk.Dialog):
language = sr_settings.get("language", "en-us")
model_size = self.config_manager.get_model_size_for_engine(engine)
vad_sensitivity = sr_settings.get("vad_sensitivity", 3)
- silence_timeout = sr_settings.get("silence_timeout", 2.0)
+ silence_timeout = sr_settings.get("silence_timeout", 1.2)
logger.info(
f"Loaded current settings: engine={engine}, language={language}, model_size={model_size}, "
@@ -7875,7 +7397,7 @@ class SettingsDialog(Gtk.Dialog):
"""Return the selected whisper.cpp model variant."""
variant_id = self.model_variant_combo.get_active_id()
if variant_id:
- return variant_id
+ return cast(str, variant_id)
size_id = self.model_combo.get_active_id()
model_size = size_id.lower() if size_id else "small"
@@ -7889,7 +7411,7 @@ class SettingsDialog(Gtk.Dialog):
return _recommended_whispercpp_variant_for_language(
recommended_model,
reason,
- language_id,
+ cast(str, language_id),
)
def _downloaded_alternative_for(self, recommended: str) -> Optional[str]:
@@ -7905,8 +7427,9 @@ class SettingsDialog(Gtk.Dialog):
return None
language_id = _language_for_whispercpp_variant(self)
- wants_english = _language_is_english(language_id)
- recommended_mb = WHISPERCPP_MODEL_INFO.get(recommended, {}).get("size_mb", 0)
+ wants_english = _language_is_english(cast(str, language_id))
+ recommended_info = WHISPERCPP_MODEL_INFO.get(recommended, {})
+ recommended_mb = cast(int, recommended_info.get("size_mb", 0))
# An English-only recommendation must not be answered with multilingual
# weights: they weigh the same and recognise English worse, which is the
@@ -7918,24 +7441,25 @@ class SettingsDialog(Gtk.Dialog):
for model_name in list_downloaded_whispercpp_models():
if model_name in NON_DICTATION_MODELS:
continue
- info = WHISPERCPP_MODEL_INFO.get(model_name)
+ info = cast(Optional[dict[str, Any]], WHISPERCPP_MODEL_INFO.get(model_name))
if not info:
continue
if is_english_only_whispercpp_model(model_name) and not wants_english:
continue
if needs_english_only and not is_english_only_whispercpp_model(model_name):
continue
- if info["size_mb"] < recommended_mb:
+ size_mb = cast(int, info["size_mb"])
+ if size_mb < recommended_mb:
continue
- if best_mb is None or info["size_mb"] < best_mb:
- best, best_mb = model_name, info["size_mb"]
+ if best_mb is None or size_mb < best_mb:
+ best, best_mb = model_name, size_mb
return best
def _get_default_whispercpp_variant_for_size(self, model_size: str) -> Optional[str]:
"""Return the default specialization for a user-selected size."""
language_id = _language_for_whispercpp_variant(self)
- return _default_whispercpp_variant_for_size(model_size, language_id)
+ return _default_whispercpp_variant_for_size(model_size, cast(str, language_id))
def _on_apply_recommendation(self, _button: Any) -> None:
"""Set both pickers to the model the card is offering.
@@ -8178,7 +7702,7 @@ class SettingsDialog(Gtk.Dialog):
"""
pinned = self.config_manager.get_model_variant_for_engine("whisper_cpp")
language_id = _language_for_whispercpp_variant(self)
- return resolve_whispercpp_variant(saved_model_for_engine, pinned, language_id)
+ return resolve_whispercpp_variant(saved_model_for_engine, pinned, cast(str, language_id))
def _populate_whispercpp_model_options(self, saved_model_for_engine: str):
"""Populate whisper.cpp size and specialization selectors."""
@@ -8210,7 +7734,7 @@ class SettingsDialog(Gtk.Dialog):
if size_variant in WHISPERCPP_MODEL_INFO:
info = WHISPERCPP_MODEL_INFO[size_variant]
status = "✓" if is_whispercpp_model_downloaded(size_variant) else "↓"
- display_text += f" ({_format_size(info['size_mb'])}) {status}"
+ display_text += f" ({_format_size(cast(int, info['size_mb']))}) {status}"
if model_size == recommended_size:
display_text += " ★"
self.model_combo.append(model_size, display_text)
@@ -8231,7 +7755,7 @@ class SettingsDialog(Gtk.Dialog):
recommended_model, _ = self._get_recommended_whispercpp_model_for_language()
for model_name in variants:
- info = WHISPERCPP_MODEL_INFO[model_name]
+ info = cast(dict[str, Any], WHISPERCPP_MODEL_INFO[model_name])
is_downloaded = is_whispercpp_model_downloaded(model_name)
status = "✓" if is_downloaded else "↓"
star = " ★" if model_name == recommended_model else ""
@@ -8248,7 +7772,7 @@ class SettingsDialog(Gtk.Dialog):
# so English picks .en instead of leaving Standard multilingual stuck.
# Follow mode uses the layout sentinel here so an English *display*
# does not retarget onto .en and then refuse the mode.
- model_to_set = _whispercpp_variant_for_language(model_to_set, language_id)
+ model_to_set = _whispercpp_variant_for_language(model_to_set, cast(str, language_id))
if model_to_set not in variants:
model_to_set = None
if not model_to_set and recommended_model in variants:
@@ -8297,8 +7821,8 @@ class SettingsDialog(Gtk.Dialog):
if engine == "whisper_cpp":
for name in list_downloaded_whispercpp_models():
- info = WHISPERCPP_MODEL_INFO[name]
- size_mb = info["size_mb"] if isinstance(info.get("size_mb"), int) else 0
+ info = cast(dict[str, Any], WHISPERCPP_MODEL_INFO[name])
+ size_mb = cast(int, info["size_mb"])
items.append(
(
name,
@@ -8309,8 +7833,8 @@ class SettingsDialog(Gtk.Dialog):
)
elif engine == "whisper":
for name in _list_downloaded_whisper_models():
- info = WHISPER_MODEL_INFO[name]
- size_mb = info["size_mb"] if isinstance(info.get("size_mb"), int) else 0
+ info = cast(dict[str, Any], WHISPER_MODEL_INFO[name])
+ size_mb = cast(int, info["size_mb"])
items.append(
(
name,
@@ -8324,9 +7848,9 @@ class SettingsDialog(Gtk.Dialog):
lang_info = SUPPORTED_LANGUAGES.get(model.language, {})
lang_name = model.language
if isinstance(lang_info, dict):
- name = lang_info.get("name")
- if isinstance(name, str):
- lang_name = name
+ language_name_value = lang_info.get("name")
+ if isinstance(language_name_value, str):
+ lang_name = language_name_value
items.append(
(
model.dirname,
@@ -8432,7 +7956,7 @@ class SettingsDialog(Gtk.Dialog):
delete_btn.get_style_context().add_class("destructive-action")
response = dialog.run()
dialog.destroy()
- return response == Gtk.ResponseType.YES
+ return cast(bool, response == Gtk.ResponseType.YES)
def _delete_model_from_disk(self, model_id: str) -> None:
"""Delete a downloaded model for the selected engine."""
@@ -8935,7 +8459,7 @@ class SettingsDialog(Gtk.Dialog):
"""Expand or collapse the advanced island, remembering the choice."""
expanded = expander.get_expanded()
if expanded:
- self.advanced_box.show_all()
+ cast(Any, self.advanced_box).show_all()
# Per-engine visibility has to run after show_all, which would
# otherwise reveal rows the active engine does not use.
self._update_engine_specific_ui()
@@ -9020,7 +8544,7 @@ class SettingsDialog(Gtk.Dialog):
def _update_simple_visibility(self) -> None:
"""Show the simple questions, with the second language only when asked for."""
- self.simple_group.show_all()
+ cast(Any, self.simple_group).show_all()
wants_second = self.simple_multi_switch.get_active()
_set_no_show_all_visible(self.simple_second_language_row, wants_second)
@@ -9649,7 +9173,7 @@ class SettingsDialog(Gtk.Dialog):
self.update_recognition_progress("Listening", info="Starting recognition test...")
# Enough room for one utterance plus the configured silence window.
- delay = float(selected_settings.get("silence_timeout", 2.0)) + 2.0
+ delay = float(selected_settings.get("silence_timeout", 1.2)) + 2.0
threading.Thread(target=self._stop_test_after_delay, args=(delay,)).start()
def _test_text_callback(self, text: str):
diff --git a/src/vocalinux/ui/settings_engines.py b/src/vocalinux/ui/settings_engines.py
new file mode 100644
index 0000000..6b504d2
--- /dev/null
+++ b/src/vocalinux/ui/settings_engines.py
@@ -0,0 +1,50 @@
+"""Speech-engine metadata used by the Settings dialog."""
+
+from ..speech_recognition.engines import available_engines
+from ..utils import parakeet_model_info as parakeet
+from ..utils.faster_whisper_model_info import FASTER_WHISPER_MODEL_INFO
+from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO
+from ..utils.whispercpp_model_info import MODEL_SIZES as WHISPERCPP_MODEL_SIZES
+from ..utils.whispercpp_model_info import (
+ NON_DICTATION_MODELS,
+ WHISPERCPP_MODEL_INFO,
+ default_variant_for_size,
+)
+
+WHISPER_MODEL_INFO = {
+ "tiny": {"size_mb": 75, "desc": "Fastest, lowest accuracy", "params": "39M"},
+ "base": {"size_mb": 142, "desc": "Fast, good for basic use", "params": "74M"},
+ "small": {"size_mb": 466, "desc": "Balanced speed/accuracy", "params": "244M"},
+ "medium": {"size_mb": 1500, "desc": "High accuracy, slower", "params": "769M"},
+ "large": {"size_mb": 2900, "desc": "Highest accuracy, slowest", "params": "1550M"},
+}
+
+
+def get_available_engines() -> dict[str, bool]:
+ """Preserve the legacy engine-to-availability mapping."""
+ available = set(available_engines())
+ return {
+ name: name in available
+ for name in (
+ "vosk",
+ "whisper",
+ "whisper_cpp",
+ "parakeet",
+ "faster_whisper",
+ "remote_api",
+ )
+ }
+
+
+__all__ = [
+ "FASTER_WHISPER_MODEL_INFO",
+ "NON_DICTATION_MODELS",
+ "SUPPORTED_LANGUAGES",
+ "VOSK_MODEL_INFO",
+ "WHISPERCPP_MODEL_INFO",
+ "WHISPERCPP_MODEL_SIZES",
+ "WHISPER_MODEL_INFO",
+ "default_variant_for_size",
+ "get_available_engines",
+ "parakeet",
+]
diff --git a/src/vocalinux/ui/transcript_dialog.py b/src/vocalinux/ui/transcript_dialog.py
index 2a16117..b23a9c4 100644
--- a/src/vocalinux/ui/transcript_dialog.py
+++ b/src/vocalinux/ui/transcript_dialog.py
@@ -272,7 +272,7 @@ class TranscriptDialog(Gtk.Dialog):
dialog.format_secondary_text("Do you want to replace it?")
dialog.add_button("_Cancel", Gtk.ResponseType.CANCEL)
dialog.add_button("_Replace", Gtk.ResponseType.OK)
- confirmed = dialog.run() == Gtk.ResponseType.OK
+ confirmed = bool(dialog.run() == Gtk.ResponseType.OK)
dialog.destroy()
return confirmed
diff --git a/src/vocalinux/utils/resource_manager.py b/src/vocalinux/utils/resource_manager.py
index 1baf13a..e94cecd 100644
--- a/src/vocalinux/utils/resource_manager.py
+++ b/src/vocalinux/utils/resource_manager.py
@@ -192,7 +192,7 @@ class ResourceManager:
Dictionary with validation results
"""
resources_dir = self.resources_dir
- results = {
+ results: dict[str, object] = {
"resources_dir_exists": os.path.exists(resources_dir),
"icons_dir_exists": os.path.exists(self.icons_dir),
"sounds_dir_exists": os.path.exists(self.sounds_dir),
@@ -211,7 +211,7 @@ class ResourceManager:
for icon in expected_icons:
icon_path = self.get_icon_path(icon)
if not os.path.exists(icon_path):
- results["missing_icons"].append(icon)
+ cast(list[str], results["missing_icons"]).append(icon)
# Expected sounds
expected_sounds = ["start_recording", "stop_recording", "error"]
@@ -219,6 +219,6 @@ class ResourceManager:
for sound in expected_sounds:
sound_path = self.get_sound_path(sound)
if not os.path.exists(sound_path):
- results["missing_sounds"].append(sound)
+ cast(list[str], results["missing_sounds"]).append(sound)
return results
diff --git a/src/vocalinux/utils/system_language.py b/src/vocalinux/utils/system_language.py
index 550ce87..b1e15dc 100644
--- a/src/vocalinux/utils/system_language.py
+++ b/src/vocalinux/utils/system_language.py
@@ -16,7 +16,7 @@ import logging
import os
import re
import subprocess
-from typing import Optional
+from typing import Any, Optional, cast
from .host_process import host_env
@@ -161,22 +161,24 @@ def detect_keyboard_layout() -> Optional[str]:
return chosen.split(",")[0].strip() or None
-def detect_locale_language(environ: Optional[dict] = None) -> Optional[str]:
+def detect_locale_language(environ: Optional[dict[str, str]] = None) -> Optional[str]:
"""Return the first locale value the environment offers, or None.
LANGUAGE is returned intact (a colon-separated preference list). Mapping
that list onto a supported catalogue entry happens in
``_language_from_locale_env``.
"""
- environ = os.environ if environ is None else environ
+ environment = cast(dict[str, str], os.environ if environ is None else environ)
for name in _LOCALE_ENV_VARS:
- value = environ.get(name)
+ value = environment.get(name)
if value:
return value
return None
-def _language_from_locale_env(environ: dict, supported: set[str] | dict) -> Optional[str]:
+def _language_from_locale_env(
+ environ: dict[str, str], supported: set[str] | dict[str, dict[str, str]]
+) -> Optional[str]:
"""Map locale env vars onto a catalogue entry, first supported win.
LANGUAGE is a colon-separated preference list: each entry is tried through
@@ -199,7 +201,7 @@ def _language_from_locale_env(environ: dict, supported: set[str] | dict) -> Opti
def detect_system_language(
- supported: set[str] | dict, environ: Optional[dict] = None
+ supported: set[str] | dict[str, dict[str, str]], environ: Optional[dict[str, str]] = None
) -> Optional[str]:
"""Return the language id to start from, or None when nothing is decisive.
@@ -215,8 +217,8 @@ def detect_system_language(
logger.info(f"Language {layout_language} taken from keyboard layout {layout!r}")
return layout_language
- environ = os.environ if environ is None else environ
- locale_language = _language_from_locale_env(environ, supported)
+ environment = cast(dict[str, str], os.environ if environ is None else environ)
+ locale_language = _language_from_locale_env(environment, supported)
if locale_language:
logger.info(f"Language {locale_language} taken from locale environment")
return locale_language
@@ -255,7 +257,7 @@ def _gsettings_get(key: str) -> Optional[str]:
return result.stdout.strip() or None
-def _parse_sources(value: Optional[str]) -> list:
+def _parse_sources(value: Optional[str]) -> list[tuple[str, str]]:
"""Parse a gsettings input-source list, tolerating the ``@a(ss)`` prefix."""
text = (value or "").strip()
if text.startswith("@a(ss) "):
@@ -265,7 +267,9 @@ def _parse_sources(value: Optional[str]) -> list:
except (ValueError, SyntaxError):
logger.debug(f"Could not parse an input-source list: {value!r}")
return []
- return list(parsed) if isinstance(parsed, (list, tuple)) else []
+ if not isinstance(parsed, (list, tuple)):
+ return []
+ return cast(list[tuple[str, str]], list(parsed))
def _xkb_layout_from_source(source: object) -> Optional[str]:
@@ -283,7 +287,7 @@ def _xkb_layout_from_source(source: object) -> Optional[str]:
return source_id.split("+", 1)[0].strip() or None
-def _source_at_index(parsed: list, current: str) -> Optional[object]:
+def _source_at_index(parsed: list[tuple[str, str]], current: str) -> Optional[tuple[str, str]]:
"""Return the ``(type, id)`` pair the ``current`` index points at."""
if not parsed:
return None
@@ -307,7 +311,7 @@ def _parse_active_source(sources: str, current: str) -> Optional[str]:
return _xkb_layout_from_source(_source_at_index(_parse_sources(sources), current))
-def _active_input_source() -> Optional[object]:
+def _active_input_source() -> Optional[tuple[str, str]]:
"""Return GNOME's active ``(type, id)`` input source, or None if unreadable.
None means "no answer available" -- not GNOME, or the keys are missing. An
diff --git a/src/vocalinux/utils/whispercpp_model_info.py b/src/vocalinux/utils/whispercpp_model_info.py
index cdcfc12..1ddf6e4 100644
--- a/src/vocalinux/utils/whispercpp_model_info.py
+++ b/src/vocalinux/utils/whispercpp_model_info.py
@@ -11,7 +11,7 @@ import os
import re
import subprocess
from functools import lru_cache
-from typing import Optional
+from typing import Optional, cast
from .host_process import host_env
from .model_checksums import whispercpp_revision
@@ -352,8 +352,8 @@ def _prefer_discrete_vulkan_device() -> Optional[int]:
devices = _hardware_vulkan_devices()
for device in devices:
if device["device_type"] == "discrete":
- return device["index"]
- return devices[0]["index"] if devices else None
+ return cast(int, device["index"])
+ return cast(int, devices[0]["index"]) if devices else None
def _vulkan_device_name_by_index(devices: list[dict], device_index: Optional[int]) -> Optional[str]:
@@ -361,11 +361,11 @@ def _vulkan_device_name_by_index(devices: list[dict], device_index: Optional[int
if not devices:
return None
if device_index is None:
- return devices[0]["name"]
+ return cast(str, devices[0]["name"])
for device in devices:
if device["index"] == device_index:
- return device["name"]
- return devices[0]["name"]
+ return cast(str, device["name"])
+ return cast(str, devices[0]["name"])
@lru_cache(maxsize=1)
@@ -546,7 +546,7 @@ def _model_file_path(model_name: str) -> str:
model_info = WHISPERCPP_MODEL_INFO.get(model_name)
if model_info and model_info.get("url"):
- return os.path.join(whispercpp_dir, os.path.basename(model_info["url"]))
+ return os.path.join(whispercpp_dir, os.path.basename(cast(str, model_info["url"])))
return os.path.join(whispercpp_dir, f"ggml-{model_name}.bin")
diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py
index 447494b..75a56e1 100644
--- a/tests/test_config_manager.py
+++ b/tests/test_config_manager.py
@@ -86,6 +86,15 @@ class TestConfigManager(unittest.TestCase):
)
self.assertTrue(os.path.exists(self.temp_config_dir))
+ def test_paste_long_text_defaults_to_off_and_can_be_changed(self) -> None:
+ config_manager = ConfigManager()
+ self.assertFalse(DEFAULT_CONFIG["text_injection"]["paste_long_text"])
+ self.assertFalse(config_manager.get_paste_long_text())
+ config_manager.set_paste_long_text(True)
+ self.assertTrue(config_manager.get_paste_long_text())
+ config_manager.set_paste_long_text(False)
+ self.assertFalse(config_manager.get_paste_long_text())
+
def test_ensure_config_dir(self):
"""Test that _ensure_config_dir creates the directory."""
# Delete the config directory to test creation
diff --git a/tests/test_engine_availability.py b/tests/test_engine_availability.py
new file mode 100644
index 0000000..2914d34
--- /dev/null
+++ b/tests/test_engine_availability.py
@@ -0,0 +1,43 @@
+"""Tests for optional speech-engine availability checks."""
+
+import sys
+import unittest
+from importlib.machinery import ModuleSpec
+from unittest.mock import patch
+
+from vocalinux.speech_recognition import engines
+
+
+class TestEngineAvailability(unittest.TestCase):
+ def test_module_specs_control_availability_without_importing(self) -> None:
+ module_names = ("vosk", "whisper", "faster_whisper")
+ for available_modules in (set(module_names), set()):
+ with self.subTest(available_modules=available_modules):
+ with patch.dict(sys.modules):
+ for module_name in module_names:
+ sys.modules.pop(module_name, None)
+
+ def find_spec(name: str) -> ModuleSpec | None:
+ if name in available_modules:
+ return ModuleSpec(name, loader=None)
+ return None
+
+ with patch.object(
+ engines.importlib.util,
+ "find_spec",
+ side_effect=find_spec,
+ ):
+ available = engines.available_engines()
+
+ for module_name in module_names:
+ self.assertEqual(
+ module_name in available,
+ module_name in available_modules,
+ )
+ self.assertNotIn(module_name, sys.modules)
+
+ def test_module_availability_handles_spec_errors(self) -> None:
+ for error in (ImportError, ValueError):
+ with self.subTest(error=error):
+ with patch.object(engines.importlib.util, "find_spec", side_effect=error):
+ self.assertFalse(engines._module_available("missing"))
diff --git a/tests/test_faster_whisper_engine.py b/tests/test_faster_whisper_engine.py
index a21d6a4..137984e 100644
--- a/tests/test_faster_whisper_engine.py
+++ b/tests/test_faster_whisper_engine.py
@@ -10,7 +10,7 @@ import pytest
sys.modules["gi"] = MagicMock()
sys.modules["gi.repository"] = MagicMock()
-from vocalinux.common_types import Engine, EngineType
+from vocalinux.common_types import Engine, EngineType, SpeechEngine
from vocalinux.speech_recognition.engines.faster_whisper_engine import FasterWhisperEngine
from vocalinux.utils.faster_whisper_model_info import (
FASTER_WHISPER_MODEL_INFO,
@@ -271,9 +271,10 @@ class TestFasterWhisperEngine:
)
return whisper_mock
- def test_engine_implements_protocol(self):
+ def test_engine_implements_protocol(self) -> None:
"""Test that FasterWhisperEngine satisfies the Engine Protocol."""
engine = FasterWhisperEngine(model_size="tiny")
+ assert isinstance(engine, SpeechEngine)
assert hasattr(engine, "init")
assert hasattr(engine, "transcribe")
assert hasattr(engine, "is_ready")
diff --git a/tests/test_main.py b/tests/test_main.py
index f6cbfe3..a4191b9 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -234,7 +234,7 @@ class TestMainModule(unittest.TestCase):
model_size="medium",
language="en-us",
vad_sensitivity=3,
- silence_timeout=2.0,
+ silence_timeout=1.2,
stop_sound_guard_ms=200,
buffer_during_reload=True,
voice_commands_enabled=None,
diff --git a/tests/test_recognition_manager.py b/tests/test_recognition_manager.py
index d941f5f..f1b1e3a 100644
--- a/tests/test_recognition_manager.py
+++ b/tests/test_recognition_manager.py
@@ -301,7 +301,7 @@ class TestSpeechRecognition(unittest.TestCase):
# Default values
self.assertEqual(manager.vad_sensitivity, 3)
- self.assertEqual(manager.silence_timeout, 2.0)
+ self.assertEqual(manager.silence_timeout, 1.2)
# Configure with valid values
manager.reconfigure(vad_sensitivity=4, silence_timeout=1.5)
diff --git a/tests/test_recognition_manager_advanced.py b/tests/test_recognition_manager_advanced.py
index cd5ee76..23cb0a3 100644
--- a/tests/test_recognition_manager_advanced.py
+++ b/tests/test_recognition_manager_advanced.py
@@ -603,34 +603,53 @@ class TestInitWhispercpp(unittest.TestCase):
except Exception:
pass
- def test_cpu_fallback_filters_unsupported_whispercpp_params(self):
- mgr = _make_manager(engine="whisper_cpp")
- model = MagicMock()
- mock_pywhispercpp = MagicMock()
- mock_pywhispercpp.Model.return_value = model
-
- with patch.dict(
- "sys.modules",
- {
- "pywhispercpp": MagicMock(),
- "pywhispercpp.model": mock_pywhispercpp,
- },
+ def test_cpu_fallback_filters_unsupported_whispercpp_params(self) -> None:
+ for override, original_threads, expected_threads in (
+ (None, 2, 6),
+ (3, 2, 3),
):
- with patch.object(mgr, "_get_supported_whispercpp_params", return_value={"n_threads"}):
- loaded_backend = mgr._handle_gpu_fallback(
- RuntimeError("16-bit storage not supported"),
- "/tmp/model.bin",
- {"n_threads": 4, "no_context": True},
- "cpu",
- )
+ with self.subTest(override=override):
+ mgr = _make_manager(engine="whisper_cpp", whispercpp_n_threads=override)
+ model = MagicMock()
+ mock_pywhispercpp = MagicMock()
+ mock_pywhispercpp.Model.return_value = model
+ model_kwargs = {"n_threads": original_threads, "no_context": True}
- self.assertEqual(loaded_backend, "cpu")
- self.assertIs(mgr.model, model)
- self.assertEqual(mock_pywhispercpp.Model.call_count, 1)
- self.assertEqual(
- mock_pywhispercpp.Model.call_args.kwargs,
- {"n_threads": 4},
- )
+ with patch.dict(
+ "sys.modules",
+ {
+ "pywhispercpp": MagicMock(),
+ "pywhispercpp.model": mock_pywhispercpp,
+ },
+ ):
+ with patch.object(
+ mgr,
+ "_get_supported_whispercpp_params",
+ return_value={"n_threads"},
+ ):
+ with patch(
+ "vocalinux.speech_recognition.recognition_manager.multiprocessing.cpu_count",
+ return_value=8,
+ ):
+ with patch(
+ "vocalinux.speech_recognition.recognition_manager._show_notification"
+ ):
+ with patch.dict("os.environ", {}, clear=False):
+ loaded_backend = mgr._handle_gpu_fallback(
+ RuntimeError("16-bit storage not supported"),
+ "/tmp/model.bin",
+ model_kwargs,
+ "cpu",
+ )
+
+ self.assertEqual(loaded_backend, "cpu")
+ self.assertIs(mgr.model, model)
+ self.assertEqual(mock_pywhispercpp.Model.call_count, 1)
+ self.assertEqual(
+ mock_pywhispercpp.Model.call_args.kwargs,
+ {"n_threads": expected_threads},
+ )
+ self.assertEqual(model_kwargs["n_threads"], original_threads)
class TestDownloadVoskModel(unittest.TestCase):
@@ -650,11 +669,12 @@ class TestBufferManagement(unittest.TestCase):
class TestWhispercppModelKwargs(unittest.TestCase):
- def test_default_model_kwargs(self):
+ def test_default_model_kwargs(self) -> None:
mgr = _make_manager(engine="whisper_cpp")
kwargs = mgr._build_whispercpp_model_kwargs(n_threads=4)
self.assertTrue(kwargs.get("no_timestamps"))
self.assertTrue(kwargs.get("no_context"))
+ self.assertFalse(kwargs["print_progress"])
self.assertNotIn("initial_prompt", kwargs)
self.assertEqual(kwargs.get("temperature"), 0.0)
self.assertEqual(kwargs.get("n_threads"), 4)
@@ -694,7 +714,7 @@ class TestWhispercppModelKwargs(unittest.TestCase):
self.assertEqual(kwargs.get("no_speech_thold"), 0.8)
self.assertEqual(kwargs.get("n_threads"), 8)
- def test_model_kwargs_filter_unsupported_native_params(self):
+ def test_model_kwargs_filter_unsupported_native_params(self) -> None:
mgr = _make_manager(engine="whisper_cpp")
kwargs = mgr._build_whispercpp_model_kwargs(n_threads=4)
@@ -714,6 +734,7 @@ class TestWhispercppModelKwargs(unittest.TestCase):
)
self.assertNotIn("no_timestamps", filtered)
+ self.assertNotIn("print_progress", filtered)
self.assertEqual(filtered["n_threads"], 4)
self.assertTrue(filtered["no_context"])
diff --git a/tests/test_recognition_manager_device_config.py b/tests/test_recognition_manager_device_config.py
index 6385e95..0ef06ef 100644
--- a/tests/test_recognition_manager_device_config.py
+++ b/tests/test_recognition_manager_device_config.py
@@ -1199,61 +1199,101 @@ class TestWhispercppInitialization(unittest.TestCase):
assert manager.model_size == "tiny" # Should be corrected
assert manager._model_initialized is False
- def test_init_whispercpp_gpu_fallback(self):
+ def test_init_whispercpp_gpu_fallback(self) -> None:
"""Test whisper.cpp GPU fallback to CPU."""
- manager = _make_manager(engine="whisper_cpp")
- manager.model_size = "tiny"
+ import vocalinux.utils.whispercpp_model_info as whispercpp_info
- mock_pywhispercpp = MagicMock()
- mock_model_success = MagicMock()
+ for thread_override, expected_threads in ((None, (2, 6)), (3, (3, 3))):
+ with self.subTest(thread_override=thread_override):
+ manager = _make_manager(
+ engine="whisper_cpp",
+ whispercpp_n_threads=thread_override,
+ )
+ manager.model_size = "tiny"
+ manager.whispercpp_gpu_device = 0
- # Setup module hierarchy
- model_class = MagicMock(
- side_effect=[RuntimeError("16-bit storage not supported"), mock_model_success]
- )
- mock_pywhispercpp.Model = model_class
- mock_pywhispercpp.model.Model = model_class
+ mock_pywhispercpp = MagicMock()
+ model_class = MagicMock(
+ side_effect=[
+ RuntimeError("16-bit storage not supported"),
+ MagicMock(),
+ ]
+ )
+ mock_pywhispercpp.Model = model_class
+ mock_pywhispercpp.model.Model = model_class
- # Mock the imported functions from whispercpp_model_info
- mock_psutil = MagicMock()
- mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024
+ mock_psutil = MagicMock()
+ mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024
- with patch(
- "vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO", {"tiny": {}}
- ):
- with patch(
- "vocalinux.speech_recognition.recognition_manager.get_model_path",
- return_value="/fake/model.bin",
- ):
- with patch("os.path.getsize", return_value=100000000): # Mock file size
- with patch("os.path.exists", return_value=True):
- with patch.dict(
- "sys.modules",
- {
- "pywhispercpp": mock_pywhispercpp,
- "pywhispercpp.model": mock_pywhispercpp,
- "psutil": mock_psutil,
- },
- ):
- # Patch the imports that happen inside _init_whispercpp
- import vocalinux.utils.whispercpp_model_info as whispercpp_info
-
- with patch.object(
- whispercpp_info,
- "detect_compute_backend",
- return_value=(MagicMock(), "test"),
- ):
+ with patch(
+ "vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO",
+ {"tiny": {}},
+ ):
+ with patch(
+ "vocalinux.speech_recognition.recognition_manager.get_model_path",
+ return_value="/fake/model.bin",
+ ):
+ with patch("os.path.getsize", return_value=100000000):
+ with patch("os.path.exists", return_value=True):
with patch.object(
- whispercpp_info,
- "get_backend_display_name",
- return_value="Vulkan",
+ manager,
+ "_whispercpp_model_is_verified",
+ return_value=True,
):
- with patch(
- "vocalinux.speech_recognition.recognition_manager._show_notification"
+ with patch.dict(
+ "sys.modules",
+ {
+ "pywhispercpp": mock_pywhispercpp,
+ "pywhispercpp.model": mock_pywhispercpp,
+ "psutil": mock_psutil,
+ },
):
- with patch.dict("os.environ", {}, clear=True):
- manager._init_whispercpp()
- assert manager._model_initialized is True
+ with patch.object(
+ whispercpp_info,
+ "detect_compute_backend",
+ return_value=(MagicMock(), "test"),
+ ):
+ with patch.object(
+ whispercpp_info,
+ "detect_cpu_info",
+ return_value="test CPU",
+ ):
+ with patch.object(
+ whispercpp_info,
+ "detect_vulkan_devices",
+ return_value=[{"index": 0, "name": "test GPU"}],
+ ):
+ with patch.object(
+ whispercpp_info,
+ "get_backend_display_name",
+ return_value="Vulkan",
+ ):
+ with patch.object(
+ manager,
+ "_detect_pywhispercpp_gpu_backend",
+ return_value="vulkan",
+ ):
+ with patch(
+ "vocalinux.speech_recognition.recognition_manager.multiprocessing.cpu_count",
+ return_value=8,
+ ):
+ with patch(
+ "vocalinux.speech_recognition.recognition_manager._show_notification"
+ ):
+ with patch.dict(
+ "os.environ",
+ {},
+ clear=True,
+ ):
+ manager._init_whispercpp()
+
+ self.assertTrue(manager._model_initialized)
+ self.assertEqual(model_class.call_count, 2)
+ self.assertEqual(
+ tuple(call.kwargs["n_threads"] for call in model_class.call_args_list),
+ expected_threads,
+ )
+ self.assertEqual(manager._whispercpp_effective_threads, expected_threads[-1])
def test_init_whispercpp_import_error(self):
"""Test whisper.cpp when import fails."""
diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py
index 6fef6dc..035b4e6 100644
--- a/tests/test_settings_dialog.py
+++ b/tests/test_settings_dialog.py
@@ -10,7 +10,10 @@ UX Design Notes tested:
- No action buttons - uses title bar close (GNOME HIG)
"""
+import os
+import subprocess
import sys
+import textwrap
import time
import unittest
from unittest.mock import MagicMock, Mock, call, patch
@@ -52,6 +55,24 @@ mock_config_manager.set = Mock()
mock_config_manager.save_settings = Mock()
+class TestAvailableEngines(unittest.TestCase):
+ def test_available_engines_preserves_legacy_mapping(self) -> None:
+ from vocalinux.ui import settings_dialog, settings_engines
+
+ with patch.object(settings_engines, "available_engines", return_value=["whisper_cpp"]):
+ self.assertEqual(
+ settings_dialog.get_available_engines(),
+ {
+ "vosk": False,
+ "whisper": False,
+ "whisper_cpp": True,
+ "parakeet": False,
+ "faster_whisper": False,
+ "remote_api": False,
+ },
+ )
+
+
def apply_settings_internal(dialog, settings: dict) -> bool:
"""
Simplified version of SettingsDialog._apply_settings_internal for testing.
@@ -1281,7 +1302,7 @@ class TestSettingsNavigation(unittest.TestCase):
def test_sidebar_category_list_is_scrollable(self) -> None:
"""The category list sits in a ScrolledWindow so the dialog can shrink
vertically without clipping categories (#678)."""
- class_body = self.source_code.split("class SettingsDialog(Gtk.Dialog):")[1]
+ class_body = self.source_code.split("class SettingsDialog(AboutSectionMixin):")[1]
init_body = class_body.split("def __init__")[1].split("\n def ")[0]
self.assertIn("sidebar_scroller = Gtk.ScrolledWindow()", init_body)
self.assertIn(
@@ -1299,7 +1320,12 @@ class TestAboutPage(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.source = TestSettingsSearch._settings_source()
- cls.about = cls.source.split("def _build_about_section")[1].split("\n def ")[0]
+ from pathlib import Path
+
+ about_path = Path(__file__).resolve().parents[1] / "src/vocalinux/ui/settings_about.py"
+ cls.about_source = about_path.read_text(encoding="utf-8")
+ cls.source += cls.about_source
+ cls.about = cls.about_source.split("def _build_about_section")[1].split("\n def ")[0]
def test_three_groups_keep_updater(self):
self.assertIn('title="Vocalinux"', self.about)
@@ -1382,8 +1408,8 @@ class TestAboutPage(unittest.TestCase):
def test_talk_to_us_sits_above_updates(self):
self.assertLess(self.about.find('title="Talk to us"'), self.about.find('title="Updates"'))
self.assertLess(
- self.about.find("self.about_tab.pack_start(talk_group"),
- self.about.find("self.about_tab.pack_start(updates_group"),
+ self.about.find("about_tab.pack_start(talk_group"),
+ self.about.find("about_tab.pack_start(updates_group"),
)
def test_channel_check_shares_one_row(self):
@@ -1574,6 +1600,92 @@ class TestAboutInk(unittest.TestCase):
self.assertIn(_about_ink_hex(), {_ABOUT_INK, _ABOUT_INK_ON_DARK})
+class TestAboutColorSchemeRead(unittest.TestCase):
+ def test_about_section_reads_os_scheme_once_per_build(self) -> None:
+ script = textwrap.dedent("""
+ from unittest.mock import MagicMock, patch
+
+ from vocalinux.ui import settings_about, settings_dialog
+
+ dialog = MagicMock()
+ gtk = MagicMock()
+ gtk.Settings.get_default.return_value = None
+ with (
+ patch.object(settings_about, "Gtk", new=gtk),
+ patch.object(settings_dialog, "PreferenceRow", return_value=MagicMock()),
+ patch.object(settings_dialog, "PreferencesGroup", return_value=MagicMock()),
+ patch.object(settings_dialog, "_combo_with_suffix", return_value=MagicMock()),
+ patch.object(settings_dialog, "_combo_chrome"),
+ patch.object(settings_dialog, "_style_action_button"),
+ patch.object(settings_dialog, "_set_accessible_name"),
+ patch("vocalinux.ui.settings_about.os.path.exists", return_value=False),
+ patch(
+ "vocalinux.utils.gtk_color_scheme.read_os_color_scheme",
+ return_value="prefer-light",
+ ) as read_color_scheme,
+ ):
+ settings_about.AboutSectionMixin._build_about_section(dialog)
+ assert read_color_scheme.call_count == 1, read_color_scheme.call_count
+ print(f"ABOUT_SCHEME_READS={read_color_scheme.call_count}")
+ """)
+ env = os.environ.copy()
+ worktree_src = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))
+ env["PYTHONPATH"] = os.pathsep.join(
+ path for path in (worktree_src, env.get("PYTHONPATH", "")) if path
+ )
+ result = subprocess.run(
+ [sys.executable, "-c", script],
+ check=True,
+ capture_output=True,
+ env=env,
+ text=True,
+ )
+ self.assertIn("ABOUT_SCHEME_READS=1", result.stdout)
+
+
+class TestPasteLongTextPreference(unittest.TestCase):
+ def test_switch_loads_and_saves(self) -> None:
+ from vocalinux.ui import settings_dialog
+
+ dialog = MagicMock()
+ dialog.config_manager.get_settings.return_value = {
+ "general": {},
+ "ui": {},
+ "text_injection": {},
+ "history": {},
+ "auto_pause": {},
+ "model_keepalive": {},
+ "post_processing": {},
+ "advanced": {},
+ }
+ dialog.config_manager.get_paste_long_text.return_value = True
+ dialog.config_manager.get_paste_shortcut.return_value = "auto"
+ dialog._get_current_settings.return_value = {
+ "engine": "whisper_cpp",
+ "language": "auto",
+ "model_size": "tiny",
+ "vad_sensitivity": 3,
+ "silence_timeout": 2.0,
+ }
+ dialog._follow_layout_supported.return_value = False
+
+ with patch.object(
+ settings_dialog, "get_available_engines", return_value={"whisper_cpp": True}
+ ):
+ with patch("vocalinux.ui.autostart_manager.is_autostart_enabled", return_value=False):
+ settings_dialog.SettingsDialog._load_and_apply_settings(dialog)
+ dialog.paste_long_text_switch.set_active.assert_called_once_with(True)
+
+ dialog.config_manager.reset_mock()
+ with patch.object(settings_dialog, "_handlers_suppressed", return_value=False):
+ result = settings_dialog.SettingsDialog._on_paste_long_text_toggled(
+ dialog, MagicMock(), True
+ )
+ self.assertFalse(result)
+ dialog.config_manager.set_paste_long_text.assert_called_once_with(True)
+ dialog.config_manager.save_settings.assert_called_once_with()
+
+
class TestSettingsControlColumn(unittest.TestCase):
"""Dropdowns, steppers, and action buttons share one right-hand column."""
diff --git a/tests/test_shortcut_capture.py b/tests/test_shortcut_capture.py
index dabe12e..5ea2226 100644
--- a/tests/test_shortcut_capture.py
+++ b/tests/test_shortcut_capture.py
@@ -509,13 +509,13 @@ def test_stop_is_idempotent_and_callback_after_stop_does_not_commit(
def _settings_dialog_unbound(name: str) -> Any:
- """Return a SettingsDialog method without instantiating the dialog.
-
- Gtk.Dialog is a MagicMock in this harness, so `class SettingsDialog` is
- constructed as a MagicMock and the class body lives on ``return_value``.
- """
- namespace = SettingsDialog.return_value
- return namespace[name]
+ """Return a SettingsDialog method without instantiating the dialog."""
+ namespace = getattr(SettingsDialog, "return_value", None)
+ if isinstance(namespace, dict):
+ return namespace[name]
+ if namespace is None:
+ namespace = SettingsDialog
+ return getattr(namespace, name)
def test_destroy_clears_recording_flag_and_closes_devices(
diff --git a/tests/test_speech_recognition.py b/tests/test_speech_recognition.py
index 7d4a683..a49d65e 100644
--- a/tests/test_speech_recognition.py
+++ b/tests/test_speech_recognition.py
@@ -548,7 +548,7 @@ class TestSpeechRecognition(unittest.TestCase):
# Default values
self.assertEqual(manager.vad_sensitivity, 3)
- self.assertEqual(manager.silence_timeout, 2.0)
+ self.assertEqual(manager.silence_timeout, 1.2)
# Configure with valid values
manager.reconfigure(vad_sensitivity=4, silence_timeout=1.5)
diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py
index baee340..450e173 100644
--- a/tests/test_text_injector.py
+++ b/tests/test_text_injector.py
@@ -4,6 +4,7 @@ Tests for text injection functionality.
import contextlib
import json
+import logging
import os
import subprocess
import sys
@@ -566,6 +567,32 @@ class TestTextInjector(unittest.TestCase):
result = injector.inject_text("Test")
self.assertTrue(result)
+ def test_window_info_subprocesses_run_only_at_debug(self) -> None:
+ injector = TextInjector()
+ injector.environment = DesktopEnvironment.X11
+
+ with patch.object(injector, "_inject_with_xdotool"):
+ self.mock_subprocess.reset_mock()
+ with self.assertLogs(
+ "vocalinux.text_injection.text_injector",
+ level=logging.INFO,
+ ):
+ self.assertTrue(injector.inject_text("Test"))
+ self.mock_subprocess.assert_not_called()
+
+ self.mock_subprocess.reset_mock()
+ with self.assertLogs(
+ "vocalinux.text_injection.text_injector",
+ level=logging.DEBUG,
+ ):
+ self.assertTrue(injector.inject_text("Test"))
+
+ self.assertGreater(self.mock_subprocess.call_count, 0)
+ self.assertEqual(
+ self.mock_subprocess.call_args_list[0].args[0],
+ ["xdotool", "getactivewindow"],
+ )
+
def test_inject_text_returns_false_on_failure(self):
"""Test that inject_text returns False on failure."""
injector = TextInjector()
@@ -1786,6 +1813,219 @@ class TestTextInjectorEdgeCases(unittest.TestCase):
)
self.assertFalse(any("Escape" in command for command in commands))
+ def test_x11_long_text_pastes_when_enabled(self) -> None:
+ injector = TextInjector()
+ injector.environment = DesktopEnvironment.X11
+ text = "x" * 30
+ with patch.object(injector, "_paste_long_text_enabled", return_value=True):
+ with patch.object(injector, "_clipboard_holds_non_text", return_value=False):
+ with patch.object(injector, "_wait_for_modifiers_released") as wait:
+ with patch.object(
+ injector, "_inject_via_clipboard_paste", return_value=True
+ ) as paste:
+ injector._inject_with_xdotool(text)
+ wait.assert_called_once_with()
+ paste.assert_called_once_with(text, env=mock.ANY)
+
+ def test_x11_long_text_paste_is_opt_in_and_requires_thirty_chars(self) -> None:
+ for enabled, text in ((False, "x" * 30), (True, "x" * 29)):
+ with self.subTest(enabled=enabled, length=len(text)):
+ injector = TextInjector()
+ injector.environment = DesktopEnvironment.X11
+ self.mock_subprocess.reset_mock()
+ with patch.object(injector, "_paste_long_text_enabled", return_value=enabled):
+ with patch.object(injector, "_inject_via_clipboard_paste") as paste:
+ injector._inject_with_xdotool(text)
+ paste.assert_not_called()
+ self.assertTrue(
+ any(
+ call.args and call.args[0][:2] == ["xdotool", "type"]
+ for call in self.mock_subprocess.call_args_list
+ )
+ )
+
+ def test_x11_long_text_paste_failure_falls_back_to_typing(self) -> None:
+ injector = TextInjector()
+ injector.environment = DesktopEnvironment.X11
+ with patch.object(injector, "_paste_long_text_enabled", return_value=True):
+ with patch.object(injector, "_clipboard_holds_non_text", return_value=False):
+ with patch.object(injector, "_inject_via_clipboard_paste", return_value=False):
+ injector._inject_with_xdotool("x" * 30)
+ self.assertTrue(
+ any(
+ call.args and call.args[0][:2] == ["xdotool", "type"]
+ for call in self.mock_subprocess.call_args_list
+ )
+ )
+
+ def _mock_x11_clipboard_targets(self, stdout: str, returncode: int = 0) -> None:
+ def which_side_effect(command: str) -> str | None:
+ return f"/usr/bin/{command}" if command in {"xclip", "xdotool"} else None
+
+ def run_side_effect(command: list[str], **kwargs: Any) -> MagicMock:
+ if command == ["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"]:
+ return MagicMock(returncode=returncode, stdout=stdout, stderr="")
+ return MagicMock(returncode=0, stdout="1234", stderr="")
+
+ self.mock_which.side_effect = which_side_effect
+ self.mock_subprocess.side_effect = run_side_effect
+
+ def test_x11_long_text_types_when_clipboard_has_image_targets(self) -> None:
+ injector = TextInjector()
+ injector.environment = DesktopEnvironment.X11
+ self._mock_x11_clipboard_targets("TARGETS\nTIMESTAMP\nMULTIPLE\nSAVE_TARGETS\nimage/png\n")
+
+ with patch.object(injector, "_paste_long_text_enabled", return_value=True):
+ with patch.object(injector, "_inject_via_clipboard_paste") as paste:
+ injector._inject_with_xdotool("x" * 60)
+
+ paste.assert_not_called()
+ self.assertTrue(
+ any(
+ call.args and call.args[0][:2] == ["xdotool", "type"]
+ for call in self.mock_subprocess.call_args_list
+ )
+ )
+
+ def test_x11_long_text_pastes_when_clipboard_has_text_targets(self) -> None:
+ injector = TextInjector()
+ injector.environment = DesktopEnvironment.X11
+ self._mock_x11_clipboard_targets(
+ "TARGETS\nTIMESTAMP\nUTF8_STRING\ntext/plain;charset=utf-8\n"
+ )
+
+ with patch.object(injector, "_paste_long_text_enabled", return_value=True):
+ with patch.object(injector, "_inject_via_clipboard_paste", return_value=True) as paste:
+ injector._inject_with_xdotool("x" * 60)
+
+ paste.assert_called_once_with("x" * 60, env=mock.ANY)
+ self.assertFalse(
+ any(
+ call.args and call.args[0][:2] == ["xdotool", "type"]
+ for call in self.mock_subprocess.call_args_list
+ )
+ )
+
+ def test_x11_long_text_pastes_when_clipboard_is_empty(self) -> None:
+ injector = TextInjector()
+ injector.environment = DesktopEnvironment.X11
+ self._mock_x11_clipboard_targets("", returncode=1)
+
+ with patch.object(injector, "_paste_long_text_enabled", return_value=True):
+ with patch.object(injector, "_inject_via_clipboard_paste", return_value=True) as paste:
+ injector._inject_with_xdotool("x" * 60)
+
+ paste.assert_called_once_with("x" * 60, env=mock.ANY)
+
+ def test_x11_long_text_types_when_xclip_missing_and_clipboard_is_unreadable(self) -> None:
+ injector = TextInjector()
+ injector.environment = DesktopEnvironment.X11
+
+ def which_side_effect(command: str) -> str | None:
+ return "/usr/bin/xdotool" if command == "xdotool" else None
+
+ self.mock_which.side_effect = which_side_effect
+
+ with patch.object(injector, "_paste_long_text_enabled", return_value=True):
+ with patch.object(injector, "_read_clipboard", return_value=None):
+ with patch.object(injector, "_inject_via_clipboard_paste") as paste:
+ injector._inject_with_xdotool("x" * 60)
+
+ paste.assert_not_called()
+ self.assertTrue(
+ any(
+ call.args and call.args[0][:2] == ["xdotool", "type"]
+ for call in self.mock_subprocess.call_args_list
+ )
+ )
+
+ def test_x11_clipboard_meta_only_targets_are_treated_conservatively(self) -> None:
+ injector = TextInjector()
+ injector.environment = DesktopEnvironment.X11
+ self._mock_x11_clipboard_targets("TARGETS\nTIMESTAMP\nMULTIPLE\nSAVE_TARGETS\n")
+
+ self.assertTrue(injector._clipboard_holds_non_text())
+ self.mock_subprocess.assert_any_call(
+ ["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ timeout=1.0,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ env=mock.ANY,
+ )
+
+ def test_paste_long_text_setting_is_read_from_config(self) -> None:
+ injector = TextInjector()
+ with patch(
+ "vocalinux.text_injection.text_injector.config_dir",
+ return_value="/config",
+ ):
+ with patch(
+ "vocalinux.text_injection.text_injector.os.path.exists",
+ return_value=True,
+ ):
+ with patch(
+ "builtins.open",
+ mock_open(read_data='{"text_injection":{"paste_long_text":true}}'),
+ ):
+ self.assertTrue(injector._paste_long_text_enabled())
+
+ def test_xdotool_chunk_typing_has_no_inter_chunk_sleep(self) -> None:
+ injector = TextInjector()
+ injector.environment = DesktopEnvironment.X11
+ with patch.object(injector, "_paste_long_text_enabled", return_value=False):
+ self.mock_sleep.reset_mock()
+ injector._inject_with_xdotool("x" * 60)
+ self.assertNotIn((0.1,), [call.args for call in self.mock_sleep.call_args_list])
+
+ def test_plain_x11_clipboard_paste_uses_xdotool_and_x11_clipboard(self) -> None:
+ injector = TextInjector()
+ injector.environment = DesktopEnvironment.X11
+ self.assertEqual(
+ injector._clipboard_paste_command(),
+ ["xdotool", "key", "--clearmodifiers", "ctrl+v"],
+ )
+ self.assertEqual(
+ injector._clipboard_paste_command(terminal=True),
+ ["xdotool", "key", "--clearmodifiers", "ctrl+shift+v"],
+ )
+ with patch.object(injector, "_x11_clipboard_tools", return_value=["xclip"]) as tools:
+ with patch.object(injector, "_read_clipboard", return_value=None):
+ with patch.object(injector, "_copy_to_clipboard", return_value=True) as copy:
+ self.assertTrue(injector._inject_via_clipboard_paste("dictation"))
+ tools.assert_called_once_with()
+ copy.assert_called_once_with("dictation", tools=["xclip"])
+
+ def test_terminal_paste_detection_runs_on_plain_x11(self) -> None:
+ injector = TextInjector()
+ injector.environment = DesktopEnvironment.X11
+ with patch.object(injector, "_paste_shortcut_preference", return_value="auto"):
+ with patch(
+ "vocalinux.text_injection.text_injector.is_focused_window_terminal",
+ return_value=True,
+ ) as detect_terminal:
+ self.assertTrue(injector._should_use_terminal_paste())
+ detect_terminal.assert_called_once_with()
+
+ def test_read_clipboard_prefers_x11_selection_for_x11_injection(self) -> None:
+ injector = TextInjector.__new__(TextInjector)
+ injector.environment = DesktopEnvironment.X11
+ injector._session_environment = DesktopEnvironment.WAYLAND
+
+ def which_side_effect(command: str) -> str | None:
+ return f"/usr/bin/{command}" if command in {"wl-paste", "xclip", "xsel"} else None
+
+ self.mock_which.side_effect = which_side_effect
+ self.mock_subprocess.return_value = MagicMock(returncode=0, stdout="previous", stderr="")
+ with patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
+ self.assertEqual(injector._read_clipboard(), "previous")
+ self.assertEqual(
+ self.mock_subprocess.call_args.args[0],
+ ["xclip", "-selection", "clipboard", "-o", "-t", "UTF8_STRING"],
+ )
+
def test_inject_with_xdotool_terminates_options_before_the_text(self) -> None:
"""A chunk starting with '-' must type literally, not parse as a flag (#921).
diff --git a/tests/test_text_injector_ext.py b/tests/test_text_injector_ext.py
index 8fed5ad..e0cc881 100644
--- a/tests/test_text_injector_ext.py
+++ b/tests/test_text_injector_ext.py
@@ -1224,38 +1224,50 @@ class TestInjectText(unittest.TestCase):
class TestLogWindowInfo(unittest.TestCase):
- def test_log_x11(self):
+ def test_log_x11(self) -> None:
from vocalinux.text_injection.text_injector import DesktopEnvironment
obj = _make_injector(DesktopEnvironment.X11)
- with patch.object(obj, "_log_x11_window_info") as mock_log:
- obj._log_current_window_info()
- # Verify that _log_x11_window_info was called for X11 environment
- mock_log.assert_called_once()
+ with patch(
+ "vocalinux.text_injection.text_injector.logger.isEnabledFor",
+ return_value=True,
+ ):
+ with patch.object(obj, "_log_x11_window_info") as mock_log:
+ obj._log_current_window_info()
+ mock_log.assert_called_once()
- def test_log_wayland(self):
+ def test_log_wayland(self) -> None:
from vocalinux.text_injection.text_injector import DesktopEnvironment
obj = _make_injector(DesktopEnvironment.WAYLAND)
- # For pure Wayland, _log_current_window_info logs a debug message instead
- # Just verify it doesn't raise
- obj._log_current_window_info()
+ with patch(
+ "vocalinux.text_injection.text_injector.logger.isEnabledFor",
+ return_value=True,
+ ):
+ obj._log_current_window_info()
- def test_log_xwayland(self):
+ def test_log_xwayland(self) -> None:
from vocalinux.text_injection.text_injector import DesktopEnvironment
obj = _make_injector(DesktopEnvironment.WAYLAND_XDOTOOL)
- with patch.object(obj, "_log_x11_window_info") as mock_log:
- obj._log_current_window_info()
- # Verify that _log_x11_window_info was called for WAYLAND_XDOTOOL environment
- mock_log.assert_called_once()
+ with patch(
+ "vocalinux.text_injection.text_injector.logger.isEnabledFor",
+ return_value=True,
+ ):
+ with patch.object(obj, "_log_x11_window_info") as mock_log:
+ obj._log_current_window_info()
+ mock_log.assert_called_once()
- def test_log_exception(self):
+ def test_log_exception(self) -> None:
from vocalinux.text_injection.text_injector import DesktopEnvironment
obj = _make_injector(DesktopEnvironment.X11)
- with patch.object(obj, "_log_x11_window_info", side_effect=Exception("err")):
- obj._log_current_window_info() # Should not raise
+ with patch(
+ "vocalinux.text_injection.text_injector.logger.isEnabledFor",
+ return_value=True,
+ ):
+ with patch.object(obj, "_log_x11_window_info", side_effect=Exception("err")):
+ obj._log_current_window_info()
class TestInjectKeyboardShortcut(unittest.TestCase):
diff --git a/uv.lock b/uv.lock
index ef21cd9..27d1c7f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3007,6 +3007,7 @@ lint = [
{ name = "black" },
{ name = "flake8" },
{ name = "isort" },
+ { name = "mypy" },
]
[package.metadata]
@@ -3053,6 +3054,7 @@ lint = [
{ name = "black", specifier = ">=23.0.0" },
{ name = "flake8", specifier = ">=6.0.0" },
{ name = "isort", specifier = ">=5.12.0" },
+ { name = "mypy", specifier = ">=1.0.0" },
]
[[package]]