* feat(tray): allow disabling missing tray warning dialog
Fixes#620
* test(tray): stabilize missing-watcher opt-out coverage
Default the D-Bus ListNames mock to include StatusNotifierWatcher so
TrayIndicator construction no longer opens the warning dialog during
setUp, and bind the don't-show-again checkbox test to the module Gtk mock.
* test(tray): make missing-watcher opt-out tests deterministic
* fix(settings): restore custom shortcut Record/Set controls
Gtk.Widget.show_all() is a no-op while no_show_all is True, so the
contextual Custom Shortcut row never appeared after selecting it in the
searchable settings UI. Clear the flag before showing and restore it on
hide.
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* fix(settings): sync custom row when reverting shortcut separators
Selecting Custom Shortcut then a combo group separator restored the
preset id but left Record/Set visible. Reuse _sync_shortcut_selection_ui
so combo and custom-row visibility stay aligned.
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* fix(settings): clear stale Record/Set hint on separator revert
Selecting Custom Shortcut sets a temporary info hint; clicking a combo
separator already restored the row visibility but left that hint in
place. Refresh the mode description after syncing.
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* fix(languages): map en-in to Whisper code en
English (India) used the catalog id as the Whisper language argument,
which is invalid. Resolve Whisper/whisper.cpp/remote_api codes via the
SUPPORTED_LANGUAGES whisper field so en-us and en-in both become en.
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* test(languages): cover whisper language resolver fallbacks
Exercise unknown catalog ids and legacy en-us fallback paths so
codecov/patch covers resolve_whisper_language fully.
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
Expose Hungarian and other high-demand Whisper languages in Settings/CLI,
wire official VOSK models where Alphacephei ships them, keep CLI choices
derived from SUPPORTED_LANGUAGES, and update the languages marketing page
with honest per-engine support.
Fixes#565
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* fix(audio): prevent Bluetooth mic probe heap corruption
Open PortAudio streams only once when negotiating capture format, and
always stop_stream() before close(). Separate channel/rate probing was
aborting on Bluetooth SCO devices (e.g. FreeBuds) with malloc corruption.
Fixes#567
* refactor(audio): open capture stream once instead of probe-close-reopen
Adversarial review of the first fix found it still closed the probe
stream and immediately reopened the device — the same rapid open/close
pattern blamed for the Bluetooth SCO heap corruption, just fewer cycles.
_open_capture_stream() now returns the successfully negotiated stream
and all three callers (mic test, recording, reconnect) use it directly,
so each capture session performs exactly one PortAudio open. Also drops
the dead known_working_rate parameter.
* test(audio): cover negotiation fallback paths for codecov patch
Add tests for mic-test, recording, and reconnect fallback branches when
_open_capture_stream returns no stream, plus Bluetooth settle-on-failure
and empty-read reconnect cleanup. Brings patch diff coverage above 80%.
* feat(dictation): append trailing space after completed transcription
Fix cross-session glueing ("Hello.This") for push-to-talk and toggle
by appending a trailing space to each injected segment instead of relying
on in-session leading-space memory that is cleared on IDLE.
Adds an optional Settings toggle (default on) and preserves legacy
leading-space behavior when disabled. Newlines from voice commands are
left without a trailing space.
Fixes#605
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* fix(dictation): honor trailing-space setting from disk live
Read append_trailing_space from config.json on each injection so the
Settings toggle applies without restart. main() and TrayIndicator each
own a ConfigManager, so an in-memory read missed Settings writes — same
pattern as TextInjector.copy_to_clipboard.
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* test(dictation): align capitalize tests with trailing-space default
Update auto-capitalize expectations to include the default trailing
space, and add edge-case coverage for legacy mode, newlines, empty
input, and config-read failures so patch coverage clears 80%.
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* feat: auto-capitalize sentences after punctuation
Add capitalize_sentences() function that uppercases the first letter of
dictated text and after sentence-ending punctuation (., !, ?) followed by
whitespace. Preserves URLs, decimals, and abbreviations without trailing space.
- Config option text_injection.auto_capitalize (default: true)
- Settings toggle in General tab to opt out
- 17 tests covering edge cases
Fixes#553
* style: black-format auto-capitalize helpers for CI lint
Fixes the Lint Python check failure on the auto-capitalize PR.
* refactor: make auto-capitalize Vosk-only with clear UI indication
- Gate auto-capitalize feature to Vosk engine only (Whisper outputs proper casing)
- Update settings tooltip to explain Vosk-only behavior
- Update subtitle to show '(Vosk only)' for user clarity
* test(main): cover Vosk-only auto-capitalize injection path
Add callback wiring tests for enabled/disabled auto-capitalize on Vosk
and the Whisper skip path so codecov patch coverage clears the 80% bar.
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* refactor(settings): move Auto-Capitalize to Recognition tab
Auto-capitalize is dictation output post-processing, not application
lifecycle behavior, so it belongs alongside Voice Commands rather than
with autostart/clipboard settings in General.
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
Drop the thick left accent strip on .info-box helpers so they read as
muted flat surfaces with a thin full border, matching the preference
cards instead of looking like callout banners.
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* fix(ibus): keep engine teardown correct when parent destroy fails
`do_destroy` passed `super().do_destroy` to `_handle_engine_destroy`.
PyGObject binds that vfunc to the GType rather than to the instance, so
calling it with no arguments raises:
TypeError: IBus.Object.destroy() takes exactly 1 argument (0 given)
This fired on every engine teardown. The exception escaped
`_handle_engine_destroy` before its return, so neither
`_active_instance = ...` nor `_focus_event.clear()` in the caller ran,
leaving a destroyed engine registered as active with focus still set --
the two values `_ibus_ping_status` (#523) reads to decide commit
readiness.
Two changes:
- Bind the parent destroy to the instance, falling back to
`IBus.Object.destroy(self)` only when the no-arg call raises TypeError,
so this keeps working if PyGObject binds the vfunc correctly later.
- Guard the parent destroy call so no failure there can skip teardown.
Fixes#606
* refactor(ibus): extract parent-destroy workaround into a testable helper
Codecov flagged the new code as uncovered (36% patch, below the 80%
target). The whole `if IBUS_AVAILABLE:` branch of `do_destroy` is
unreachable from the suite, which only ever exercises that method with
IBUS_AVAILABLE False -- the single line it replaced was already
uncovered on main for the same reason.
Move the workaround into a module-level `_invoke_parent_destroy`, the
same pattern `_handle_engine_destroy` already uses to make `do_destroy`
testable, and bind it with functools.partial at the call site.
Behaviour is unchanged. Coverage of the branch goes from 0 to all but
the one-line partial, and three tests now pin the actual contract:
pass-through when the binding is correct, retry with the instance on the
GType-binding TypeError, and no masking of other exceptions.
* feat(text-injection): use IBus on unbridged compositors when ibus-wayland runs
The `_IBUS_UNBRIDGED_COMPOSITORS` denylist assumes nothing sits between
the compositor and ibus-daemon. Since IBus 1.5.32 that is no longer a
safe assumption: IBus ships `ibus-wayland`, which implements
`zwp_input_method_v2` and relays commits to native Wayland clients
speaking text-input-v3. Every compositor on the denylist exposes
`zwp_input_method_manager_v2`, so with the bridge running they are
bridged, and skipping IBus falls back to a slower path unnecessarily.
Add a live `pgrep -x ibus-wayland` probe and let it override the
denylist. Scoped to the denylist branch only, so GNOME and the KDE
VirtualKeyboard path (#574) are untouched.
Behaviour is unchanged when the bridge is not running, and because the
check is a live probe rather than configuration it degrades back to the
denylist by itself if the bridge dies or was never started. Cost is one
pgrep per TextInjector construction, not per injection.
Verified on Hyprland 0.56.1 with IBus 1.5.34: dictation into Electron
apps (VSCodium, Electron 39/41) and kitty commits through IBus, 303
chars in ~200ms versus 16.8s for 415 chars via `ydotool type`.
Also adds VOCALINUX_FORCE_BACKEND (ibus|wtype|ydotool|auto) as discussed
in #607. Autodetection has to infer whether IBus commits actually reach
the focused app and cannot verify it -- commit_text() reports success
even when the text is dropped -- so an escape hatch is useful when the
inference is wrong, and it makes the two paths A/B-testable without
editing code. Unknown values fall back to auto with a warning rather
than silently pinning a backend. Related to #476 and #402; happy to drop
this from the PR if it should follow their design instead.
Two existing tests asserted the unbridged outcome without pinning the
probe -- one blanket-mocks subprocess.run to returncode=0, the other
shells out for real -- so both now state the "no bridge" precondition
explicitly rather than depending on the host.
Note for users: Chromium only opens a zwp_text_input_v3 seat when
launched with --enable-wayland-ime; without it the bridge runs and the
app still receives nothing.
Closes#607
* test(text-injection): cover bridge override and forced-backend branches
Codecov flagged the new branches in _check_dependencies as uncovered
(74% patch, below the 80% target).
Adds four tests to TestCheckDependencies:
- unbridged compositor + ibus-wayland running selects IBus
- VOCALINUX_FORCE_BACKEND=wtype pins wtype where IBus would be chosen
- VOCALINUX_FORCE_BACKEND=ydotool pins ydotool even with wtype present
- VOCALINUX_FORCE_BACKEND=ibus bypasses the reachability guards, with
neither a daemon nor a bridge available
Every line added by this PR is now covered.
* fix(ui): AppImage smoke fixes for tray icons, labels, and identity
Replace placeholder mic tray SVGs with opaque branded icons, show
whisper.cpp instead of Whisper_cpp, set GLib/AppIndicator identity to
Vocalinux (not main.py), and give About dialog buttons enough padding
so labels are not clipped under Adwaita.
* fix(packaging): harden AppImage build for Wayland and tray deps
Pin the build interpreter, install onnxruntime into the bundle, set
desktop Exec=AppRun + StartupWMClass, prune typelibs to an allowlist
after linuxdeploy, and stop forcing GDK_BACKEND=x11 on Wayland
(VOCALINUX_FORCE_X11=1 remains as an escape hatch). CI AppImage jobs
also pull Ayatana AppIndicator and librsvg SVG loader packages.
* fix(packaging): bundle full stdlib from base_prefix in AppImage
When build.sh runs under a venv, sys.prefix only has site-packages, so
the AppImage was missing encodings and crashed on start. Copy the
interpreter and stdlib from sys.base_prefix, force pip
--ignore-installed into AppDir, set Gdk program class to Vocalinux, and
tone down the muted tray slash so small StatusNotifier icons stay slate.
* fix(tray): use absolute icon paths so stale hicolor icons lose
XFCE StatusNotifier resolves IconName through the system theme and can
ignore IconThemePath. A leftover ~/.local/share/icons placeholder
(vocalinux-microphone-off.svg filled #e74c3c) was winning over the
AppImage's bundled icons. Outside Flatpak, pass absolute SVG paths to
set_icon_full / new_with_path instead of bare theme names.
* feat(settings): replace tab navigation with searchable sidebar and topic pages
- Sidebar (icon + label) with Gtk.Stack replaces the 7-tab notebook
- Live settings search (Ctrl+F): filters rows in place, per-page match
counts, dims pages without hits, empty-results state
- Topic-based pages per GNOME HIG: Dictation, Speech Model, Audio,
Performance, Application, Advanced
- Persistent bottom status strip: recognition state, shared mic level
bar, and Test Dictation with inline transcription output
- GPU device selection moves out of the power-user gate onto the
Performance page; Reset to Defaults moves into the Advanced page
- Consolidated model info card (info + warning + legend in one surface)
- Contextual custom-shortcut row, in-list auto-pause empty state,
uniform control widths, aligned group descriptions
- Esc clears search, Ctrl+W closes
* test(settings): update source-inspection tests for sidebar redesign, add search coverage
* fix(settings): index unlocked advanced controls in search
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* fix(settings): restore selected page after clearing search
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
---------
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* fix(injection): skip unbridged IBus on KDE Plasma Wayland
Do not auto-start XIM ibus-daemon on Wayland (circular viability check).
On KDE, require KWin VirtualKeyboard enabled before selecting IBus.
Fixes#574
* refactor(injection): tighten KDE VirtualKeyboard bridge check
Ponytail cleanup: shorter logs and fewer failure branches, same behavior.
* style: black-format ibus daemon Wayland test for CI
* fix(injection): parse KWin VirtualKeyboard gdbus variants strictly
Match <<true>>/<<false>> wrappers instead of bare substrings so ambiguous
gdbus output cannot flip the #574 IBus bridge decision.
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* feat(auto-pause): unload speech model while selected apps run
Add Settings toggle + process list so Vocalinux frees CPU/GPU/RAM while
configured games/apps are running, then reloads the model when they exit.
Closes the process-list half of #445 (window-focus pause is separate).
* test(auto-pause): isolate tray wiring to avoid stale GLib mocks
Move TrayIndicator auto-pause tests into test_suspend_handler so
test_auto_pause_monitor never imports tray_indicator under a collection-time
gi MagicMock. That import pinned GLib.SOURCE_REMOVE and broke resume tests
later in the suite (CI on #517).
* test(auto-pause): raise patch coverage for codecov target
Cover process snapshot I/O, GLib poll scheduling, config coercion,
unload edge cases, and tray fallback paths so codecov/patch clears 80%.
* feat(keepalive): idle model offload for GPU/battery saving
Add opt-in keep-alive that unloads the speech model after inactivity so
hybrid-GPU laptops can release VRAM/Vulkan. Next dictation lazy-reloads.
Shares unload_model with auto-pause (#445) using distinct reasons so
app-pause still blocks dictation while idle unload does not.
Fixes#591 (with #445 via shared offload infrastructure).
* test(keepalive): mock gi.repository.GLib via sys.modules
Match the auto-pause GLib test pattern so CI on Python 3.9/3.10
sees the late `from gi.repository import GLib` import.
* test(keepalive): drop post-fire source_remove assertion
After the GLib timeout callback runs, _timeout_id is already cleared,
so shutdown does not call source_remove.
* fix(settings): hide auto-pause empty-state label when apps are listed
The dialog-wide show_all() after _load_and_apply_settings() re-showed
the 'No apps listed yet' row that _refresh_auto_pause_list had hidden.
Mark the row no_show_all and manage visibility explicitly.
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* feat(settings): move auto-pause and idle unload to dedicated Power tab
- New Power tab between Shortcuts and General so resource management
features are easy to find; General returns to app-behavior toggles only
- Rename 'Model Keep-Alive (Battery)' to 'Unload When Idle' — the old
name read as the opposite of what the feature does
- Clearer copy throughout: row titles, descriptions, tooltips,
'From running…' becomes 'Choose Running App…', empty-state hint
- Gray out app-list and idle-timeout controls while their feature
toggle is off
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* fix(settings): show which notebook tab is selected
The tab CSS targeted :selected, but GTK3 marks the active notebook tab
with :checked, so no selected styling ever applied — all tabs rendered
identically. Use :checked and add an accent underline + bold label.
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* fix(vulkan): auto-select discrete GPU and add manual device selection
On hybrid GPU systems (Intel iGPU + NVIDIA dGPU), whisper.cpp's Vulkan
backend always selected device 0 (the integrated GPU), resulting in
significantly slower performance.
Changes:
- Add detect_vulkan_devices() to enumerate all Vulkan GPUs and identify
discrete vs integrated devices
- Set GGML_VULKAN_DEVICE env var before model init to select the correct
GPU (auto-selects discrete GPU when available)
- Add whispercpp_gpu_device config option for manual device selection
- Add GPU device dropdown in Settings > Advanced for users with multiple
Vulkan GPUs
Fixes#589
* test: add coverage for Vulkan GPU device selection
- Test detect_vulkan_devices() with single/multiple GPUs
- Test _prefer_discrete_vulkan_device() preference logic
- Test GPU device selection in recognition_manager
- Verify GGML_VULKAN_DEVICE env var is set correctly
* fix(speech_recognition): pass whispercpp GPU device to pywhispercpp Model context_params
The Vulkan device-selection logic from PR #590 set the GGML_VULKAN_DEVICE
environment variable, but pywhispercpp did not pick it up. Pass the selected
GPU device through the pywhispercpp Model context_params so whisper.cpp
actually initializes the requested Vulkan device.
Fixes#589
* fix(speech_recognition): fallback when pywhispercpp lacks context_params
Older pywhispercpp releases (< ~1.4.0) reject the context_params keyword
and raise TypeError. Detect that specific failure and retry without GPU
device selection so the engine still loads on those versions.
Fixes#589
* refactor(speech_recognition): inline context_params assignment
Simplify the GPU device selection path and remove the temporary dict.
* fix(vulkan): parse real vulkaninfo GPU0: headers
detect_vulkan_devices() only recognized fictional "GPU id = N" headers,
so real vulkaninfo --summary output (GPU0:/GPU1:) returned no devices and
hybrid NVIDIA systems fell through to CUDA. Also resolve preferred device
names by GPU id instead of list position.
Fixes#589
* refactor(whispercpp): drop ineffective GGML_VULKAN_DEVICE env var
pywhispercpp ignores GGML_VULKAN_DEVICE; device selection already goes
through context_params={"gpu_device": N}. Remove the misleading env write
so maintainers do not think the env var is the selection path.
Anay GarodiaGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(cli): add version flag
* fix(cli): parse args before acquiring single-instance lock so --version works while app is running
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(ui): use Gtk response signal for first-run dialog
Overriding do_response and calling Gtk.Dialog.do_response() breaks on
modern PyGObject with "Class GtkDialog doesn't implement response".
Connect to the response signal instead so dialog.run() still works.
Fixes#566
* test(ui): cover first-run dialog response handler
Exercise connect("response") wiring and _on_response mapping so
codecov/patch is green for the #566 Gtk.Dialog.do_response fix.
* test(ui): isolate first-run dialog response coverage
Load first_run_dialog under a unique module name with a real Gtk.Dialog
stand-in so _on_response and connect("response") run under full-suite
import pollution without clobbering other tests' gi mocks.
* test(ui): cover _on_response via SimpleNamespace
Call FirstRunDialog._on_response on a stand-in object instead of
constructing Gtk.Dialog; enough for codecov/patch on the handler body.
* test(ui): map first-run responses via SimpleNamespace
Exercise FirstRunDialog._on_response for YES/NO/LATER/unknown without
constructing Gtk.Dialog.
* test(ui): collapse first-run response cases into one table
One loop covers YES/NO/LATER/unknown instead of four copy-pasted tests.
* test(ui): restore isolated first-run dialog coverage load
SimpleNamespace calls against vocalinux.ui.first_run_dialog.FirstRunDialog
fail under the full suite when earlier tests leave the class as a MagicMock.
Reload the source under a unique module name with a real Gtk.Dialog stand-in
(as in 680c5ed) so _on_response and connect("response") stay green on CI.
Fixes#550.
Root cause: VOSK_MODEL_INFO["medium"]["languages"] and
VOSK_MODEL_INFO["large"]["languages"] in vosk_model_info.py were missing
entries for "it" (Italian) and "en-in" (English-India), even though both
languages are present in the "small" table and VOSK actually publishes
bigger models for them (vosk-model-it-0.22, vosk-model-en-in-0.5 at
alphacephei.com/vosk/models).
When a user selected Italian + the medium (or large) VOSK model,
_init_vosk() built vosk_model_map["medium"] = None (via .get() on a
missing key), and _get_vosk_model_path() then called
os.path.join(MODELS_DIR, None), crashing with:
join() argument must be str, bytes, or os.PathLike object, not 'NoneType'
before any download was even attempted. This matches the exact debug log
in the issue and the settings-dialog download flow the reporter exercised.
Fix:
- Add the missing "it"/"en-in" entries to the medium/large tables so all
10 supported languages now resolve consistently across all three sizes.
- Add a guard in _get_vosk_model_path() that raises a clear ValueError
("No VOSK '<size>' model available for language '<lang>'...") instead of
crashing deep inside os.path.join, so any future data gap fails loudly
and helpfully rather than with a cryptic TypeError.
* chore(release): prepare v0.14.2
IBus stability patch on the 0.14 series: engine process launch after XDG
import (#534) and FocusIn gate for first dictation on GNOME Wayland (#533).
Keep 0.14 series feature list in README/UPDATE for patch releases; changelog
and AppStream note only the delta. Document that rule in RELEASE_PROCESS.
* fix(release): address 0.14.2 review findings
- Dates: v0.14.2 uses 2026-07-17 (not before 0.14.1)
- AUR: sha256sums=SKIP until tag; document updpkgsums in RELEASE_PROCESS
- Single version source: package __init__ re-exports version.py; User-Agent uses __version__
- Docs: drop redundant series recaps; shrink AppStream/changelog delta
- README ecosystem status: Stable v0.14.2 (not Beta)
* docs(release): include settings scroll fixes in 0.14.2 notes
Cover #538 and #541 (scrollable settings tabs / wheel polish) in README,
UPDATE, website changelog, AppStream, and version history after rebasing
onto main.
* fix(ibus): wait for FocusIn before commit on scoped injection
Cold first activation of the vocalinux engine on GNOME Wayland could
commit before mutter bound a client context, so logs reported success
while the first dictation of a session was dropped.
Fixes#523
* refactor(ibus): clear focus event directly in do_destroy
Drop the clear_focus callback on the destroy helper; one local branch is enough.
* test(ibus): cover FocusIn gate helpers for patch coverage
Extract PING/focus-wait helpers and exercise them so codecov/patch
clears the 80% target on the #523 fix.
* fix(ibus): restore engine process launch after XDG path import
PR #484 switched ibus_engine to a relative import of xdg_data_home for
Flatpak-safe paths. start_engine_process and the IBus component exec still
run this file by path (python /path/to/ibus_engine.py), which has no
package context, so the process died immediately with ImportError and
Vocalinux fell back to ydotool (clipboard paste).
Accept absolute/inline import fallbacks when loaded as a script, log child
stderr from engine.stderr.log on startup failure, and add a path-based
--xml smoke test so this does not regress.
* test(ibus): cover start_engine_process failure diagnostics
Raise patch coverage for the engine.stderr.log failure paths so
codecov/patch meets the 80% target on #534.
* chore: re-trigger CI after rebase onto main
Patch release for Flatpak/AUR packaging and post-0.14.0 fixes:
installer wrappers, ydotool dual API and Flatpak socket probe, model
download timeouts, layout-aware hotkeys, and related docs/web bumps.
* feat(flatpak): add Flatpak packaging for universal distribution
Adds a Flathub-ready Flatpak for the whisper.cpp engine, plus the minimal
runtime adaptations Flatpak actually needs — deliberately scoped down from the
earlier attempts (#416, #428), which bundled unrelated whisper.cpp GPU, audio,
and clipboard changes.
Packaging (packaging/flatpak/):
- app manifest, AppStream metainfo, and desktop entry (com.vocalinux.Vocalinux)
- generated offline Python dependency modules (flatpak-pip-generator)
- bundled shared modules: PortAudio, libayatana-appindicator, xdotool, xsel
Runtime changes (Flatpak-only, behaviour unchanged outside a sandbox):
- utils/paths.py: honour XDG_CONFIG_HOME/XDG_DATA_HOME so config, models and
runtime data live under ~/.var/app/<app-id> in the sandbox
- autostart uses `flatpak run <app-id>` (the host launcher is always on PATH)
- text injection and keyboard backend detect a Wayland host reached via XWayland
(no Wayland socket granted) and route through xdotool / pynput-on-X11
- tray indicator references the exported `<app-id>-*` host icon theme names
Also: a CI workflow building x86_64 + aarch64 bundles, and README / packaging
README / distro-compatibility doc updates.
Verified with a full flatpak-builder build + bundle install on aarch64:
pywhispercpp loads its native libraries without LD_LIBRARY_PATH, XDG paths
redirect correctly, bundled tools are present, and the entrypoint runs.
Manifest and builddir lint are clean and AppStream metadata validates.
Closes#167
* chore(flatpak): sync metainfo version to 0.14.0-beta
Also ignore flatpak-build-dir/ so local builder output stays untracked.
* refactor(flatpak): trim docs and comments after ponytail review
Drop redundant packaging README command blocks, shrink path/tray/autostart
comments, and stop ignoring a non-canonical build-dir name.
* fix(flatpak): close readiness gaps for v0.14.0-beta
Route log files through XDG data dirs so Flatpak keeps them under
~/.var/app, refresh AppStream screenshots to vocalinux.com, and use
the SPDX GPL-3.0-only license id.
* fix(flatpak): enable global hotkeys via evdev and --device=input
pynput over XWayland only receives keys for the focused X client. Prefer
evdev inside Flatpak and grant /dev/input so shortcuts work app-wide.
* fix(flatpak): inject text with ydotool/uinput on Wayland
xdotool only reaches XWayland clients, so typing appeared to succeed but
never hit native apps. Ship ydotool, grant --device=all for uinput, start
ydotoold on demand, and type via uinput inside the Flatpak.
* fix(flatpak): paste via wl-copy + Ctrl+V instead of typing
ydotool type injects character-by-character. Ship wl-clipboard, grant
--socket=wayland, and prefer clipboard paste for instant insertion.
* test(text-injection): expect ydotool default key-delay of 2
Matches the shipped VOCALINUX_YDOTOOL_KEY_DELAY default used when
clipboard paste falls back to character typing.
* fix(hotkeys): resolve combo letters via active XKB layout
Settings capture stores character tokens from GDK keyvals (e.g. alt+a),
but the Wayland/evdev path mapped letters to fixed US KEY_* codes. On
AZERTY that made Alt+A listen for the key labeled Q. Resolve letter
tokens through libxkbcommon for the active layout so recording and
listening agree.
Fixes#513
* refactor(hotkeys): slim layout-aware key resolution
Drop unused resolve helper, dual knobs, setxkbmap/localectl probes, and
redundant tests. Keep gsettings + libxkbcommon map and the AZERTY combo path.
* test(hotkeys): drop redundant AZERTY resolve-only case
Keep fire/no-fire combo coverage and the XKB fr map check.
* test(hotkeys): cover layout map detection and error paths
Raise patch coverage for layout_key_map / evdev_code_for_key so codecov
patch target (80%) is met.
Selecting a preset after recording a custom combo left the custom entry
filled, so both looked active. Sync the dropdown to "Custom Shortcut"
when a non-preset is set, and clear the custom field when a preset wins.
#502 only exempted KDE from the #478 real-engine gate. GNOME/Mutter has
the same scoped-activation path, so stock sessions with only an xkb:*
layout fell through to wtype, which Mutter rejects, then clipboard-only.
Allow any Wayland session without an explicit non-IBus IM to try IBus
when ibus-daemon is running; unbridged compositors still hit the
existing denylist.
Fixes#504
* feat(asr): add chat audio remote benchmarking
* style(asr): apply black formatting
* test(main): include remote API model kwarg
* test(asr): add coverage for chat-completions error handling and parsing
Add 10 new tests to cover uncovered lines in PR #468:
- ConnectionError handler in _try_chat_completions_audio_api (lines 1601-1602)
- _parse_chat_completion_transcription edge cases: None, list content,
empty/whitespace strings, JSON without text field, plain text, and
single-line language metadata (lines 1610, 1613-1621, 1625, 1629-1640)
Coverage for recognition_manager.py improved from 85% to 86%.
* feat(asr): support FunASR remote responses
* refactor(asr): scope remote FunASR support to the #469 path
Drop chat-completions/Qwen client, evaluation notes, and the remote ASR
benchmark harness. Keep model field, tolerant OpenAI response parsing,
SenseVoice label stripping, FunASR docs, and tests.
* feat(shortcuts): configurable modifier+key hotkeys (e.g. Alt+R)
Generalize the shortcut system beyond single-modifier double-tap/hold to
support modifier+key combos (alt+r, ctrl+alt+r, super+space) in both toggle
(press once) and push-to-talk (hold) modes.
- base.py: ShortcutSpec + parse_shortcut_spec/is_valid_shortcut/format label;
parse_shortcut kept for back-compat (bare modifiers still rejected).
- evdev + pynput backends: combo detection with global (cross-device) modifier
tracking so split keyboards work; legacy gestures unchanged.
- keyboard_shortcuts: validate via is_valid_shortcut instead of a fixed allowlist.
- settings_dialog: additive Custom Shortcut row (typed entry + live Record capture).
- tests: 34 new tests (parser, back-compat, evdev + pynput combo, split keyboard).
* fix(shortcuts): apply saved shortcut (not stale preset) on mode change
When a custom shortcut (e.g. alt+r) is active, the preset combo still points at
a default/previous preset. _on_shortcut_mode_changed read the combo, so toggling
Toggle<->Push-to-Talk restarted the live listener on the wrong shortcut while
config still said alt+r. Read the saved shortcut from config instead.
Addresses Codex P2 review on PR #493.
* style: apply black formatting to configurable-hotkeys files
* test: cover _gdk_keyname_to_token shortcut-capture mapping
* fix(shortcuts): reset combo state after dropped/lost evdev events
SYN_DROPPED and device-disconnect recovery cleared key_pressed_devices but not
the combo state. A dropped modifier *release* left _combo_pressed thinking the
modifier was held, so the main key alone could falsely toggle/start dictation,
and a push-to-talk hold could stay stuck. Add _reset_combo_state() (clears the
pressed set and ends any live hold) and call it from both recovery paths.
Addresses maintainer review on PR #493.
* fix(settings): combo-aware toggle label (single press, not double-tap)
_update_shortcut_ui_for_mode always said 'Double-tap the key' for toggle mode,
but a modifier+key combo (e.g. alt+r) toggles on a single press. Read the saved
shortcut and use get_shortcut_display_name so a combo shows 'Press Alt+R'.
Addresses maintainer review on PR #493.
---------
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
* fix(text-injection): wait for shortcut modifiers to release before Wayland inject
Injection emits real key events, so a held modifier from a toggle/PTT shortcut
(e.g. Alt from Alt+R) turns the simulated Ctrl+V paste into Ctrl+Alt+V and
nothing pastes — while the clipboard still shows the text. Because transcription
can finish in tens of ms, the user is often still holding the key, causing
intermittent paste failures.
Poll evdev for held modifier keycodes and defer injection until they clear
(bounded by VOCALINUX_INJECT_MODIFIER_WAIT, default 1.0s, 0 disables). Returns
immediately when nothing is held, so no added latency in the common case.
* style: black-format injection modifier-wait test
* test: cover _held_modifier_keycodes evdev scanning (patch coverage)
* test: cover error paths in _held_modifier_keycodes
* fix(text-injection): treat non-finite modifier-wait env as default
float('inf') parses cleanly and is > 0, so it slipped past the guard and made
the wait deadline infinite -- a held modifier could block injection forever
(nan was nonsensical too). Sanitize VOCALINUX_INJECT_MODIFIER_WAIT via a helper
that falls back to the 1.0s default for non-finite values. Regression tests
added, including that 'inf' still terminates.
Addresses maintainer review on PR #494.
* Fix: IBus engine restore uses wrong fallback on GNOME/Wayland (#497)
On GNOME/Wayland, when IBus has no global engine (common when Mutter
manages input sources), 'ibus engine' can return a misleading
'xkb:us::eng' default that doesn't match the user's actual keyboard
layout. When Vocalinux restores this wrong value after text injection,
Electron apps switch to US layout.
Root cause: get_current_engine() trusted the output of 'ibus engine'
without checking whether IBus actually had a global engine set, and
had no fallback to discover the real keyboard layout.
Fix:
- Add get_current_engine_gnome_fallback() that reads the actual
keyboard layout from org.gnome.desktop.input-sources via gsettings
- Modify get_current_engine() to detect 'No global engine' in stderr
and to detect the suspicious 'xkb:us::eng' default on GNOME/Wayland,
falling back to gsettings in both cases
- Modify stop() to log a message when skipping engine restoration
because no valid engine was captured (None means don't restore)
- Modify inject_text() to log when skipping restoration after
injection for the same reason
- Backward compatible: if gsettings is unavailable, falls back to
current behavior (returning None or the IBus-reported value)
Tests: 12 new tests covering GNOME fallback, 'No global engine'
detection, suspicious default detection, gsettings parsing, and
edge cases.
* ponytail: simplify gsettings uint32 parsing
gsettings prints 'uint32 N' or just 'N' — split()[-1] handles both,
no need for separate branches
---------
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* fix(audio): resolve device by name to prevent index mismatch crash (#498)
Audio device indices can shift between sessions when USB devices are
replugged or virtual devices are added/removed. A saved device_index=24
(BRIO webcam) could later point to 'speech-dispatcher-dummy', causing
PortAudio to attempt recording from a virtual device and triggering
malloc corruption / SIGABRT.
Fix has three parts:
1. Store and match by device name, not just index
- Config already saves device_name alongside device_index
- At recording time, _resolve_device_by_name() enumerates current
devices and finds the one matching the saved name
- Falls back to stored index, then system default (with warning log)
- main.py now passes audio_device_name to SpeechRecognitionManager
2. Filter virtual devices from input device enumeration
- _is_virtual_device() detects speech-dispatcher, dummy, null sink,
monitor, and ALSA output devices
- get_audio_input_devices() skips virtual devices
- Settings dialog dropdown only shows real input devices
- Debug logging in _record_audio also skips virtual devices
3. Validate resolved device has input channels (from flatpak branch)
- Extra safety check: after resolving, verify maxInputChannels > 0
- Falls back to system default if resolved device is invalid
- Brings the validation from feature/flatpak-packaging to main
Closes#498
* style: apply black formatting to recognition_manager.py
* test: add audio_device_name kwarg to mock assertion
* test: add coverage for _is_virtual_device, _resolve_device_by_name, device name management
* test: fix StopIteration in fallback test by using return_value for mock
* ponytail: remove redundant validation and simplify _resolve_device_by_name
- Remove ~20 lines of dead validation in _record_audio() — _resolve_device_by_name
and _resolve_valid_input_device already check maxInputChannels and virtual devices
- Simplify _resolve_device_by_name to just find index by name, then delegate all
validation to _resolve_valid_input_device (which already does channel/virtual checks)
- Update tests to match the simplified delegation pattern
* style(speech): format device fallback warning
* test(speech): cover audio device fallback edges
* test(speech): cover reconnection device fallback
---------
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
* fix(ibus): require a real IM engine on Wayland (#478)
On Wayland a bare xkb:* IBus engine is a layout passthrough that never
delivers commit_text() to applications, so selecting the IBus backend
there silently no-ops (niri/sway, and GNOME/Mutter via the env-var
branch). Require a real IM engine before treating IBus as active on
Wayland, regardless of how it was detected; X11/XWayland (XIM) is
unchanged. Complements #486's compositor denylist.
* test(ibus): bind #478 patches to the module under test
Patch get_current_engine/is_ibus_daemon_running via patch.object on a
held ibus_engine reference and call is_ibus_active_input_method through
it, so the patched functions and the function under test always share
one module object. Fixes flakiness in the full CI suite where other test
files re-import ibus_engine and the dotted-string patch bound a
different module object.
On Wayland, setxkbmap only reaches the XWayland X11 server, so
restore_xkb_layout() rewrote the XWayland layout (flipping it to us for
XWayland/Electron apps) while native Wayland apps stayed correct. Make
restore_xkb_layout() a no-op on Wayland and stop defaulting the captured
layout to 'us' so a never-captured layout is never re-applied.
Pass --key-delay 8 to ydotool type instead of relying on the default
(12-20ms). This makes text injection ~3x faster while keeping key-delay
above zero to avoid the Shift-leak bug where capital letters corrupt
following characters.
The delay is configurable via VOCALINUX_YDOTOOL_KEY_DELAY env var.
Note: --key-hold flag from the original issue does not exist in standard
ydotool (verified against official man page and v1.0.4 source).
Fixes#482
* fix(text_injection): use wtype on compositors that don't bridge IBus to native apps
On Wayland compositors that don't bridge IBus to native clients (COSMIC,
Sway, Hyprland, Wayfire, river, niri, ...), the IBus engine's commit_text()
reaches only XWayland and GTK/Qt apps that load the IBus IM module. Native
apps such as cosmic-term receive nothing, yet inject_text() reports success,
so dictation silently does nothing.
- Add a compositor denylist (_wayland_compositor_bridges_ibus) so IBus is
skipped on those compositors and the virtual-keyboard path (wtype/ydotool)
is used instead. A denylist (rather than an allowlist) keeps the existing
IBus-preferred behaviour on unrecognised desktops. Complements the recent
KDE Plasma Wayland handling, which covers the mirror-image case.
- Replace the unreliable `ydotool type ""` probe (exits 0 even with no daemon)
with _is_ydotoold_running(), so a daemonless ydotool install correctly falls
back to wtype instead of selecting a no-op ydotool.
- Add unit tests for the compositor gate, the ydotoold check, and that an
active-IBus COSMIC session selects wtype.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(text_injection): cover ydotoold socket-env and ydotool direct-mode paths
Add tests for the two new branches codecov/patch flagged as uncovered:
the explicit YDOTOOL_SOCKET path in _is_ydotoold_running(), and the
ydotool direct-mode fallback when no daemon and no wtype are available.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two independent text-injection bugs on Wayland:
1. Clipboard copy silently failed. wl-copy/xclip/xsel fork a background
process that keeps owning the selection; capturing its stderr with
subprocess.PIPE makes run() block on the surviving child until the
clipboard is next overwritten, so the call always hits its timeout and
_copy_to_clipboard reports failure even though the copy succeeded.
Redirect stdout/stderr to DEVNULL so run() only waits for the
short-lived foreground process.
2. ydotool scrambled text on non-US keyboard layouts. `ydotool type`
emits positional evdev keycodes that get re-interpreted through the
active layout (assumed US QWERTY), so dictated text came out garbled on
AZERTY/QWERTZ/Dvorak (e.g. "message" -> ",essqge") and non-ASCII was
dropped. Always inject via clipboard paste (Ctrl+V) for ydotool, which
is layout-independent and Unicode-safe.
Adds regression tests for both.
Claude-Session: https://claude.ai/code/session_01EhyratrEE4vDWhriirUB9k
* feat(whisper): refine whisper.cpp model selection
* refactor(whisper): exclude diarization model from picker
* docs(whisper): document guided model variants
* fix(ui): cap settings dialog height at 600px to prevent excessive height on high-res displays
* fix(ibus): capture current engine during prepare_engine for scoped activation
Two bugs were causing IBus injection to fail and fall back to ydotool:
1. get_current_engine() returned empty string (falsy) when 'ibus engine'
output was empty, instead of None. This caused inject_text() to think
no engine was available.
2. prepare_engine() didn't capture the current IBus engine, so when
inject_text() tried to get it at runtime, it would fail on systems
where 'ibus engine' returns empty output.
Fix: get_current_engine() now returns None for empty output. prepare_engine()
captures the current engine before starting the Vocalinux process. inject_text()
uses the captured _previous_engine as fallback when get_current_engine() fails.
Fixes the regression from PR #457 where IBus injection would fail with:
WARNING Could not determine current IBus engine
ERROR IBus engine instance is not active and the previous engine
could not be captured safely
* fix(ibus): always activate engine for injection on Wayland
On Wayland, 'ibus engine' returns empty output, so get_current_engine()
returns None. The previous code would skip activation when it couldn't
determine the current engine, causing injection to fail and fall back
to ydotool.
Now we always activate the Vocalinux engine when it's not active,
regardless of whether we can capture the previous engine. If we couldn't
capture it, we simply don't restore anything after injection.
This fixes the regression where IBus injection would fail with:
WARNING Could not determine current IBus engine
ERROR IBus engine instance is not active and the previous engine
could not be captured safely
Fixes#458
* test(ibus): add missing mocks for is_engine_active and get_current_engine
Existing tests didn't mock is_engine_active() and get_current_engine(),
which are now called at the start of inject_text() for scoped activation.
This caused CI failures on Python 3.9-3.13 where the unmocked calls
would try to run 'ibus engine' and get unexpected results.