perf: faster Settings, typing and dictation; mypy in CI, engine interface, architecture docs (#968)

* fix(perf): reduce startup and inference overhead

* fix(perf): report effective whisper.cpp fallback settings

* fix(injection): add opt-in X11 paste for long dictations

* perf(vad): lower default silence timeout to 1.2s

* fix(types): resolve mypy errors across application source

* ci: run mypy in the lint job

* ci: include mypy in lint dependency group

* fix(types): narrow evdev timestamp comparison

* refactor(recognition): add SpeechEngine protocol and engine dispatch table

* refactor(settings): move About section and engine tables out of settings_dialog

* docs: add ARCHITECTURE.md and refresh docs index and AGENTS layout

* fix(settings): preserve GTK dialog compatibility after extraction

* style: format touched Python modules

* fix(types): keep local typecheck from syncing dependencies

* test(vad): align main startup expectation with new default

* style: sort engine registry and protocol test imports

* test(settings): follow extracted About builder attribute alias

* test(settings): isolate GTK dialog base in About test

* test(settings): isolate About color scheme build test

* test(settings): restore GTK base after About scheme test

* test(settings): isolate About scheme check from GTK mocks

* test(settings): support extracted dialog mixin methods

* docs: add before/after performance benchmark charts

* fix(injection): type instead of pasting when the clipboard holds non-text data
This commit is contained in:
Jatin K Malik
2026-10-20 20:23:08 -07:00
committed by GitHub
parent 4799f06bd2
commit 65de3427fb
48 changed files with 7354 additions and 905 deletions
+4
View File
@@ -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
+20 -11
View File
@@ -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/` |
+111
View File
@@ -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.
+5
View File
@@ -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 |
+2
View File
@@ -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
+56
View File
@@ -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
![Settings first open](settings.svg)
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
![Text injection](typing.svg)
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
![GPU fallback](gpu_fallback.svg)
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
![Silence timeout](silence_timeout.svg)
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.
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 37 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 38 KiB

+1 -1
View File
@@ -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:
+1
View File
@@ -102,6 +102,7 @@ lint = [
"black>=23.0.0",
"isort>=5.12.0",
"flake8>=6.0.0",
"mypy>=1.0.0",
]
[project.scripts]
+8 -8
View File
@@ -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}"
+1 -1
View File
@@ -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:
+45 -1
View File
@@ -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."""
...
+1 -1
View File
@@ -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)
@@ -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
@@ -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()
@@ -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"]
@@ -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)'}...'")
+8 -4
View File
@@ -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:
+119 -32
View File
@@ -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:
+20 -8
View File
@@ -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).
+2 -2
View File
@@ -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)
@@ -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()
@@ -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]:
"""
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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]
+522
View File
@@ -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)
File diff suppressed because it is too large Load Diff
+50
View File
@@ -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",
]
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+16 -12
View File
@@ -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
+7 -7
View File
@@ -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")
+9
View File
@@ -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
+43
View File
@@ -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"))
+3 -2
View File
@@ -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")
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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)
+49 -28
View File
@@ -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"])
+87 -47
View File
@@ -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."""
+116 -4
View File
@@ -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."""
+7 -7
View File
@@ -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(
+1 -1
View File
@@ -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)
+240
View File
@@ -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).
+29 -17
View File
@@ -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):
Generated
+2
View File
@@ -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]]