Commit Graph
281 Commits
Author SHA1 Message Date
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
Benjamin EiseleandGitHub 330e1d8237 fix(actions): send real BackSpace key events for "delete that" (#714)
`_handle_delete_last` built its deletion as `"\b" * len(text)` and
passed it to `inject_text`, treating U+0008 as typed input. No backend
delivers that as a keypress:

- `ydotool type` has no keymap entry for U+0008, so it types nothing.
- IBus `commit_text()` commits it as a literal control character, which
  the client inserts or drops rather than treating as a deletion.

Only an actual key event deletes, so the "delete that" voice command
did nothing on those paths while still reporting success.

Add `press_backspace(count)`, which sends `count` real BackSpace events
using the tool that fits the environment: `xdotool key --repeat` on X11
and the xdotool-backed Wayland path, `wtype -k BackSpace` or the
ydotool keycode pair `14:1 14:0` otherwise. WAYLAND_IBUS resolves a
virtual-keyboard tool of its own, since IBus cannot send key events.

`inject_text` is untouched; only the delete_last action changes.

The existing delete_last tests asserted the U+0008 string was injected,
so they now pin the new contract instead: press_backspace called once
with the character count, and inject_text not called at all.
2026-09-02 01:54:15 -07:00
Benjamin EiseleandGitHub 030d9486cd fix(ibus): route keyboard shortcuts to a virtual-keyboard tool (#716)
`_inject_keyboard_shortcut` dispatched X11 and WAYLAND_XDOTOOL to
xdotool and sent everything else to
`_inject_shortcut_with_wayland_tool`, putting both IBus environments on
a path that cannot serve them. IBus commits text; it has no way to
synthesise a key combination.

WAYLAND_IBUS failed worse than it looked. `wayland_tool` is only set
when dependency detection picks a Wayland injection tool, so on an IBus
session it can be unset, and the attribute access raised inside the
enclosing try. The except logs and returns False, so every shortcut
reported a clean failure rather than a missing tool.

Route X11_IBUS through xdotool, which reaches the same X server the XIM
path is already using, and give WAYLAND_IBUS a virtual-keyboard tool,
preferring wtype and falling back to ydotool. With neither present the
error names the real cause instead of surfacing an AttributeError.

X11_IBUS folds into the existing xdotool condition rather than adding a
branch, since the call is identical. The `wayland_tool` write is taken
under `_state_lock`, matching every other write to it; the injection
call stays outside the block, as the lock is not reentrant.

Text injection is unchanged; this only affects shortcut delivery, and
only in the two IBus environments.

Five tests cover the routing: X11_IBUS reaching xdotool, WAYLAND_IBUS
preferring wtype, its ydotool fallback, the no-tool-available failure
injecting nothing, and an already-chosen tool being left alone.
2026-09-02 01:41:28 -07:00
Benjamin EiseleandGitHub a62c5fa70d fix(wayland): drive keyboard shortcuts with wtype and ydotool (#715)
`_inject_shortcut_with_wayland_tool` could not deliver a shortcut on
either backend:

- wtype was refused outright with "Keyboard shortcuts not supported
  with wtype", but wtype does chord: `-M` presses a modifier, `-k`
  types a key, `-m` releases it. Modifiers must be released explicitly,
  as wtype only drops them when the process exits.
- ydotool was handed the shortcut string as-is, `ydotool key ctrl+z`.
  `ydotool key` takes raw evdev keycodes ("29:1 44:1 44:0 29:0") and
  documents non-interpretable values as only causing a delay, so this
  was a silent no-op that still returned True.

Parse the shortcut into a list of `(modifiers, key)` steps and emit the
right form per tool. Tokens are classified by name rather than by
position, because the mappings in `ActionHandler._SHORTCUT_ACTIONS` are
not all modifiers-first: `select_line` is "Home+shift+End", meaning
press Home, then Shift+End -- two sequential steps, not one chord. Each
non-modifier token closes a step and consumes the modifiers seen since
the previous one.

An unmappable ydotool key now fails loudly instead of emitting a
partial sequence, and a trailing modifier with no key is rejected at
parse time.

The X11 and WAYLAND_XDOTOOL paths are untouched -- xdotool already
accepts the string form directly.

Verified on Hyprland 0.56.2 with wtype 0.4 and ydotool 1.0.4, injecting
into a GTK3 window that logs key-press events. `wtype -M ctrl -k z -m
ctrl` arrives as z with the control modifier set; the two-step form
arrives as Home with no modifier, then End with shift; the raw keycode
form arrives as Control_L followed by z with control. Run on its own,
`ydotool key ctrl+z` exits 0 and delivers no key event at all -- the
failure this fixes.

`test_wtype_not_supported` asserted the old refusal, so it is replaced
by tests pinning the emitted argv for both tools, the two-step case,
and the unmappable-key rejection.
2026-09-02 01:18:59 -07:00
Jatin K MalikandGitHub 6684f6f584 fix(inject): skip leftover IBus on KDE so dictation actually types (#753)
* fix(inject): skip leftover IBus on KDE so dictation actually types

Test Dictation and clipboard paste worked. Inject claimed success and
nothing showed up in Kate, Obsidian, terminals, or browsers.

We started ibus-daemon on X11 even when the session never opted into
IBus, then treated that daemon plus a bare xkb engine as active.
commit_text() returns True. Qt/GTK on KDE never hear it.

Do not spawn the daemon unless GTK_IM_MODULE / QT_IM_MODULE /
XMODIFIERS already name IBus. On KDE, a running daemon without those
env vars is not the session IM. Prefer a live WAYLAND_DISPLAY over a
stale XDG_SESSION_TYPE=x11, and do not take the GNOME scoped-IBus
shortcut on KDE.

See #752

* style: wrap IBus helpers so black is happy

CI lint failed on two lines in the #753 inject fix. Same behavior.

* test(inject): leftover IBus on KDE should be skipped

The CheckDependencies case still expected IBus for a leftover daemon
on KDE. That is the #752 bug. It now expects skip and wtype instead.
2026-09-02 01:02:49 -07:00
Jatin K MalikandGitHub 21b8ee8d05 fix(aur): build on Arch setuptools 84 and skip 1.4 context_params (#757)
AUR --no-isolation uses extra python-setuptools 84, so the <82 cap blocked the 0.16.0 rebuild. AUR pywhispercpp backends are still 1.4 and crash if we pass context_params; skip that argument below 1.5.
2026-08-30 01:12:48 -07:00
Jatin K MalikandGitHub 68b00bd7d8 chore(release): prepare v0.16.1 (#755)
Bump the stable line from 0.16.0 to 0.16.1. README and UPDATE keep the 0.16 series highlights and add the patch-only fixes. Website changelog, schema, AppStream, and AUR PKGBUILD follow.
2026-08-30 00:49:09 -07:00
Jatin K MalikandGitHub 7cdf5a7069 feat(settings): even out dropdowns and tidy the About page (#754)
* feat(settings): even out dropdowns and tidy the About page

Pin combos to a shared right-hand column so long labels cannot stretch a
row. Fold the dictation-tone preview into a two-stage Start/Stop button,
quiet the model status card, shorten Performance copy, and rebuild About
with combined links, channel+Check, family platform tiles, and Talk to us
moved up next to the license.

* fix(settings): keep tone preview on start if the cue fails

Do not flip Start/Stop when preview_tone_cue returns false, so a missing
file or silent player cannot skip the start cue. Also annotate
PreferenceRow.set_title / set_subtitle.

* fix(settings): paint About icons for dark dialogs

Dark ink on Adwaita/Yaru-dark buttons made the family tiles and Talk to us
marks disappear. Pick a light foreground on dark themes (and keep the
original ink on light ones) so the marks match the labels.

* fix(settings): show Android and iOS marks on VocaPhone

VocaPhone covers both, so the family tile now uses the Android robot and
the Apple mark instead of a single phone icon.

* fix(settings): cut repeated About copy

Name the AGPL once on the License row. The VocaHQ blurb is just the
private-dictation line. VocaPhone says TestFlight, not source-only, and
Gateway is self-hosted and headless.

* fix(settings): space About icons from their labels

Talk-to-us buttons and the family tiles were packing the mark against the
text. Give the image an end margin and a wider gap in the family tile.
2026-08-30 00:20:27 -07:00
Jatin K MalikandGitHub 158780fc38 fix(ui): use the canonical VocaHQ Discord invite (#749) 2026-08-29 15:51:27 -07:00
SergeyandGitHub 65381b4582 fix(appimage): build against a glibc floor users actually have (#743)
* build(appimage): pin the interpreter, packages and tooling

* ci(appimage): build inside a pinned ubuntu 22.04 image

* test(appimage): guard the pins and the base image

* docs: state the AppImage glibc floor

* fix(appimage): bundle libibus with its typelib

* ci(appimage): let the container run the isolated GI smoke test

* fix(appimage): keep the bundle's libraries out of host binaries

* fix(appimage): bundle libgpg-error with the libgcrypt that needs it

* docs(agents): require host_env() for spawned binaries

* ci: run the AppImage jobs when its other inputs change

* fix(appimage): check every bundled typelib, not just the seeded ones

* fix(ibus): keep the bundle when respawning our own interpreter

* fix(injection): pass host_env() to setxkbmap and notify-send

* test(ibus): wait for the IBus warmup thread before asserting
2026-08-29 14:46:50 -07:00
6b14338d1e feat(tray): offer the recommended model from the missing-model notification (#687)
* feat(tray): offer the recommended model from the missing-model notification

Dictation without a model on disk showed a dead-end notification telling
the user to open Settings, find the model picker and start the download
by hand, at the moment they wanted to talk. There was no Download button
anywhere: downloads only happen as a side effect of changing a selection.

The notification now carries a "Download <model> (<size>)" button for the
model the picker already stars for this machine, so recommendations stay
in one place: get_recommended_model() for whisper.cpp, including the
English-only variant when an English language is selected, and the RAM
based recommendations for VOSK and Whisper. Clicking it downloads in the
background, reports progress in the notification, and saves the model for
that engine only after it loaded.

Notification servers that do not advertise the actions capability, and
systems without libnotify, still get the plain text they got before.
Dictation is not started for the user afterwards: a download can take
several minutes, and grabbing the microphone unannounced would surprise
someone who has long since moved on.

* feat(tray): actually fetch the offered model, and only claim it when loaded

Review follow-ups on #687.

reconfigure() only re-initializes when engine, model or language change,
and force_download is read during that re-init. First-run defaults are
whisper_cpp + tiny, and tiny is what get_recommended_model() returns on a
CPU-only machine under 16GB, so the click was a no-op that logged
"Applied VAD/silence timeout changes.", saved the model, said "Speech
model ready", and left the next shortcut showing the same missing-model
notification.

Add force_reinit to reconfigure() and pass it here, then gate the save
and the success notification on speech_engine.model_ready instead of on
reconfigure() not raising.

The "starting..." notification is now created in
_download_recommended_model, which runs on the main loop where libnotify
dispatches the action; only the handle travels to the worker, which
already routes update/close/notify through _idle_once.

The offer body no longer promises a button the server cannot draw: with
no action it points at Settings, like the notification it replaced.

Tests cover the case that breaks the feature — the recommendation equal
to the configured model — behaviourally, through a stand-in engine that
gates re-init the way reconfigure() does, plus the real reconfigure()
contract in both directions. They also stop touching sys.modules at
collection time, which is the desync #686 hit, and black is clean.

* test(notifications): cover the libnotify/notify-send boundary directly

Every test that touches notifications so far patches the whole module
away, so the module itself had no coverage: the fallback to notify-send
when the server has no action support or libnotify fails to initialize,
the handle kept alive in _pending until the user answers, and update()
refusing a fallback handle. These drive it with a fake Notify installed
through the module's own lazy cache rather than by patching
gi.repository, which is shared with every other test in the process.

* fix(download): allow only one model download at a time across tray and Settings

The tray's download worker and the Settings dialog's both drive the same
engine, which has a single progress callback slot and a single
configuration. Started together, the later one overwrote the earlier
one's progress callback, the earlier one's finally block then cleared it
mid-download, and whichever finished last became the persisted model.

Give the engine a non-blocking claim. Both Settings entry points and the
tray take it before starting a download and release it when the worker
ends; whoever loses the race is told a download is already running and
the Settings picker goes back to the saved model.

---------

Co-authored-by: Kacper Paczos <23406555+kacperpaczos@users.noreply.github.com>
2026-08-28 23:29:58 -07:00
Jatin K MalikandGitHub 65ce9daa19 fix(ibus): sync XWayland layout from GNOME after scoped inject (#742)
Super+Space during recording on GNOME Wayland updates Mutter and IBus
but can leave XWayland on the previous map. Scoped inject restores the
IBus engine and skips setxkbmap (#474), so X apps keep the old layout.

After engine restore, copy GNOME's current XKB source onto XWayland
when DISPLAY is set. Do not replay an empty capture and do not call
restore_xkb_layout for this. Sessions with no X display are left alone.
2026-08-28 23:29:53 -07:00
Jatin K MalikandGitHub 9c3ed49317 fix(tray): stay idle after leftover transcription on toggle stop (#741)
* fix(tray): stay idle after leftover transcription on toggle stop

Toggle stop can time out while leftover audio is still transcribing.
The recognition thread then painted PROCESSING and never went idle, so
the tray stayed red until another Alt+F12.

Skip PROCESSING and LISTENING updates once recording has stopped, always
set IDLE when that thread exits, and paint the engine's current state in
the tray instead of a stale GLib.idle_add argument.

* fix(tray): leftover drain must not idle a new dictation session

stop_recognition already marks IDLE. Forcing IDLE when the leftover
worker exits can clobber a new LISTENING start during leftover drain.
2026-08-28 23:29:46 -07:00
Jatin K MalikandGitHub 008fd3138e fix(injection): use Ctrl+Shift+V when pasting into terminals (#734)
* fix(injection): use Ctrl+Shift+V when pasting into terminals

Clipboard injection always sent Ctrl+V, which most terminal emulators
ignore. Detect standalone terminal windows and send Ctrl+Shift+V, with a
Settings override for nested IDE panels that window-class detection cannot see.

* fix(injection): fail closed when terminal window probe is mocked

Clipboard tests patch text_injector.subprocess.run, which replaces
stdlib subprocess.run. Window detection now ignores non-string probe
output so injection still uses Ctrl+V instead of raising TypeError.

* docs: fix overclaimed terminal auto-detect in USER_GUIDE

Auto-detect only runs on X11 and on Hyprland, Sway, and niri. GNOME and
KDE Wayland stay on Ctrl+V unless you set Clipboard Paste Shortcut to
Ctrl+Shift+V.
2026-08-27 00:12:27 -07:00
Kacper PaczosandGitHub a4bd398945 fix(settings): save the model only once the engine loaded it (#685)
* fix(settings): save the model only once the engine loaded it

Applying settings wrote the config first and reconfigured the engine
afterwards, and reconfiguring is what downloads a missing model. A
download that failed, was cancelled, or was interrupted therefore left
the config naming a model that never reached the disk: the next start
reported "No Speech Model" while the previously working model sat on
disk, deselected. Reconfigure first and persist only on success, so a
failed switch keeps the settings that are actually running.

On failure the pickers are put back on the saved model. They otherwise
kept showing the rejected one, which also blocked a retry, since
re-selecting the same entry emits no "changed" signal.

Selecting an engine now applies as well. Every other control on the page
applies on change, but the engine only repainted the pickers: the config
and the recognizer stayed on the previous engine until another control
was touched, and closing the dialog dropped the change without a word.
Remote API is deferred until its server URL is filled in, since applying
it empty can only fail validation.

* fix(settings): restore the engine picker after a failed engine switch

Fixes the known limitation called out in the previous commit (#692):
when applying a newly selected engine failed, the resync restored the
model pickers but left the engine combo displaying the engine that was
never applied, so the page described a state that was not running.

Set the combo back to the saved engine while holding the apply
suppression flag, so _on_engine_changed() repaints the pickers without
cascading into another apply or download prompt.

* fix(settings): resync the engine picker on every path that never applied

Review follow-ups on #685.

#692 was not actually closed. The resync lived only in the download
thread's except block, and _apply_settings_internal() reports failure by
returning False as well as by raising, so the thread fell through to
set_complete(True, "") and reported a switch that never happened. Act on
the return value instead of depending on #690 to make that except live,
and add the same handling to apply_settings()'s modal path, whose except
never resynced at all.

Add _resync_engine_ui_if_unapplied() as the backstop the review asked
for: after the modal closes, and when the dialog closes, compare the
engine on screen against the engine in the config and restore the picker
when they disagree. That also covers Remote API left without a server
URL, where applying stays deliberately deferred.

_on_engine_changed now checks _initializing / _applying_settings itself,
like every other control on this page, so the combo move made by a resync
repaints without rewriting the user's language or cascading into another
apply.

Tests no longer touch sys.modules at collection time: the reimport moved
into a module-scoped fixture that also restores the attribute on
vocalinux.ui, which is what desynced test_model_deletion on 3.9/3.10.
New cases cover the modal path that #692 is about — needs_download true
with the apply returning False, and with it raising "Download cancelled" —
plus the close hook. The stub now uses RecognitionState.IDLE rather than
the string "idle", so tests stop taking the stop_recognition branch and
sleeping 0.5s.
2026-08-26 23:59:37 -07:00