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