Commit Graph
295 Commits
Author SHA1 Message Date
f36c3f6ae5 feat(text_injection): add RemoteDesktop portal injection backend (#885)
* feat(text_injection): add RemoteDesktop portal injection backend

Wayland offers exactly one sanctioned key-injection path:
org.freedesktop.portal.RemoteDesktop's NotifyKeyboardKeysym. It reaches
native clients on compositors with no input-method-v2 bridge, works from
inside the Flatpak sandbox without /dev/uinput or a helper daemon, and
delivers keysyms so non-US layouts survive -- unlike ydotool's evdev
keycodes.

RemoteDesktopPortal is a synchronous client over a dedicated worker
thread holding a private bus connection and GLib context, which keeps
Request::Response delivery deterministic. Sessions are created lazily on
first use so startup probing only reads the interface version; v2 portal
sessions persist a restore token (mode 0600 under data_dir) so the
permission prompt appears once.

Backend selection now prefers the portal on Wayland: it is probed at
runtime ahead of ydotool/wtype/xdotool in autodetection, is pinnable via
VOCALINUX_FORCE_BACKEND=portal or text_injection.backend, and demotes to
the previous order on runtime failure. Flatpak still needs --device=all
for evdev hotkeys, but uinput is only a fallback path now.

Closes #750

* fix(text_injection): resolve Greptile findings on RemoteDesktop portal injection

- Move persist_mode/restore_token to SelectDevices options (portal v2
  sends them there, not CreateSession); a bad token is ignored rather
  than sinking the session, so drop the stale-token retry paths that
  could issue a second Start after a user cancel
- Track delivered units on RemoteDesktopPortalError so Wayland
  fallbacks retry only the undelivered tail instead of retyping the
  prefix (text chars, backspace taps, shortcut steps)
- Give queued portal jobs a single deadline budget across
  CreateSession/SelectDevices/Start waits; on submit timeout the job
  is cancelled via a Gio.Cancellable plus abort checkpoints so the
  worker cannot keep emitting keys after the caller gave up
- Close sessions whose Start never succeeded
- Release held shortcut modifiers on mid-step failure
- Skip the portal probe when an installed wtype/ydotool backend is
  explicitly pinned
- Type annotations on the new signatures and touched tests

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 23:55:27 -07:00
5e531cbac0 refactor(audio): extract capture source from recognition manager (#883)
* refactor(audio): extract capture source from recognition manager

Move audio capture out of the silence-segmented dictation loop into
vocalinux.audio.capture: a reusable PortAudioCaptureSource that owns
device resolution, format negotiation, downmixing, resampling, and
stream lifecycle (open/read/reopen/close). The dictation loop now
consumes 16 kHz mono chunks and keeps only segmentation policy (VAD,
silence counters, buffer limits, reconnection backoff).

This unblocks alternate capture sources (PipeWire system-audio capture
for #751, tdrz long-form output for #756) which need the same capture
mechanics without dictation's silence segmentation.

Behavior is identical: the same helpers run in the same order, moved
verbatim. They are re-exported from recognition_manager for existing
imports, and a CaptureSource protocol is declared in common_types.py.
Tests repoint their patches of the moved helpers to the new module.

Closes #760

* fix(audio): annotate capture API and keep a superseded session's source

* fix(audio): reset retry counters when a new capture session starts

* fix(audio): keep last-error timestamp across capture sessions

The new-session reset wiped _last_audio_error_time, so an error within five seconds of the previous session's error always took the reconnect path instead of failing fast. Only the attempt count is per-session; the timestamp is also the rapid-failure throttle.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 23:04:43 -07:00
9e6a03cd20 feat(settings): make the settings category list scrollable (#886)
* feat(settings): make the settings category list scrollable

Wrap the sidebar category ListBox in a Gtk.ScrolledWindow (matching the
page wrappers) so the dialog can shrink vertically without clipping
categories and new pages do not pin the minimum window height.

Fixes #678

* fix(settings): reveal the selected sidebar row in the scroller

* fix(settings): retry sidebar scroll until the selected row is laid out

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 22:59:58 -07:00
ff108595fd fix(hotkeys): consume the dictation shortcut so it stops leaking to apps (#873)
* fix(hotkeys): consume the dictation shortcut so it stops leaking to apps

Grab each monitored keyboard with EVIOCGRAB and re-emit every event the
shortcut does not own through a paired uinput clone. The configured
shortcut key is now consumed by Vocalinux instead of also reaching the
focused app (right-alt opening menus in Thunderbird/Firefox, F11/F12
triggering browser actions).

- Pure-modifier gestures reserve the modifier's events entirely; bare
  function-key shortcuts consume that key; modifier+key combos consume
  the main key only while the combo owns it, so typing and chords like
  Alt+Tab keep working and injected text is untouched
- BUS_VIRTUAL devices (our clones, ydotool, remappers) are never grabbed
- EV_REP is stripped from clones and repeats are forwarded verbatim, so
  repeat rate is preserved without doubling
- Feedback event types (EV_LED/EV_SND/EV_FF) are never re-injected
- Falls back to ungrabbed listening when /dev/uinput or the grab is
  unavailable, warning once that keys will also reach the app

Fixes #871

* test(hotkeys): cover grab/forward edge cases

Race cleanup on duplicate open, forwarder close errors during stop and
device removal, mid-SYN_DROPPED forwarding, write failures, and the
combo consume edge cases (unowned repeat/release, missing main key).

* fix(installer): grant read on /dev/uinput so key suppression works

The evdev backend's uinput clone opens /dev/uinput O_RDWR, but the
installed 80-dotool.rules gave the input group write-only access
(0620), so shortcut suppression silently fell back to ungrabbed
monitoring on stock installs. Bump the rule to 0660 and refresh it when
the installed copy differs so existing installs pick up the fix.

* fix(hotkeys): harden evdev grab/forward lifecycle and udev install

- release the device grab and close the clone when a uinput write fails
- close all devices if the monitor exits unexpectedly (releases grabs)
- monitor virtual devices too; skip only our own uinput clones
- track consumed combo presses per device so releases stay paired
- after SYN_DROPPED, release keys the clone still believes are held
- append the uinput udev rule instead of overwriting local rules
- annotate new signatures and narrow the clone-creation catch

* fix(hotkeys): reconcile swallowed combo releases after SYN_DROPPED

- resync the swallowed-press pairing even when the clone holds no keys;
  a dropped combo release otherwise leaves the next ordinary press
  consumed, eating a keystroke
- narrow every new close-cleanup catch to OSError/IOError/RuntimeError
  with debug context instead of blanket Exception passes

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 22:59:35 -07:00
004696bbe0 refactor(settings): consolidate dialog guard flags and widget construction (#878)
* refactor(settings): consolidate dialog guard flags and widget construction

The settings dialog grew the same work in several places. This gathers
the shared pieces into module helpers with no behavior change:

- _suppressed(): a context manager for the try/finally flag dance the
  handlers used to open-code for _populating_models,
  _processing_language_change, _simple_syncing, and _simple_driving.
  The apply-guard handoff and the nested resync keep their explicit
  save/restore because they do not fit a lexical block.
- _handlers_suppressed(): one name for the
  "initializing or applying" check every handler repeated on entry.
- _set_no_show_all_visible(): the clear-flag / show / re-arm sequence
  for rows created hidden, used by the second-language row and the
  custom-shortcut row.
- _add_switch_row(), _combo_chrome(), _new_spin(),
  _connect_picker_entry(): the repeated switch-row, tooltip +
  scroll-guard, spin-button, and picker-entry wiring.
- _undownloaded_model_info() and _make_download_dialog(): the
  per-engine download gate both apply paths carried verbatim.

Flags stay plain booleans and helpers stay module-level so existing
tests keep working unchanged.

* fix(settings): annotate download dialog builder and align visibility docs

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 22:59:18 -07:00
ba721779c3 fix(downloads): abort blocked model fetches on cancel (#888)
* fix(downloads): abort blocked model fetches on cancel

requests.get() has no way to abort while it is still resolving,
connecting, or waiting for response headers — which is what an
unreachable download server looks like. The cancel flag was only
checked between response chunks, so Cancel sat until the request
timed out. The blocking open now runs on a helper thread the caller
polls every 200 ms; on cancel it raises at once while the abandoned
attempt stays bounded by the existing timeouts.

The settings dialog's download workers also reported "Model ready to
use" whenever the apply returned True, but reconfigure() can no-op
when the selection matches live state — success without a download.
They now pass force_reinit so a reported success means the engine
actually initialized the downloaded model.

Fixes #679

* fix(downloads): bound cancel openers, close late responses

- Track blocked request opener threads and give each attempt its own stop
  event so a cancel-and-retry cannot stack unaccounted requests and a
  response landing after cancel is closed instead of leaked
- Catch requests.exceptions.RequestException in the opener instead of a
  bare Exception
- Close the streaming response on the mid-stream cancel path
- Make the blocked-open test helper cancellable and reap tracked openers;
  check each download worker call site for force_reinit separately; add
  missing return annotations on new test helpers and methods

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 22:52:12 -07:00
d29c884782 fix(settings): gray out Dictation Tone while sound effects are off (#877)
* fix(settings): gray out Dictation Tone while sound effects are off

The Enable Sound Effects switch mutes every dictation cue, so the tone
picker changed nothing while it was off. The row now goes insensitive
with the switch and follows the state loaded from config, matching the
keep-alive timeout and auto-pause sensitivity pattern.

Also bump the pre-commit isort pin 5.13.2 -> 8.0.1 to match uv.lock: the
two versions disagree on single-name import wrapping, so the old hook
collapsed imports the locked isort (and CI) then expanded back.

Fixes #849

* test(settings): cover tone-row sensitivity on settings load

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 22:43:56 -07:00
Jatin K MalikandGitHub 77def71354 fix(audio): stop passing explicit index when zero devices enumerate (#891)
* fix(audio): stop passing explicit index when zero devices enumerate

With zero audio devices, _resolve_valid_input_device returned the saved preferred_index, so _open_capture_stream opened input_device_index=<stale> — a device that cannot exist — and Pa_OpenStream aborted the whole process (SIGSEGV) before the clean IOError->ERROR path could run. Zero enumerated devices now returns None so PyAudio falls back to the system default and fails catchably; a negative count (enumeration failure) still passes the preferred index through.

* test(audio): cover capture path omitting device index

* test(audio): cover saved-device zero-enumeration capture path

* test(audio): annotate new resolver tests
2026-09-29 22:26:16 -07:00
a89966a744 feat(recognition): record while an idle model reloads (#851)
* feat(recognition): buffer audio during model reload

* fix(recognition): recover buffered reload failures

* test(recognition): cover buffered reload edge cases

* fix(recognition): narrow buffered reload errors

* fix(settings): wait for IDLE before restoring Test Dictation callbacks

Buffered reload's stop_recognition() returns while PROCESSING. Test
Dictation must keep its text callbacks until recognition reaches IDLE
so the test utterance cannot inject into the focused app.

* style: apply Black formatting to PR 851 files

* fix(settings): cancel buffered reload before Test Dictation timeout restore

On the ~3min idle-wait timeout, do not restore live injector callbacks while
a buffered reload worker may still deliver text. Cancel via
_cancel_reload_recording off the GTK thread, then restore; if cancel is
unavailable or fails, leave test callbacks installed and show an error.
Add return/param annotations and regression coverage.

* fix(settings): keep Test Dictation waiting if buffered cancel fails

After the idle-wait timeout, if cancel is unavailable or raises, keep
polling with test callbacks and _test_active instead of marking the test
done. Ending early left the test callback as the only text destination,
so later normal dictation could land in the hidden test buffer. Restore
live injectors only once recognition reaches IDLE.

* fix(settings): reset Test Dictation timeout-cancel flag per session

Clear _test_timeout_cancel_attempted when a test starts, when entering
the buffered idle-wait, and when restoring UI so a later Test Dictation
can still cancel after timeout. Narrow the cancel-worker except clause
to AttributeError/OSError/RuntimeError/TypeError so unrelated defects
are not treated as ordinary cancel failure.

* fix(recognition): signal capture completion when buffered capture dies

A dead microphone during a buffered model reload left should_record=false
with no stop_recognition handoff, so the reload worker blocked forever on
_capture_finished.wait(). Signal the event from the audio thread's failure
teardown paths instead.

Refs VocaHQ/vocalinux#851

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 22:02:56 -07:00
Web-BenefitsGitHubAlexanderDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
db07590761 feat(activation): D-Bus activation for compositor global shortcuts (#568)
Rebased onto latest main (conflict-resolution squash for PR #568).

Co-authored-by: Alexander <ab@digital-benefits.de>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-29 21:19:29 -07:00
Jatin K MalikandGitHub fb9166ed9e feat(settings): run local VocaGateway from Advanced (podman-first) (#774)
* feat(settings): run local VocaGateway from Speech Engine (podman-first)

Add an optional Local VocaGateway panel next to Remote Server so Linux users
can start the pinned v0.1.0 compose project with podman (docker fallback),
watch Live/Pairable/Ready status, pair a phone without loopback QR URLs, and
preset remote_api to the gateway transcriptions endpoint. Stop preserves
volumes; Flatpak fails closed; tray offers Stop only when this session manages
the gateway. Default on-device engine stays whisper.cpp.

* fix(gateway-embed): drop nonexistent compose cpu profile

VocaGateway v0.1.0 exposes default service gateway plus native/cuda/vulkan
profiles, not cpu. Start and stop the default service without --profile, keep
the latest image pin rejection, and prefer save_config for the remote_api preset.

* fix(gateway-embed): keep compose --profile cpu per embed contract

v0.1.0 has no cpu profile entry, but Compose still starts the unprofiled
gateway service when --profile cpu is set, and keeps cuda/vulkan/native off.
That matches the frozen desktop embed contract and stays forward-compatible.

* fix(gateway-embed): harden embed path for settings and compose

Keep podman/docker probing off the GTK thread, reject link-local and
loopback public URLs for phone QR, sanitize compose .env image overrides,
and cap bootstrap token reads so a hostile file cannot blow memory.

* fix(gateway-embed): keep LAN toggle and phone QR in sync

When Allow LAN flips while compose is already managed, rewrite publish
host and PUBLIC_URL and recreate the gateway before Pairable comes back.
Also reject default docker/podman bridge addresses for phone QR.

* test(gateway-embed): pin bridge and LAN-gate pairing cases

Cover 172.17.0.2 / link-local not becoming Pairable, and the mid-run
LAN toggle path that must wait for compose republish before advertising
a phone URL.

* style(gateway-embed): black format embed modules

* fix(gateway-embed): reject short-form loopback and localhost.* for QR

Align Pairable/QR host checks with vocagateway desktop-embed policy so
127.1 / localhost.* never become phone-reachable display URLs.

* fix(gateway-embed): detach Settings status listener on destroy

Gateway poll callbacks were left registered after Settings closed, so
idle_add could touch destroyed GTK widgets while compose stayed up.

* fix(gateway-embed): pass host_env to bridge ip probe

_bridge_iface_ips shells out to host ip; without host_env the AST
gate fails and AppImage bundles can hand the wrong GLib to ip.

* fix(packaging): ship gateway_embed in the AppImage

The explicit setuptools packages list omitted the new package, so the
bundle booted from source trees but died with ModuleNotFoundError on
every distro boot test.

* style(packaging): black the gateway_embed package guard

* fix(settings): pack local VocaGateway into the Advanced island

The rebase onto main auto-merged textually onto the simple Speech Model
page. Keep the gateway card on advanced_box, next to Remote Server.

* fix(gateway-embed): treat missing Compose as unavailable

When podman or docker is present but Compose is not, stop advertising Run.
Return empty compose_args, show an install-Compose hint, and cover it with a unit test.

Also add module loggers on the new gateway_embed modules and annotate the
gateway Settings/tray callbacks Greptile flagged. just lock found no lockfile drift.

* fix(gateway-embed): keep Allow LAN bind matched to the switch

Snapshot the LAN flag for start/republish, record what compose actually
got, then republish again if the switch moved mid-flight so 0.0.0.0 never
lingers while Allow LAN reads off.

* fix(gateway-embed): detect leftover compose after Quit

Quit does not stop compose, so a LAN-published gateway can keep running.
Settings and the tray treated that as Stopped because managed_by_us is
only process memory. Probe compose after runtime detection, show Stop
when it is actually up, and let Stop work even if this session did not
start it.

* fix(gateway-embed): restore LAN switch from leftover compose

Quit leaves compose running, so the next launch now reads
VOCAGATEWAY_PUBLISH_HOST from the embed .env, adopts the project as
managed, and keeps Allow LAN honest so republish can close 0.0.0.0.

* fix(gateway-embed): restore .env if LAN republish fails

compose recreate can fail after write_env_file already pointed
VOCAGATEWAY_PUBLISH_HOST at loopback. Restore the previous .env so
orphan adopt still sees a live 0.0.0.0 bind, and snap Allow LAN back
to the running publish mode.

* style(gateway-embed): type helper args and black MagicMock

* refactor(types): move Readable protocol to common_types

AGENTS.md keeps Protocol interfaces in common_types. Pairing now imports
the shared Readable surface instead of a local _Readable helper.

* fix(gateway-embed): satisfy mypy on rebased embed code

* fix(audio): keep duck restore point when a set reports failure
2026-09-29 15:33:27 -07:00
Jatin K MalikandGitHub c2654609ac feat(ui): floating glowing dictation overlay (#516)
* feat(ui): add floating glowing dictation overlay

Show a bottom-center non-focus overlay while recognition is LISTENING
or PROCESSING so users get a clear visual cue beyond the tray icon.
Optional GtkLayerShell on Wayland; plain always-on-top GTK fallback
elsewhere. Configurable via ui.show_overlay (default on) and Settings.

* fix(test): isolate dictation overlay tests from GTK pollution

Overlay unit tests no longer construct real GTK windows or import
tray_indicator, which was loading gi.repository early and breaking
later tests that mock GI (single-instance and suspend GLib checks).

* docs: add dictation overlay screenshots

Capture the floating glowing orb in listening (green) and processing
(amber) states for the PR and README screenshots section.

* test(ui): raise dictation overlay coverage for Codecov patch gate

Exercise GTK init, draw/animation, positioning, layer-shell fallbacks,
tray overlay wiring, settings toggle, and config edge cases so patch
coverage on the floating overlay clears the 80% Codecov target.

* test(tray): call overlay helpers via unbound methods

Avoid instance-level MagicMock shadowing of set_overlay_enabled under
some import/mock orderings (seen on CI Python 3.10).

* test(tray): inject explicit config mock for overlay helpers

Assign a MagicMock config_manager on the instance so assertions do not
depend on the constructor patch (which can leave a real ConfigManager
under some Python versions / import orders).

* refactor(ui): ponytail pass on dictation overlay

Drop the hand-drawn mic glyph, simplify the Cairo clear/pulse path,
reuse ConfigManager get_bool/set, tighten tray overlay helpers, and
delete string-grep structural tests that proved nothing.

* fix(ui): pin overlay to bottom strip and stop stealing focus

Use a full-width bottom strip (layer-shell left/right/bottom anchors, or
resize+move fallback) so the orb is bottom-center on Wayland. Map the
window once and toggle opacity instead of hide/show so compositors do not
re-activate it and yank focus out of the editor (which broke IBus inject).

* fix(appimage): seed GtkLayerShell typelib for dictation overlay

* fix(ui): address greptile findings on dictation overlay

* fix(appimage): keep layer-shell typelib optional for direct builds
2026-09-29 14:24:31 -07:00
Jatin K MalikandGitHub eef2554f7c feat(audio): lower other audio while dictating (#861)
* feat(audio): lower other audio while dictating

Opt-in setting lowers the default PipeWire or PulseAudio sink while the
microphone is open, then restores that exact level. It stays off by default.

* fix(test): keep pytest.approx working when numpy is mocked

Other test modules replace sys.modules["numpy"] with a MagicMock at import
and leave it there. pytest.approx then crashes on isinstance(..., numpy.bool_).

* fix(audio): restore every channel and do not duck if it cannot be undone

A failed restore-point write no longer lowers the sink. PulseAudio puts
each channel back, and a dead microphone restores playback instead of
waiting for an explicit stop. The recognition test patches the real
audio module when an older test left a mock behind.

* fix(test): put the audio-feedback mock back after the ducking test

The recognition test has to patch the real module, but later tests still
expect the mock that test_text_injector installs. Restore that mock when
the test ends, and annotate the new helpers.

* fix(test): annotate the playback-duck tests

AGENTS.md requires a type hint on every new function. These tests were the
ones still missing them.

* fix(audio): skip arming playback duck after recording already failed

* chore: retrigger review after rebase
2026-09-29 14:24:28 -07:00
HashingandGitHub 12e440ce1c feat(text-injection): let config.json pin the injection backend (#649)
* feat(text-injection): let config.json pin the injection backend (#476)

Autodetection has to infer whether IBus commits actually reach the
focused application, and it cannot verify that: commit_text() reports
success even when the compositor drops the text. When the inference is
wrong the failure is silent, and dictation appears to work in XWayland
windows while doing nothing in native ones.

VOCALINUX_FORCE_BACKEND already provides an escape hatch, but it is an
environment variable: it covers a single run, is undocumented outside
the test suite, and a user whose compositor is misdetected has to wrap
the launcher in a shell script to make the choice stick.

Add a persistent `text_injection.backend` setting alongside it, accepting
the same ibus/wtype/ydotool/auto values. The environment variable still
wins so a backend can be A/B-tested for one run without touching the
saved configuration, and the startup log now names which of the two
pinned the choice so a stale setting is diagnosable from --debug output.

Reads the file directly rather than through ConfigManager, keeping this
package independent of the UI layer, exactly as _should_copy_to_clipboard
already does. An unreadable or corrupt config falls back to autodetection
instead of blocking startup, and an unrecognised value is ignored with a
warning so a typo cannot silently pin the wrong backend.

Defaults to "auto", so existing installs behave exactly as before.

* fix(text-injection): tell an explicit VOCALINUX_FORCE_BACKEND=auto from unset

The variable is meant to override the saved text_injection.backend pin for a
single run, which is what makes the two paths A/B-testable without editing
config. Setting it to auto -- the value that means "autodetect this time" --
did not do that: the reader collapsed unset and an explicit auto into the same
"auto" return, so the caller read both as "nothing was forced" and fell through
to the saved pin. The pin won, and there was no way to ask for autodetection
for one run short of editing the config file the variable exists to avoid.

Read the variable as three states instead: unset, an explicit auto, or a
backend name. Only the unset state consults config, so an explicit auto now
short-circuits to autodetection.

An unrecognised value is deliberately treated as unset rather than as auto. A
typo means the user failed to override their preference, not that they asked to
discard it, so falling through to a valid saved pin is the kinder reading.

_forced_backend() keeps its signature and its collapsed return, so its existing
callers and tests are untouched; the new three-state reading lives alongside it
and is what the preference resolver consumes.

* feat(text-injection): accept an xdotool pin and warn when ibus overrides the denylist

Two gaps in the pinnable backend set.

xdotool was not accepted, even though on X11 it is the tool that actually does
the typing. Users were told to pin a *Wayland* backend to mean "not IBus", and
get xdotool as a side effect. Naming it directly is what they were reaching for.
On Wayland it needs no special handling: the value skips IBus like any other
non-ibus pin and falls through to the existing XWayland fallback, so no branch
is added to the selection chain.

The accepted values now live in one tuple, because four places listed them --
both readers and both "expected ..." messages -- and a fifth value would have
had to be added to all four. The two messages derive from the tuple, so they
cannot fall behind it.

Pinning ibus also silently bypasses the compositor denylist. That check exists
because a denylisted compositor makes IBus report a commit as delivered while
the text never arrives (#478, #485), so a pin is the one setting that can put a
user back into a failure with no symptom other than nothing happening. The pin
is still honoured -- an override that ignores you is worse than one that warns
-- but it now says so at warning level, names the compositor, and gives the
remedy. It stays quiet when the compositor genuinely bridges IBus, including a
denylisted one running the ibus-wayland bridge, and on X11 where the denylist
does not apply.

The check is evaluated once. The guard chain short-circuits on the pin, so a
pinned run never reaches the existing compositor branch, and this is its first
and only call -- worth knowing because the check can shell out to pgrep or
gdbus. A test asserts the call count so that stays true.

Documents xdotool in the value table and rewrites the X11 paragraph, which
described xdotool as the implicit result of pinning something else rather than
as a value you can name.

* fix(text-injection): say so when the pinned backend is not the one in use

A pin could be quietly ignored. Pinning a tool that is not installed fell
through to autodetection and typed with whatever it found, under a log line
that had already announced the pin was overriding autodetection. Pinning
xdotool on Wayland did nothing at all, since the selection chain reaches
ydotool and wtype first. Pinning ibus where IBus support is unavailable skipped
the whole IBus branch. In each case the user set a preference, saw no
complaint, and got something else.

These are one condition, not three: whatever ended up doing the typing is not
what was pinned. The check compares the pin against the backend read back from
final state, so a cause nobody has thought of yet is covered without another
branch. It runs at the end of construction rather than inside
_check_dependencies, which returns early on two paths and which the wtype probe
can still override afterwards. The reason clause is added only where a generic
check answers it -- the binary is absent, or IBus is unavailable -- and omitted
otherwise rather than guessing.

Reporting the source separately was letting the environment variable and the
config setting disagree. _check_dependencies resolved the backend, then
re-derived which source had won by asking again -- so the environment was
parsed twice per construction, and a malformed value was reported to the user
twice. Resolution now returns the backend and its source together, which fixes
the duplicate warning and means the source named in a log line is the one that
actually chose the backend. _backend_preference() keeps its signature and reads
the first element.

Two log lines also claimed VOCALINUX_FORCE_BACKEND regardless of where the pin
came from, so a config-set backend was reported as an environment variable the
user had never set. They now name the real source.

The new warning is a startup diagnostic, not a live invariant: a tool switched
later by _try_recover_from_fallback is not reported again. Noted in the
docstring so the boundary is deliberate.

* docs(user-guide): file the injection-backend section under Troubleshooting

The section sat under Customization, directly beneath GPU Acceleration --
where the paragraph immediately above it reads "To check which backend is
being used" about Vulkan and CUDA. The word means something else there, and
two adjacent sections using it for different things is a bad place to explain
either.

It also is not a customization option. It exists because a compositor can make
IBus report a commit as delivered while nothing arrives (#478, #485), and
someone hitting that reads Troubleshooting, not Customization. The section
already opens on the symptom, so it needs no rewriting to belong there.

Pure reorder: no wording changes, and the heading text is unchanged so any
existing link still resolves.

* test(text-injection): cover the pin paths that skip the selection chain

_check_dependencies had three uncovered branches, all reachable by pinning a
backend and none of them exercised: the two early returns that hand over to
IBus before a tool is chosen, and the daemonless-ydotool fallback a wtype pin
lands on when wtype is absent. The early returns matter most, since the pin
check runs at the end of construction precisely so those paths cannot skip
it, and nothing proved that.

Adds the ydotool counterpart to the missing-binary case, so a check keyed on
one tool name cannot pass by coincidence; an X11 test for the guide's claim
that any non-ibus pin turns the IBus path off, with a control asserting IBus
is constructed without a pin so the assertion is the pin's doing; and an
end-to-end explicit-auto case, which until now was only proven at the
resolver rather than through selection.

The pin log line said it was "overriding backend autodetection" before
anything had been checked. An unavailable pin falls through to autodetection,
so that line announced an outcome it could not know and then read as
contradicting the "was not applied" warning. It now states the request only.

_run_with_pin gains ydotoold_ready, defaulting to the previous behaviour so
existing callers are unchanged.

* fix(text-injection): report a config file the backend pin cannot be read from

A corrupt config.json disappeared into logger.debug. The setting is
hand-edited -- that is the workflow the user guide documents -- so a stray
comma is a likely way to reach this, and staying quiet left the user with
autodetection and the silent IBus miss the pin was set to avoid, with nothing
to tell that apart from the pin not working. Unknown values already warned;
an unusable file now does too, naming the file and the parse error.

The bare handler was also catching AttributeError by accident. Valid JSON
that is not an object -- a bare list, string, number or null -- reaches the
lookup and raises there rather than at parse time, and so does a
text_injection section that is not an object, where the top level is a
mapping and the section is not. Both are now shape checks rather than
exception handling, which is what lets the handler narrow to OSError and
ValueError without those five shapes escaping it.

Locating the file moves out of the try. It fixes the handler being unable to
name the path it failed on, and it is a deliberate boundary: config_dir()
resolving a path is not a failure this should paper over, so it is no longer
guarded here at all.

A missing config.json stays silent, which is the default install.

* test(text-injection): stop the ibus-pin tests depending on an installed ydotoold

The bridged-compositor and X11 cases asserted that a run logs no warnings at
all. That is broader than what they are about, and _run_ibus_pin patches
shutil.which to claim every tool exists without patching the probe that then
shells out to one, so the real _ensure_ydotoold ran and warned whenever
/usr/bin/ydotoold was absent. The tests passed on a machine that happens to
have it installed and failed on the CI runners that do not.

The assertions now name the warning they are about, and the helper patches
_ensure_ydotoold the way its sibling _run_with_pin already did, so nothing in
this class of test reads the host's installed packages.

* fix(text-injection): report pending IBus initialization state

* docs(text-injection): clarify backend pin behavior

* fix(config): preserve a hand-edited backend across unrelated saves

ConfigManager writes its full in-memory configuration on every save. A user who edits text_injection.backend in config.json while Vocalinux is running therefore loses that choice when an unrelated Settings or tray save writes the stale default back.

Remember only the backend value observed at load or after a successful save. If the in-memory value is unchanged, preserve the current value from disk; if it changed through ConfigManager, treat that as the intentional write. Missing and explicit auto both reset to auto.

The save aborts rather than overwriting a file whose JSON or text_injection shape cannot be read safely. This is deliberately per-key: no recursive configuration merge or injection-layer dependency is added.

* style(text-injection): remove redundant typing suppression

* fix(config): keep invalid UTF-8 config from blocking startup

* fix(config): preserve malformed backend sections on unrelated saves

* fix(config): retain text setting changes after malformed config

* test(text-injection): annotate backend pin test helpers
2026-09-29 12:24:59 -07:00
be77ea40f4 fix(settings): keep the download dialog visibly working after the last byte (#864)
The transfer ending is not the dialog's work ending. The checksum still has
to run and the engine still has to load the file, seconds of it on a large
model, and until now that window showed a bar parked at 100% with the
downloader's "Complete!" under it and Cancel as the only button. It reads as
a finished download waiting to be confirmed, or as a hang, and the one
button on offer is the opposite of what the user wants to press.

- ModelDownloadDialog: a fraction of 1.0 no longer paints a finished-looking
  bar. It bounces the bar, names the step, says the download is done but the
  work is not, and greys out the Cancel button, which cannot interrupt
  verification or a model load anyway. Only set_complete() says "Complete!"
  and offers OK.
- whisper.cpp: report "Verifying model..." before hashing a file that can be
  hundreds of megabytes, the same step the Vosk path has always reported, and
  "Loading model..." before backend detection and the load. The tray's
  download notification reads the same statuses.
- tests: add return annotations on the new finishing-feedback helpers and
  tests (Greptile P2).

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 12:24:52 -07:00
38e5be70ba feat(parakeet): add optional verified Orukeet model (#840)
* feat(parakeet): add optional verified Orukeet model

* fix(parakeet): recover from invalid release manifests

* chore: mark Orukeet tip verified

Empty signed tip so the ready-bar Verified gate clears on contributor unsigned history.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 01:06:54 -07:00
7bdbedce0b feat(language): follow the active keyboard layout while dictating (#837)
* feat(language): follow the active keyboard layout while dictating

Adds a "Follow keyboard layout" switch to Settings -> Speech Model ->
Advanced. With it on, the active keyboard layout is re-read at the start
of every dictation and the matching language is used, so someone who
switches layouts to work in two languages gets the right one without
opening Settings.

#796 already seeded the language once on first run, but from the
*configured* primary layout, which never changes as the user switches
between them: a "fr,us" setup reports fr forever. detect_active_keyboard_
layout() reads GNOME's input sources instead. It takes mru-sources rather
than the current index, because that index goes stale -- observed still
reading 0 after a switch to the second layout -- which is why the IBus
injection path already trusts the MRU list (#497, #738).

An active source that is an IBus engine rather than an xkb layout is a
real answer that cannot be mapped, so the language is left to the engine's
own auto-detect. Borrowing the configured xkb layout there would pin
English for someone typing Japanese through mozc-jp.

The mode is a switch over the language picker rather than an entry inside
it, since it is a mode and not a language. While on, the picker is
disabled and shows what the layout resolves to; turning it off pins that.

A language change no longer re-initialises the engine when the loaded
model takes the language per utterance, which is the whole whisper
family, so following a layout costs a settings read rather than a model
reload. VOSK loads one model per language, Parakeet ignores the picker,
and an English-only model has no non-English weights, so Settings refuses
the mode for all three rather than offering a switch that does nothing.

Fixes #821

* fix(language): keep follow mode armed and map IBus engines

Swap English-only whisper.cpp weights to the multilingual sibling while
passing the layout sentinel into reconfigure, so follow mode stays armed.
Map common GNOME IBus engines onto catalogue languages instead of
leaving them unmapped.

* fix(language): stop IBus hyphen false-maps and gate the sibling swap

The last-token hyphen heuristic mapped mozc-us and foo-us to English.
Restrict it to *-jp/*-kr and name Mozc's extra engines as Japanese.
Only whisper.cpp may consult the whisper.cpp download catalog.

* fix(language): keep multilingual weights while following layout

The picker shows the active layout while follow is on, often en-us.
Deriving whisper.cpp .en from that display made Settings refuse the
mode as English-only, then the next auto-apply persisted a concrete
language and disarmed follow. Use LANGUAGE_FOLLOWS_LAYOUT for .en vs
multilingual derivation instead, including on the load path before
the switch is synced.

* fix(language): type follow helpers and log sibling-swap failures

* fix(test): stub follow-layout model rec so mocked psutil does not blow up

follow-mode English-only guard calls get_recommended_whispercpp_model, which
reads RAM through psutil. Full CI leaves a MagicMock in sys.modules["psutil"],
so ram_gb >= 16 raises TypeError. Patch the settings_dialog alias to return a
multilingual tiny rec so the assertion still holds without real RAM.

* fix(language): catch specific errors on layout lookup and sibling swap

* fix(language): swallow more sibling-swap reconfigure failures

ImportError, TypeError, AttributeError, and ChecksumError from the
multilingual swap must not abort dictation or disarm follow mode.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 01:06:32 -07:00
Jatin K MalikandGitHub 9646011e77 fix(settings): speech model follow-ups from QA (#836)
* fix(settings): speech model follow-ups from #834

Keep the Unused downloads expander in sync with search, recommend Faster
Whisper .en weights for English, parse nvidia-smi MiB VRAM, stop simple
mode from clobbering Faster Whisper/Parakeet, and ceil RAM so ~7.4 GiB
counts as 8.

* fix(settings): keep FW .en picker ids and test unused search

Represent Faster Whisper English catalog variants in the Settings size
combo so tray-saved small.en is not rejected and overwritten. Add
behavioral unused-downloads search coverage for match/nonmatch/clear.
2026-09-29 01:06:08 -07:00
Jatin K MalikandGitHub 06f049ac76 fix(shortcuts): record unmapped F19/F24 and XF86-aliased F13–F23 (#844)
* fix(shortcuts): record unmapped F19/F24 and XF86-aliased F13–F23

* fix(shortcuts): keep split-keyboard modifiers while recording

The recorder only looked at the keyboard that sent the function key, so a modifier held on the other half was dropped. A dead device also stayed in the watch list, which left F19/F24 uncapturable until Record was restarted.

Union held keys across every open keyboard before committing, and drop a device on hangup or read error while scanning for a replacement.

* fix(shortcuts): keep capture tests off real input devices

CI opened real /dev/input paths because the harness patched a stale evdev module, so io watches never attached. Patch the live module, and move the capture device protocol to common_types.

* fix(tests): patch the recorder's own GLib globals

test_settings_dialog reimports the dialog module and leaves the replacement in sys.modules. Recorder methods still close over the original dict, so the harness never recorded timeouts or io watches.
2026-09-29 01:05:46 -07:00
a38cff59ea fix(vosk): refuse to load a model that would exceed available memory (#850)
* fix(vosk): refuse to load a model that would exceed available memory

vosk.Model() allocates the whole model in one spike during loading,
including the RNNLM rescoring component some models ship, with no way
to check the requirement up front or load incrementally. Walk the
model directory, compare its on-disk size against psutil's available
memory with a 1.5x margin, and raise before calling into the native
loader instead of letting the kernel OOM killer take the process down
mid-load.

Fixes #676

* fix(vosk): match memory safety margin to the documented peak

greptile flagged the guard's own comment: loading can spike to ~2x the
model's on-disk size, but the margin only required 1.5x available memory,
so a load between those two multiples could still trigger the OOM this
guard exists to prevent.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 01:05:24 -07:00
Jatin K MalikandGitHub 74d6f505d4 fix(update): fall back when GitHub API rate-limits (#846)
Shared NAT hits the unauthenticated api.github.com quota and the
update check logged a WARNING that looked like a failure. On 403
or 429, look up the latest stable tag from the releases page
JSON, and stay quiet if that also fails. Nightly still refuses
to treat the stable latest tag as a nightly.

Fixes #845
2026-09-29 01:05:02 -07:00
Kanishk PachauriandGitHub f4e86f6325 fix(deps): clear open dependency advisories (#869) 2026-09-24 04:42:08 +05:30
Jatin K MalikandGitHub 5b4e2c3572 fix(snap): add hardware-observe so hotkeys can read input devices (#858)
Snap confinement denies /proc/bus/input/devices unless hardware-observe
is connected. Declare the plug, tell users to connect it next to
raw-input, and update the in-app hints.

Fixes #857
2026-09-23 00:09:54 -07:00
Jatin K MalikandGitHub 4df612dd7a fix(settings): drop Test Dictation scrolled-window stripe (#853)
* fix(settings): drop Test Dictation scrolled-window stripe

GTK3 ScrolledWindow defaults to an IN etched shadow, which GNOME/Wayland
renders as a thin vertical stripe on the left of the Test Dictation
revealer even when the buffer is empty. Call set_shadow_type(NONE) to
match the other ScrolledWindows in this dialog.

Fixes #847

* Add missing test return annotation
2026-09-23 00:09:43 -07:00
Kanishk PachauriandGitHub 33492d7cb1 docs: mark VocaMac as available now (v1.0.0) (#859) 2026-09-23 06:04:31 +05:30
Amir FathiandGitHub c7b8cd4802 fix(ibus): stop the xkb:us::eng guard flipping GNOME/X11 to a US layout (#827)
The gsettings fallback for the bogus xkb:us::eng default only fired on
GNOME/Wayland; on GNOME/X11 the default is accepted and written back
into the X keymap, permanently switching the user off their real
layout (Hungarian, Brazilian ABNT2, etc).

_get_gnome_current_source() also had no fallback when mru-sources is
empty (a fresh session with a single input source never populates the
MRU list), so even with the guard fixed the recovery path still
returned None. It now falls back to the static sources list.

Fixes #699
2026-09-20 11:18:08 -07:00
Jatin K MalikandGitHub 26f90b6998 chore(release): prepare v0.17.0
Minor on the stable line. Faster Whisper and Parakeet extras, Speech Model simple setup, Snap uinput, Flatpak bundles on the tag, and QA fixes from the release branch.

Fixes tracked in the PR. Post-release follow-ups: #834.
2026-09-16 00:18:09 -07:00
Jatin K MalikandGitHub 092dde105e fix(audio): open 3-8ch HDA mics at native layout (#813) (#829)
* fix(audio): open 3-8ch HDA mics at native layout (#813)

HDA analog capture often reports 4 channels. Opening below that native
count can abort PortAudio after read with free(): corrupted unsorted
chunks (same class of bug as #666/#673).

Try the reported channel count first when it is between 3 and 8, then
2ch and 1ch. Pulse devices that advertise 32/128 channels still open
stereo-first. Downmix N channels to mono so engines keep seeing 1ch.

Virtual-device filtering for pipewire/default (#629) is unchanged.

* fix(audio): downmix HDA capture from first channel pair

Averaging all N HDA channels halves speech when only the front pair is live.
Keep stereo mean-of-both, and for 3+ channels average channels 0 and 1.

* fix(audio): pick loudest channel for N-ch downmix

First-pair-only downmix silenced mics on ch2+. For 3+ channels, select
the loudest channel by per-buffer mean-square so speech stays at full
level whether it lands on the front or rear pair. Stereo still averages
both channels.

* fix(audio): sticky N-ch downmix channel for open stream

Pin the loudest-channel pick for the life of a capture stream so a
noise burst on another input cannot switch the mic mid-utterance.

* fix(audio): gate sticky downmix lock on speech energy

Don't pin the N-ch sticky channel from a silent/ambient first buffer;
only lock once the loudest channel clears a documented mean-square floor.

* style: isort sticky-lock import in device config tests
2026-09-14 00:21:17 -07:00
Jatin K MalikandGitHub 5e8a08fbb8 fix(ui): follow OS dark/light on native GTK installs (#830)
* fix(ui): follow OS dark/light on native GTK installs

AppImage already maps portal/gsettings color-scheme into GTK_THEME.
AUR and install.sh use system GTK3, which ignores prefer-dark. Set
gtk-application-prefer-dark-theme from the same signals, and skip when
GTK_THEME is already set so AppImage and user overrides stay in charge.

Fixes #816

* docs: portal no-preference still checks gsettings

The helper already falls back after a portal 0 or a failed Read.
The module doc said that left GTK defaults, which is only true
after gsettings also comes up empty.
2026-09-14 00:16:50 -07:00
d5656b8060 fix(text-injection): route XWayland xdotool fallback through clipboard paste (#680)
* fix(text-injection): route XWayland xdotool fallback through clipboard paste

xdotool type simulates keypresses against the active X keyboard layout, so
a character the layout doesn't map comes out as the wrong glyph or garbled
(#657). The native ydotool path already avoids this by pasting via the
clipboard instead of typing (_inject_with_wayland_tool); the XWayland
xdotool fallback (_inject_with_xdotool) never got the same treatment.

Prefer clipboard paste on that fallback too when ydotool is installed,
falling back to raw xdotool type only when no paste tool is available or
the paste itself fails.

Fixes #657

* fix(text-injection): X11 clipboard paste for the XWayland fallback

jatinkrmalik's review on #680 pointed out the AppImage build does not
bundle ydotool, so the prior fix's ydotool clipboard paste never ran on
the actual reported setup; it fell through to the same layout-dependent
xdotool type as before. It also flagged a real paste-target mismatch:
an XWayland window reads the X11 CLIPBOARD selection, not the Wayland
clipboard wl-copy/ydotool write to.

The XWayland fallback now copies via xclip/xsel and pastes with
xdotool key --clearmodifiers ctrl+v when either is installed, waiting
for held modifiers to release first (the same wait already used by the
native ydotool path). ydotool clipboard paste stays as a second
fallback when xclip/xsel are absent; xdotool type is the last resort.

New regression test drives inject_text() end-to-end with no ydotool on
PATH, reproducing the reported AppImage/GNOME log rather than calling
the fallback method directly, so it also covers the recovery check
that could otherwise skip this path.

* fix(text-injection): thread x11_only through clipboard read/clear so restore matches the paste backend

jatinkrmalik's rebase pulled in the layout-aware paste work from #788,
and greptile's review on the merged branch caught a real gap in this
PR's own XWayland path: _copy_to_clipboard already takes x11_only to
write the X11 CLIPBOARD via xclip/xsel (since that is what an xdotool
paste actually reads), but _read_clipboard and _clear_clipboard never
gained the same parameter. On a Wayland host, _read_clipboard prefers
wl-paste, so the "previous clipboard" saved before an XWayland paste
could be read from an unrelated Wayland clipboard, and restoring it
back through _copy_to_clipboard with no x11_only wrote to that same
wrong backend, leaving the X11 CLIPBOARD stuck on the dictated text.

Threads x11_only through both helpers and every restore call site
(the immediate-failure path and the delayed _restore closure), so an
XWayland paste captures, clears, and restores the same X11 CLIPBOARD
selection it wrote to.

* fix(text-injection): annotate clipboard helpers and XWayland paste tests

Greptile P2 on #680: give `_get_clipboard_tools` a return annotation and
mark the new XWayland clipboard-paste regression tests `-> None`.

* fix(text-injection): derive XWayland paste from environment

Drop the ydotool/wl-copy second fallback on WAYLAND_XDOTOOL (wrong
clipboard, and rc=0 is not a paste) and the x11_only flag stack. Paste
picks xclip/xsel + xdotool from environment; user-facing copy stays
on the session clipboard.

* fix(text-injection): always pass host_env() to the paste chord

The AST guard only recognizes a direct host_env() call. The ternary
around it looked like we were handing the AppImage library path to
xdotool.

* test(text-injection): give XWayland injector stubs clipboard restore state

_inject_with_xdotool now pastes first, and that path reads
_clipboard_restore_target. The __new__ helper in test_text_injector_ext
never set it.

* fix(text-injection): skip clipboard restore on a stale paste failure

If a newer paste has already copied its text, the older paste's failure
handler must not write its saved clipboard back. Success restores already
honored the generation counter; the failure path did not.

* fix(text-injection): keep original clipboard across a stale paste failure

Publish the restore target as soon as copy succeeds (first writer wins)
so an overlapping paste inherits it. If the older paste then fails, the
newer restore still writes the pre-first clipboard, not intermediate
dictated text.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-12 13:16:10 -07:00
Jatin K MalikandGitHub 4b6e81d441 fix(snap): type into native Wayland apps with ydotool (#823)
* fix(snap): inject into native Wayland apps via ydotool

Edge rev 7 only ships wtype and xdotool. GNOME rejects the virtual
keyboard protocol, so the snap falls back to xdotool and types only
into XWayland windows. Native GTK/Qt apps get nothing.

Stage ydotool, request the uinput plug, and prefer ydotool when wtype
is rejected. Distro ydotool 0.1.x still needs a writable /dev/uinput;
log `snap connect vocalinux:uinput` when the Snap cannot open it.

* docs(snap): v0.16.2 edge has no uinput plug

The store snap (0.16.2, latest/edge rev 7) does not declare uinput.
`sudo snap connect vocalinux:uinput` fails with "has no plug named
uinput" and dictation only reaches XWayland apps. Spell that out in
README, INSTALL, UPDATE, the website changelog, and the store copy so
people do not run the 0.17 connect command against rev 7.

* docs(snap): drop the unconfirmed 0.17 version from uinput notes

Greptile P2: the tree is still 0.16.2 and the release process does not
name the next tag. Tell people to refresh until `snap info` lists the
uinput plug, instead of waiting on a version we have not cut.
2026-09-12 12:58:35 -07:00
4d8c58f536 feat(shortcuts): allow bare function keys for push-to-talk (#815)
* feat(shortcuts): allow bare function keys for push-to-talk

Accept F1–F24 shortcuts without a modifier so dictation can use a
dedicated key (e.g. F10) instead of Shift/Ctrl/Alt combos that fire
while typing. Settings shortcut capture records bare function keys too.

* fix(shortcuts): evdev availability for bare function keys

Check EV_KEY capabilities for bare F1-F24 shortcuts instead of an empty
modifier name. Add availability regression tests and type hints on new tests.

* refactor(shortcuts): fold bare F-keys into ShortcutSpec

Teach primary_modifier to return the bare key so parse_shortcut and the
backend stop special-casing empty modifiers. Capture now asks the parser
instead of re-encoding the F-key allow-list, and evdev shares one
capability probe for modifiers and main keys.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-12 12:39:25 -07:00
Jatin K MalikandGitHub 94c2eaac08 fix(tray): make PTT tray icon turn red on every hold (#809)
* fix(tray): refresh PTT icon when AppIndicator reuses a path

Right-Alt push-to-talk turned the tray red only on the first hold.
Later holds dictated fine but left the icon gray until restart.

StatusNotifier hosts often skip a redraw when IconName returns to an
absolute path already used in the session. Nudge IconThemePath before
set_icon_full so gray-red-gray-red actually turns red again.

* test(tray): assert alternating IconThemePath nudge args

Greptile P2: the PTT redraw regression only checked set_icon_theme_path
call count, so identical paths would still pass. Assert the alternating
ICON_DIR / ICON_DIR+os.sep arguments that drive the host reload.
2026-09-08 20:56:18 -07:00
ab1400b7c1 fix(injection): read WM_CLASS with xprop so xdotool stops dumping core (#807)
* fix(injection): read WM_CLASS with xprop so xdotool stops dumping core

`xdotool getwindowclassname` hands an uninitialized XClassHint to XFree
when the window carries no WM_CLASS, so it aborts with SIGABRT
("free(): invalid size") and leaves a coredump behind. On KDE Plasma
Wayland that is the normal case, not an edge case: while a native
Wayland client holds focus, KWin points XWayland's _NET_ACTIVE_WINDOW at
a property-less window, and `xdotool getactivewindow` reports it happily.

Every paste-shortcut probe therefore killed a child process and filled
the journal with coredumps. Injection still worked, because the abort
only made the probe return an empty class, so the symptom was silent
except in `coredumpctl`.

read_wm_class() now reads the property with xprop, which handles a
missing WM_CLASS as an ordinary empty result, and falls back to xdotool
only when xprop is absent and the window looks like a real client (it has
a pid or a title). The debug window-info logging in text_injector.py goes
through the same helper.

Reproduced on Fedora 44, Plasma 6 Wayland, xdotool 3.20211022.1:
`xdotool getwindowclassname 0x200000` exits 134 on the window that
`_NET_ACTIVE_WINDOW` points at.

* fix(injection): keep xdotool classname for class-only X11 clients

When xprop is missing, always allow the xdotool classname fallback so a
real terminal that exposes WM_CLASS but no title or _NET_WM_PID still
classifies as a terminal (Ctrl+Shift+V). xprop remains the primary path,
so Plasma Wayland with xprop never calls getwindowclassname.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-08 20:52:34 -07:00
Jatin K MalikandGitHub cdfe541c25 feat(speech_recognition): add faster-whisper engine backend (#543)
* feat(speech_recognition): add EngineType enum and Engine Protocol

Introduce a typed EngineType enum and an Engine Protocol in common_types.py,
plus an engines/ registry so new speech recognition backends can be plugged in
without scattering string comparisons across the manager.

* feat(speech_recognition): add faster-whisper engine backend

Add a faster-whisper engine implementation using CTranslate2/INT8 for fast
CPU inference. Includes model metadata utility, wiring through the recognition
manager, CLI, settings UI, config, optional dependency, and unit tests. Also
updates the suspend-handler test fixture for the new engine attribute.

* style(ui): format faster-whisper imports for isort 8

* test(speech_recognition): expand faster-whisper coverage for codecov

* test(speech_recognition): fix failing faster-whisper coverage tests

- Import missing get_compute_type helper

- Add tests for English-only detection, cache exceptions, and import-error registry path

- Clear _has_torch_cuda lru_cache around exception test to avoid cached True

- Replace broad builtins.__import__ patch with a fake engine module for registry test

- Restore registry module state after test

* test(speech_recognition): add coverage for faster-whisper edge cases

Cover previously missing lines in:

- faster_whisper_model_info.py: HF cache path/none, hf_hub lookup, fallback traversal, torch CUDA true, psutil exception

- faster_whisper_engine.py: model None guard after is_ready()

- engine registry import-error path

* feat(install): add faster_whisper engine option to install.sh

- Add faster_whisper to --engine help text and examples

- Add faster-whisper as interactive option 5 with description

- Install faster-whisper package and verify import at install time

- Add fallback to whisper.cpp if faster-whisper installation fails

- Add smoke test for faster-whisper import

- Update welcome message engine display name

- Note: faster-whisper models download automatically on first use

* refactor(faster_whisper): apply ponytail review cleanups

- Inline FASTER_WHISPER_MODEL_INFO dict literal; drop unused AVAILABLE_MODELS/MODEL_SIZES

- Reuse _has_torch_cuda() in FasterWhisperEngine.device property

- Remove redundant model None guard after is_ready() verification

- Shrink faster-whisper default config in install.sh to engine + model_size

- Update device tests to manage _has_torch_cuda lru_cache

- Remove test for deleted dead-code guard

* fix(faster_whisper): integrate optional engine with current main

Keep whisper_cpp as default and Parakeet as an extra. Put engine selection
in the Advanced island, install faster-whisper via the optional extra, and
refuse unpinned Hugging Face downloads until model_checksums.txt pins them.

* fix(faster_whisper): download verified models and refresh uv.lock

Settings already opened a download dialog for uncached faster-whisper, but
init only raised ChecksumError. Wire a Parakeet-style downloader that stores
bundles under models_dir, verifies each file against model_checksums.txt, and
loads WhisperModel from that path. Regenerate uv.lock for the optional
faster-whisper extra, pin digests for all eleven Systran models, and add the
missing -> None return annotations.

* fix(faster_whisper): clear engine on unload and fix auto-pause fixture

Auto-pause tests stub __init__, so they never set _faster_whisper_engine.
reinitialize_after_resume then AttributeErrors once Test Python runs.
Mirror the suspend-handler fixture, and release the faster-whisper engine
inside unload_model the same way resume already does.

* fix(faster_whisper): restore previous engine after a failed switch

reconfigure() selected the new engine and released the live model before
init. A failed faster-whisper download then left the manager in ERROR on
the new backend while Settings reverted the pickers to the saved engine,
so dictation stayed dead until restart. Reload the previous engine from
disk (no download) before re-raising so the UI and live manager agree.

* fix(speech_recognition): restore all live settings after failed switch

Failed reconfigure already put the previous engine back, but VAD, device,
remote API, and whisper.cpp knobs stayed on the attempted values while
config on disk did not. Snapshot every field reconfigure writes and restore
the lot before reloading the old backend. Also bind the real
_init_selected_engine onto the MagicMock in the force_reinit test.

* fix(speech_recognition): restore every live field after failed reconfigure

A failed engine switch already reloaded the previous backend, but Greptile
could still miss that VAD, audio device, remote API, and whisper.cpp knobs
were part of the snapshot. Keep one explicit attr list for snapshot/restore,
including the derived voice-commands flag, so live state matches persisted
config after a failed Settings apply.

* chore(speech_recognition): sign tip after rollback follow-up
2026-09-07 20:47:56 -07:00
7664266823 feat(commands): localize punctuation voice commands (#640) (#642)
* feat(commands): localize punctuation voice commands by language

Voice command phrases were English-only even when recognition language
was set to Italian, French, etc. Merge locale punctuation/line-break
aliases from the recognition language into CommandProcessor, keep
English phrases available, and document the behavior.

Fixes #640

Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>

* fix(commands): keep line breaks and French apostrophe aliases

Attach-left punctuation now eats only horizontal whitespace, so a
localized "new line" followed by a period or comma still inserts a
newline. French aliases include ASCII, U+2019, and backtick apostrophes,
and longest-match still wins so "point" cannot fire inside
"point d'interrogation".

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
2026-09-07 20:47:12 -07:00
Jatin K MalikandGitHub f90e4d5765 fix(injection): release ydotool Ctrl after a timed-out paste (#675)
subprocess.run(..., timeout=3) SIGKILLs the ydotool client. ydotoold
keeps any keys it already applied, so virtual Left Ctrl can stay held
until logout. Send a best-effort V-up/Ctrl-up (or a legacy ctrl tap)
after paste failure.

Fixes #658
2026-09-07 20:47:07 -07:00
Jatin K MalikandGitHub 2c6acbc15e fix(inject): layout-aware clipboard paste for Neo and friends (#788)
* fix(inject): resolve paste chord for non-QWERTY layouts

ydotool was sending physical KEY_V (47) for Ctrl+V. On German Neo that
key types p, so paste opened Print instead. Prefer wtype keysyms when
usable; otherwise resolve the evdev key for Latin v via xkbcommon, and
read KDE kxkbrc so Plasma Wayland Neo users are covered.

Fixes #787

* fix(inject): read Plasma active layout from real session sources

kxkbrc has no CurrentLayout/LayoutIndex, so multi-layout Neo always
fell back to us at index 0 and paste still hit KEY_V. Prefer D-Bus
getLayout or layout_memory.xml for the active layout, keep kxkbrc
for the list only, and prefer KDE over leftover GNOME on Plasma.
2026-09-07 18:10:43 -07:00
Jatin K MalikandGitHub d07c70dbc9 fix(audio): stop sink-wake from clipping start/stop cues (#804)
* fix(audio): prepend silent preroll so sink-wake does not clip cues

Short catalog tones start in a fresh paplay process while PipeWire is
still waking a suspended sink, so the first tens of milliseconds of the
cue are missing. Prepend 100ms of silence in the same WAV, cache the
result by source path, mtime, and preroll length, and fall back to the
original file if the preroll cannot be built. CI mock players keep the
raw path.

Fixes #800

* fix(audio): cache sink-wake preroll without tempfile.mkstemp

Some tests replace sys.modules["tempfile"] with a MagicMock, so
mkstemp unpacking fails and playback falls back to the raw cue.
Name the cache file from a short sha256 of the source path, mtime,
and preroll length, write a sibling .partial, then os.replace it
into place. Reload real tempfile and wave in the audio feedback
tests so they still pass after those mocks leak.

Fixes #800

* fix(tests): pin real wave for preroll without reloading tempfile

Popping tempfile out of sys.modules in the audio feedback fixture
made preroll tests pass, but later whisper tests then bound
TemporaryDirectory from a MagicMock. Import real tempfile and wave
at module top, pin only wave while audio_feedback is reimported,
and bind TemporaryDirectory from the real stdlib if a sibling test
already replaced tempfile.

Fixes #800

* fix(audio): catch specific preroll errors and type test helpers

The new preroll path swallowed every exception, which hid bugs and
disagreed with AGENTS.md. Catch OSError, ValueError, and wave.Error
on the inner partial cleanup and the outer fallback so a bad WAV or
cache write still falls back to the original cue, and other errors
still propagate. Annotate the two test helpers the same review
flagged.

* fix(audio): fall back when truncated cues raise EOFError

wave.open can raise EOFError on a truncated RIFF before playback.
Catch it with the other preroll failures so start/stop still play the
original file.

Fixes #800
2026-09-07 18:10:38 -07:00
Jatin K MalikandGitHub dcd8d06077 fix(asr): keep Parakeet recognition alive after decode errors (#803)
* fix(asr): keep Parakeet recognition alive after decode errors

sherpa-onnx create/decode exceptions were escaping the recognition
thread. Catch them like whisper.cpp, annotate the Parakeet helpers, and
cover create_stream failure.

* fix(asr): narrow Parakeet transcription exception handling

Keep create_stream/decode survival, but stop swallowing unrelated
bugs. Same RuntimeError/OSError/ValueError tuple as Whisper.
2026-09-07 18:10:33 -07:00
affbf6a9af feat(settings): simple setup for the speech model, with Advanced as an island (#801)
* feat(settings): make the language picker searchable while its list is open

Entry completion only ever covered typing into the closed entry. Open the
dropdown and the keyboard goes to GTK's own first-letter jump, so nothing typed
reaches a filter, and with thirty-odd languages scrolling is the only way
through. The first attempt at fixing this (a filtered ComboBox model) had the
same limitation for the same reason.

Replace the combo with a picker that opens a popover holding a search entry,
focused on open, above the list. Every keystroke narrows the rows, Enter takes
the first match, a click takes that row. It keeps the GtkComboBoxText methods
the dialog calls, mirrors the store's column order in base_model for the shared
row helpers, and emits "changed" only when the selection actually changes, so
re-selecting the saved language on open is not taken for a user edit.

Visibility uses the matcher the typed-text resolver already uses, so the list
and Enter cannot disagree. The completion helper and the combo styling skip
the picker: it filters its own list, and the ComboBox-only calls do not exist
on a Box.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT

* feat(settings): add a simple mode that derives the model from the language

The Speech Model panel asked four questions, but only two of them can be
answered without knowing the hardware: what language you speak and whether you
want it faster or more accurate. Size follows from the machine and the
specialization follows from the language, and the app already computed both —
it just printed the answer beside the pickers instead of using it.

Simple mode asks for the main language, whether whole texts are also dictated
in other languages, and a priority. There is deliberately no second language
field: whisper takes one language or none, so a pair cannot be passed, and with
a language pinned the multilingual weights already handle inserted foreign
words. The switch is the "none" case.

Priority moves relative to what hardware detection recommends rather than
naming an absolute size, so "most accurate" means something different on a
laptop than on a workstation. Opening simple mode reads the priority back from
the size already configured, so switching modes never changes the loaded model
on its own.

Simple mode steers the existing controls rather than writing the configuration
itself, so applying, downloading and the info card keep going through one path.
Advanced mode is the previous panel, unchanged.

Refs #779

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT

* fix(settings): stop simple mode reloading the engine four times per pick

Reported against the installed build: choosing a language locked the window
for a moment. Simple mode steers the engine, language, size and variant
controls, and every one of those change handlers ends in _auto_apply_settings,
so a single pick reconfigured the speech engine up to four times on the UI
thread, reloading the model each time. Hold a guard across the steering and
apply exactly once when it is done.

The simple language box was also a plain list with no entry, so it could not be
typed into — a step backwards from the advanced row, and unusable with over
thirty languages. It now uses the same searchable combo, with the same commit
and restore behaviour for text that resolves to nothing.

Refs #779

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT

* refactor(settings): make simple the panel and Advanced a reveal

The Simple/Advanced dropdown framed the two as alternatives, which put a mode
decision in front of the user before any of the questions they can answer. They
are not alternatives: simple is the panel, and advanced is more detail about
the same choice.

The mode selector is gone. The simple questions are always on screen, with an
Advanced switch that slides the engine, size and specialization rows out
underneath. Because simple mode steers those rows, they also work as a readout
of what the simple answers resolved to, which the old either/or hid.

The stored settings_mode becomes a show_advanced flag, remembering whether the
detail was left open.

Refs #779

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT

* refactor(settings): second language list, and Advanced in its own window

Reworked to what the panel is actually for. The main language comes from the
keyboard layout. Turning on "I also dictate whole texts in other languages"
reveals a second list rather than silently flipping to detection, so the choice
is visible. Advanced is a button that opens the detailed controls in their own
window, instead of an inline reveal that pushed the page around.

Naming a second language resolves to automatic detection, because whisper takes
one language or none. The one case where both can still be honoured is two
English entries — en-US plus en-IN — where English stays pinned and the
English-only weights, same size and better at English, remain available.

Also fixes a name collision the reveal had: advanced_revealer already belonged
to the power-user toggle on the Advanced page, and the section built later won
the attribute.

Refs #779

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT

* feat(settings): use the filtering picker for both simple language lists

The main language and the second language are the same thirty-odd-row lists as
the advanced one, so they get the same dropdown filtering rather than a
completion popup that only reacts while the entry has focus.

Refs #779

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT

* fix(settings): make simple mode do what it says on screen

Four things reported from the installed build, all real.

The second language list never appeared: the row is flagged no_show_all, and
show_all() is a no-op on such a widget, so the flag has to be cleared first —
the dance _set_custom_shortcut_row_visible already does.

There was no multilingual answer. "Any language (auto-detect)" is now the first
entry of the second list and the default when the switch is turned on, so the
switch alone visibly does something.

Changing the priority showed nothing, because the info card — the only surface
that says which model was picked and what it costs — had been moved into the
advanced window. It is back on the page under the simple questions.

Opening Advanced crashed the compositor. KWin 6.7.4 (Wayland) died with
unbounded recursion in KWin::Window::findModal(), a cycle in its transient-for
chain, when the advanced window — a long-lived toplevel transient for this
dialog, hidden on close and shown again with present() — was opened. Coredumps
at 2026-09-06 20:20:10 and 20:39:49; the crashing state is kept as tag
kwin-crash-repro. The window now stands on its own, is built on every open and
destroyed on close with the controls re-parented out first. KDE Bug 483265.

Refs #779

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT

* fix(settings): bring the advanced window up in front of the dialog

Reported: it opened behind Settings. Dropping the transient parent (which
crashed KWin) also dropped the only reason the compositor had to stack it over
the dialog, and on Wayland a toplevel mapped without an activation token lands
behind the focused window. Present it with the timestamp of the click that
opened it: that asks the compositor for focus and stacking, and needs no
transient link.

Refs #779

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT

* refactor(settings): Advanced as an island under the simple card, not a window

A second toplevel went wrong twice on KWin 6.7 / Wayland. Transient for the
dialog it crashed the compositor (unbounded recursion in
KWin::Window::findModal, tag kwin-crash-repro, KDE Bug 483265). Standing on its
own it could not be raised above the dialog at all, not even by hand.

Advanced is now its own card under the simple questions: collapsed to a header
by default, expanding in place, remembered in show_advanced. Both cards are on
screen at once, so the expanded rows double as a readout of what the simple
answers resolved to, and a change made in the advanced rows is read back into
the simple ones — skipped while simple mode is itself steering them.

The info card sits between the two, where a priority or language change shows
its effect and its cost.

Refs #779

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT

* fix(settings): let a same-size weight on disk stand in before downloading

Reported: turning the "I also dictate whole texts in other languages" switch
off went from base to base.en — same size, different weights — and sat the user
through a modal 141 MB download for it, with the window blocked behind the
progress dialog. Turning it on had done the same in the other direction.

Simple mode now looks at the disk before settling on a variant. A downloaded
weight of the same size that can serve the language stands in: English-only
first when English is wanted, the plain multilingual next, quantized ones last.
English-only weights never stand in for another language or for detection.
When nothing of that size is on disk the derived variant stays and the
download runs as before. The info card still offers the better variant to
download; it just no longer forces it.

Refs #779

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT

* fix(settings): keep Advanced language pin over stale simple multi

_sync_simple_from_advanced restored the other-languages switch and
persisted simple_second_language even when Advanced had a pinned
language. The next simple edit then decoded to auto and silently
replaced the pin. Clear/resync simple multi on a pin, refresh that
readout when Advanced language changes, and drop the second-list
Enter hook that double-applied through SearchablePicker.

Refs #779

* fix(settings): keep two-English simple multi across Advanced pin sync

Clearing every leftover second language on a pinned Advanced readout
was too broad: en-US plus en-IN still pins English, and wiping that
answer forgot the second English choice on the next open. Restore
multi only when the stored second would keep the pin; otherwise drop
it so a later simple edit cannot decode to auto.

Refs #779

* fix(settings): restore simple multi only when it matches live language

The pin-side keep left the auto branch restoring any stored second.
Two English over Advanced auto still decodes to en-us, so the next
simple edit silently replaced detection. Keep leftover multi only when
it still decodes to the live Advanced language either way.

Refs #779

* fix(settings): keep language-change tests honest after the main merge

Mock dialogs treated _initializing as truthy and skipped the handler.
Also call _refresh_simple_readout explicitly so the simple card updates.

* fix(settings): type the language-entry GTK callbacks

Greptile flagged missing param and return annotations on the new
activate and focus-out handlers. Match the surrounding Any/bool style.

Refs #779

* fix(settings): sort imports for isort after Parakeet merge

---------

Co-authored-by: Kacper Paczos <23406555+kacperpaczos@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 14:38:30 -07:00
8f4aeca2f1 feat(asr): add Parakeet TDT 0.6B engine via sherpa-onnx (#802)
* feat(asr): add Parakeet engine via sherpa-onnx

Adds a "parakeet" engine backed by sherpa-onnx, running NVIDIA Parakeet
TDT 0.6B int8 models on CPU: v3 (25 European languages, auto-detected)
and v2 (English). Output is punctuated and capitalized.

On an Intel Core Ultra 7 155H it decodes at about 12x realtime on CPU
(RTF 0.08), roughly 8x faster than whisper.cpp large-v3-turbo over the
same clips with its Vulkan GPU backend (RTF 0.59). Being a transducer it
also has no 30s window to pad, so a short push-to-talk utterance returns
in ~0.3s instead of ~2.4s.

Follows the existing engine layout: utils/parakeet_model_info.py holds
the catalog and the get_model_path / is_model_downloaded /
list_downloaded_models / delete_model helpers, while _init_parakeet /
_transcribe_with_parakeet / _download_parakeet_model follow the same shape as their
whisper.cpp counterparts and reuse _stream_model_download for progress
and cancellation.

Registered in the engine dispatch, the settings engine/model/language
pickers, the model info card, the download prompts, the unused-downloads
list and install.sh.

Models download to <models_dir>/parakeet/<model>/ via temp files renamed
on success; is_model_downloaded requires all four files, so an
interrupted download is retried rather than loaded.

Requires the new "parakeet" extra (sherpa-onnx).

* fix(install): write only the engine key for parakeet

The parakeet branch duplicated a full default config.json. ConfigManager
deep-merges DEFAULT_CONFIG on load, so every other key was redundant:
a config holding only "engine": "parakeet" still resolves
parakeet_model_size to v3-european along with the VAD, shortcut and
text-injection defaults.

* fix(install): keep push-to-talk defaults and switch existing configs

Writing only the engine key left config.json without a shortcuts
section, which _migrate_shortcuts_config reads as a pre-push-to-talk
upgrade and pins back to ctrl+ctrl/toggle - contradicting the
installer's own "Hold Right Alt while you speak" message. Seed that
section too.

Point the config at parakeet with set_configured_engine rather than only
on first create, so --engine=parakeet also switches a machine that
already has a config.json, and give print_welcome_message a parakeet arm
so the summary is not blank.

The interactive picker overwrites SELECTED_ENGINE, so document the flag
combination that reaches this branch.

* fix(asr): pin Parakeet bundles and report one progress bar

Pin each bundle to a Hugging Face commit instead of resolve/main, so a
file replaced upstream cannot change what an install downloads. This is
the same approach whispercpp_model_info takes with its pinned revision.

Remap each file's progress into its slice of the bundle. The four files
were reported through the single-file downloader independently, so the
fraction ran to 1.0 and restarted four times; it now advances once and
names the file being fetched.

* fix(asr): reject a truncated Parakeet download

_stream_model_download only rejects an empty body, so a truncated but
nonempty response was renamed into the model directory and then reported
as installed, because is_model_downloaded checks existence.

Record the byte size of each file at the pinned revision and check it
before the rename, the same order whisper.cpp verifies its digest in. A
short file is a truncated transfer rather than an upstream change, since
the revision is pinned, so it raises and the temp file is removed.

* style(asr): annotate the Parakeet model catalog

mypy inferred the catalog values as object, so reading file_sizes out of
an entry raised attr-defined and no-any-return under `just typecheck`.

* fix(asr): pin Parakeet digests in model_checksums.txt

AGENTS.md requires every speech model to be verified against a pinned
digest before install, failing closed on an unpinned one. Parakeet was
checking only the byte size.

Teach scripts/generate-model-checksums.py to emit Parakeet entries: the
ONNX files are LFS so Hugging Face publishes their sha256, and tokens.txt
is hashed from the downloaded bytes the way VOSK archives are. Several
bundles ship the same file names, so a key carries the model name.

_download_parakeet_model now calls verify_model_file before the rename
and catches ChecksumError alongside the other download failures, matching
the whisper.cpp downloader.

* fix(asr): verify on-disk Parakeet models before load

Existing bundle files were trusted by path alone, so a leftover or
copied-in tree could reach sherpa-onnx unchecked. Hash each file against
its pin on download skip and on init, and cover the Parakeet pins in CI.

* fix(ui): hide Parakeet language picker that ignores the setting

Parakeet language coverage is the model choice, not a catalog language.
Hide the picker and persist auto so Settings does not write a lying value.

* fix(asr): normalize Parakeet language to auto at startup

CLI --language and stale saved languages still reached SpeechRecognitionManager
even though Parakeet ignores catalog language. Force auto on the shared helper
used by main and the manager so stored settings stay honest.

* fix(ui): restore language after leaving Parakeet

Remember the prior Whisper/cpp language across a Parakeet visit so switching
back does not leave recognition stuck on auto. Keep Parakeet forced to auto
and the picker hidden. Also satisfy black on the startup normalizer path.

* fix(ui): keep language memory across Vosk fallback

Vosk's unsupported-language en-us coercion was writing into
_last_non_parakeet_language, so Whisper→Parakeet→Vosk→Whisper restored
en-us instead of the user's Greek (or other) preference. Remember the
pre-coercion catalog language and prefer it when leaving Vosk.

* fix(ui): keep language memory when entering Parakeet from Vosk

Vosk's coerced en-us fallback was still written into
_last_non_parakeet_language on Parakeet entry, so Vosk→Parakeet→Whisper
restored en-us instead of the pre-coercion preference (Greek or auto).
Share one coerced-fallback predicate across entry, leave-Vosk, and language
sync, and cover those paths with regression tests.

* fix(tests): set initializing flags false on language-change stubs

Plain Mock attributes are truthy and were short-circuiting the handler.

---------

Co-authored-by: Alex Thunder <AlexThunder@gmail.com>
2026-09-07 14:32:13 -07:00
946e19ee41 feat(settings): make the language picker searchable while its list is open (#798)
* feat(settings): make the language picker searchable while its list is open

Entry completion only ever covered typing into the closed entry. Open the
dropdown and the keyboard goes to GTK's own first-letter jump, so nothing typed
reaches a filter, and with thirty-odd languages scrolling is the only way
through. The first attempt at fixing this (a filtered ComboBox model) had the
same limitation for the same reason.

Replace the combo with a picker that opens a popover holding a search entry,
focused on open, above the list. Every keystroke narrows the rows, Enter takes
the first match, a click takes that row. It keeps the GtkComboBoxText methods
the dialog calls, mirrors the store's column order in base_model for the shared
row helpers, and emits "changed" only when the selection actually changes, so
re-selecting the saved language on open is not taken for a user edit.

Visibility uses the matcher the typed-text resolver already uses, so the list
and Enter cannot disagree. The completion helper and the combo styling skip
the picker: it filters its own list, and the ComboBox-only calls do not exist
on a Box.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT

* fix(settings): empty Enter must not auto-apply first language

Opening the searchable language picker clears the filter, so every row
is visible. Enter then took the first store row via _first_visible_row,
emitted changed, and auto-applied a language the user never chose.

Skip first-match activation when the needle is empty (or whitespace);
Enter only selects after a non-empty filter. Add regression coverage
and type the SearchablePicker surface (Greptile P2).

* fix(settings): finish SearchablePicker annotations

Greptile P2 stayed open after the empty-Enter fix: handler params,
get_child and _first_visible_row returns, and the ComboBoxText-compat
methods still lacked docs. Annotate the remaining signatures, add
one-line public docs, and type the test helpers. Behavior unchanged.

---------

Co-authored-by: Kacper Paczos <23406555+kacperpaczos@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-07 14:12:04 -07:00
9a0a677271 feat(models): make the recommendation applicable and stop ignoring the disk (#797)
* feat(models): make the recommendation applicable and stop ignoring the disk

The panel worked out which model fits the hardware and the language, printed it
as a plain label, and left the pickers on the wrong one. Applying it meant
hitting two dropdowns by hand after decoding that "Small EN" means size Small
plus specialization English-only.

Three changes:

- the recommendation gets a button that moves both pickers at once
- before offering a download, look at what is already on disk. A candidate has
  to be no smaller than the recommendation, and an English-only recommendation
  is not answered with multilingual weights, which weigh the same and recognise
  English worse (#776). So nothing is ever quietly downgraded.
- the size list shows weight and the downloaded marker, the way the
  specialization list already did. A size is a download decision too.

Closes #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT

* fix(settings): keep recommendation fresh across language and apply

Changing EN to PL left Use it pointed at the old English-only target, and
clicking it briefly auto-applied the default size (full large) before the
intended turbo specialization landed. Refresh the recommendation card after
language changes, and move both pickers under _populating_models before the
single apply.

* fix(settings): type the recommendation language and apply callbacks

* style(settings): wrap whispercpp model info import for isort

Lint Python was failing the isort check on the single-line
WHISPERCPP_MODEL_INFO import. Parenthesize it so black/isort agree.

---------

Co-authored-by: Kacper Paczos <23406555+kacperpaczos@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-07 14:11:59 -07:00
Jatin K MalikandGitHub 40bc31bebf fix(settings): stop freezing when applying an already-downloaded model (#790)
* fix(settings): apply already-downloaded models off the GTK main loop

Changing Model Size, Specialization, or Language froze Settings when the
model was already on disk, because reconfigure ran in the changed handler.
Move that apply onto a worker thread (same shape as the download path) and
keep the apply-guard held until GLib.idle_add clears it so a second pick
cannot start a parallel load.

Fixes #789.

* fix(settings): guard Test/apply while downloaded-model worker runs

Block apply_settings and Test Dictation while _applying_settings is held
so a concurrent Test click cannot race the shared engine. Annotate the
new worker/helpers per AGENTS.md and cover the no-op/block behavior.

* fix(settings): resync pickers when a second pick lands mid-apply

While an already-downloaded model worker was running, Model Size /
Specialization / Language could still move. The apply guard blocked
saving the new pick, then finish only cleared the flag, so the UI
could show B with dictation still on A. On finish, if the selection
no longer matches the saved config, put the pickers back. Idle
resyncs from workers now skip a destroyed dialog the same way update
check does.

* fix(settings): always resync pickers after model apply finishes

A second Model Size pick on whisper.cpp could move the size combo
while the apply guard skipped rebuilding specialization, so selected
settings still looked like the saved variant and finish skipped the
resync. Always put the pickers back on the saved config when the
worker ends, and restore language from that config too.

* test(settings): prove mid-apply picker handlers round-trip on finish

Call the real size, specialization, and language handlers while the
apply guard is held, and run finish through the real resync path so
whisper.cpp size and language cannot silently stick on the second pick.
2026-09-07 14:11:54 -07:00
43bf363f6b fix(models): let the selected language decide the whisper.cpp variant (#795)
* fix(models): let the selected language decide the whisper.cpp variant

whisper_cpp_model_size holds the id that loads the model, and for every size
below "large" the multilingual variant id is the bare size name. A stored
"medium" therefore could not say whether the user picked the multilingual
variant or never picked anything, so the language-aware default never ran and
selecting English left the picker on "Standard multilingual" while the panel
recommended English-only right underneath.

Record the specialization the user picks in its own key. Without that pin the
variant is derived from the language; a value that is not a bare size name was
written by an older build as a deliberate choice and is adopted as the pin, so
existing configurations keep the variant they had.

Closes #776

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT

* fix(models): keep language-derived whisper.cpp variants unpinned

Auto-apply was writing the derived specialization into both the pin and
whisper_cpp_model_size, so a later language change fought a stale pin and a
leftover .en id. Only deliberate specializations are pinned now; unpinned
saves store the bare size and resolve against the current language.

* fix(models): keep multilingual whisper.cpp pins across languages

Language auto-apply cleared any pin that matched the new language default,
so an explicit multilingual choice made under English was lost when switching
away and back. Preserve bare-size pins that still match the selection; keep
language-derived and stale .en pins unpinned. Wrap the isort import for CI.

---------

Co-authored-by: Kacper Paczos <23406555+kacperpaczos@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-07 14:11:49 -07:00
Jatin K MalikandGitHub f02a6a7c66 fix(settings): flip whisper.cpp specialization when language is English (#780)
Bare size names like tiny/small/medium are also the multilingual variant
ids, so a fresh config looks like a deliberate multilingual pick and the
.en preference never runs. Retarget those ambiguous ids (and matching
quantized pairs) when language implies English-only vs multilingual.

Fixes #776.
2026-09-07 14:11:44 -07:00
688d9e9bd2 feat(language): start a first run from the language the system points at (#796)
* feat(language): start a first run from the language the system points at

Nothing asked the system what language the user speaks, so whisper.cpp always
began on auto-detect and VOSK on a hardcoded en-us. Both are wrong for anyone
who does not speak English, and neither improves on its own because nothing
ever suggests setting a language.

Seed the language on first run only, so a saved choice — including a deliberate
"auto" — is never touched. The keyboard layout is consulted before the locale:
running the desktop in English while speaking something else is common among
the people who install a Linux dictation tool, which makes the interface
language a weak signal for the spoken one. A "us" layout is the default on many
installs and says little, so the locale gets a say before it is accepted.

localectl is used rather than setxkbmap, which reports nothing useful on
Wayland. Detection failures leave the packaged default in place.

Closes #777

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT

* fix(language): prefer LANGUAGE, keep cs/ca, map nb_NO

LANG was consulted before LANGUAGE, so a gettext preference list lost to
the fallback locale. C/POSIX detection used startswith("c"), which
rejected Czech and Catalan. nb_NO normalised to nb_no and missed the
nb→no catalogue map, so Norwegian fell through to en-us.

Tests cover LANG+LANGUAGE, LANGUAGE=cs, and nb_NO.

* fix(language): walk LANGUAGE prefs until one is supported

split(":")[0] dropped later supported preferences, so first-run could
stay on auto when LANGUAGE started with an unsupported tag. Try each
preference through the catalogue map. Annotate _seed_language_from_system.

* style(language): keep _language_from_locale_env on one line for black

* fix(language): fall through unmapped LANGUAGE to LANG

When every LANGUAGE preference missed the catalogue, the helper returned
before LANG was examined, so first-run could stay on auto. Skip unmapped
env vars and keep looking. Regression: LANGUAGE=xx:yy with LANG=pl_PL → pl.

---------

Co-authored-by: Kacper Paczos <23406555+kacperpaczos@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-07 14:11:40 -07:00
Jatin K MalikandGitHub 50ea1109d7 feat(snap): Snap packaging recipe and Snap Store publish strategy (#519)
* feat(snap): add Snap packaging recipe and store publish strategy

Add a strict-confinement snapcraft.yaml aligned with the current app
version and Flatpak sandbox lessons (mic, desktop, network models,
xdotool/xsel injection). Document human-only Snap Store steps and mark
install docs as not published yet.

Refs #48

* fix(snap): adopt-info so craftctl can set version from version.py

snapcraft pack failed: craftctl set version requires adopt-info on the part.

* fix(snap): stop staging GTK/GLib over the gnome platform

Prepending snap-staged GI typelibs and lib paths caused a segfault on
from gi.repository import Gtk. Use platform AppIndicator/GTK and only
stage PortAudio plus xdotool/clipboard helpers.

* docs(snap): note platform GTK path to avoid GI segfault

* fix(snap): route ALSA through Pulse so Settings does not abort

PortAudio enumerates ALSA devices when Settings opens. Missing
/usr/share/alsa/alsa.conf inside the snap triggered a PortAudio assert.
Ship an asound.conf that uses the pulse plugin, stage libasound2-plugins,
and bind ALSA paths via layout.

* fix(snap): keep ALSA conf under snap/local for snapcraft

Move asound.conf out of snap/alsa so snapcraft stops warning about
non-snapcraft paths in the snap directory.

* fix(snap): request raw-input for global keyboard shortcuts

Strict confinement blocks /proc/bus/input and /dev/input, so Wayland
evdev hotkeys never start. Declare the raw-input plug (manual connect)
and print snap-specific connect instructions instead of host input-group
hints.

* fix(hotkeys): discover keyboards via evdev when /proc is blocked

Snap raw-input grants /dev/input/event* but still denies
/proc/bus/input/devices, so find_keyboard_devices() returned empty
and hotkeys never started. Fall back to evdev.list_devices() when
/proc cannot be read.

* fix(snap): restore UI sounds and prefer wtype for Wayland inject

Stage paplay (pulseaudio-utils) and wtype; teach audio_feedback about
pw-play/canberra. xdotool-only injection often hits the wrong window on
native Wayland even when it reports success.

* fix(snap): sync 0.16.2/AGPL metadata and AppImage-safe audio env

Align snapcraft fallback version and store metadata with current main
(VocaHQ URLs, AGPL-3.0), keep docs pointing at #48 on VocaHQ, pass
host_env to pw-play/canberra paths, and document ordered Snap Store
human deploy steps.

* test(snap): harden audio host_env unit test against suite pollution

Patch os.path.exists like sibling audio_feedback tests instead of relying
on a real tempfile path that can collide with other suite mocks.

* style(snap): black-format new audio and snap packaging tests

* docs(snap): treat Store listing as live; edge→candidate→stable refresh

Name vocalinux is already on snapcraft.io (edge @ 0.14.0-beta rev 6).
Document upload/release without re-register, add snap-store-refresh.sh
helper, and point INSTALL/README/DISTRO at the live listing.

* fix(snap): move to core24 and close evdev capability FDs

core22 only ships Python 3.10, which fails requires-python >=3.11, so the
snap base is now core24 (Python 3.12) with the gnome-46 platform and the
Ubuntu 24.04 libasound2t64 package. Also close InputDevice after failed
capability scans and annotate the proc parser with TextIO.

* Snap: align store listing metadata and icon

Match snapcraft.yaml summary, description, license, website, and store
icon to the live Snap Store listing.

* Snap: trim packaging docs, script, and unused SVG

Drop store refresh wrapper, long packaging README, duplicate snap/gui SVG, and speculative pw-play/canberra paths; keep recipe and confinement fixes.

* ci: publish snap to edge and candidate on release tags

* Snap: set grade stable for candidate channel CI

* Snap: skip pip PyGObject; use gnome extension GI
2026-09-05 18:55:37 -07:00
Jatin K MalikandGitHub bb2b098193 chore(release): prepare v0.16.2 (#782)
Bump version metadata and series-aware notes for the 0.16.2 patch:
KDE leftover IBus, Wayland/IBus shortcuts, BackSpace delete that,
installer glslc, nightly stamp, release integrity, and CI/docs gates.
2026-09-05 15:04:48 -07:00