Commit Graph
170 Commits
Author SHA1 Message Date
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
Kacper PaczosandGitHub b7bfdd2dd1 fix(config): share one ConfigManager across the process (#691)
* fix(config): share one ConfigManager across the process

main(), the tray, and the sound-effects check each constructed their own
ConfigManager. Every instance loads the whole config into memory at
construction time and save_config() writes that whole cache back, so any
save from a stale instance silently reverts what another instance saved
in the meantime — no race needed, the second save just wins with old
values. The disk-read workarounds in main (_should_append_trailing_space)
already existed to dodge exactly this.

Add get_shared_config_manager() and route the three call sites through
it. It keeps ConfigManager constructible for tests, and the tray/dialog
pair already shared one instance, so this extends that arrangement to
the rest of the process.

Tests: a regression test runs the two-instance scenario through the
accessor, and a source guard fails if application code constructs
ConfigManager directly again. conftest drops the shared instance
between tests, since it would otherwise be created under whichever
mocks the first caller had active and leak them into later tests.

The two single-instance tests now patch acquire_lock itself: they used
patch.dict(sys.modules, ...), which main()'s "from . import" ignores
once an earlier test has imported the real module — they only passed
through an unrelated mock leaking into the tray's shortcut setup, which
this change stops.

* fix(config): lock the shared accessor and prove the revert it prevents

Review follow-ups on #691:

- guard the lazy create with a lock, so two threads racing the first
  call cannot each build a ConfigManager and reintroduce #689
- take both handles before the first save in the accessor test, and
  assert identity there — the old order let a fresh ConfigManager pass
- add a characterization test: two direct ConfigManager instances still
  revert each other, which is the footgun the accessor exists to hide
- patch get_shared_config_manager in test_main and test_audio_feedback
  instead of the class, so a leftover singleton cannot skip the mock
- close the ExitStack in _boot_under_patches when main() raises; the
  caller only closes a stack it received, so a failure there leaked
  those patches into every later test

* fix(audio): route the tone lookup through the shared ConfigManager

The tone picker merged on main (#707) constructs its own ConfigManager,
which is exactly the stale-cache overwrite this branch removes; the new
source guard in test_shared_config_manager.py flags it.
2026-08-26 23:59:19 -07:00
Kacper PaczosandGitHub e330f9db70 fix(settings): size the unused downloads list to its real rows (#686)
* fix(settings): size the unused downloads list to its real rows

The list reserved 56px per row, but a row renders taller than that once
its margins and the Delete button are counted. With two leftover models
the header read "2 leftover models" while only the first row fitted in
the viewport, and the second could neither be seen nor deleted. The
overlay scrollbar gave no hint either, since it stays invisible until
hovered.

Measure the list once its rows are in place and cap that at the existing
220px, and keep the scrollbar in the layout so a list that does reach the
cap looks scrollable.

* fix(settings): test the list sizing without reimporting the dialog

Review follow-ups on #686.

The test helper popped vocalinux.ui.settings_dialog from sys.modules at
collection time and reimported it under a patched Gtk, restoring only
sys.modules and not the attribute on the package. Whichever module object
a later test reached then depended on collection order, which is why
test_model_deletion::test_whisper_list_and_delete failed on 3.9/3.10 with
assert [] == ['tiny'] while main stayed green.

Move the arithmetic into a module-level _clamp_unused_downloads_height()
and unit-test that directly, the way _list_downloaded_whisper_models is
already tested. Nothing touches sys.modules now.

The dialog cannot be instantiated under the mocked-GTK harness, so the
wiring the old tests never covered is asserted against the method source,
following test_settings_mode_change.py: refresh calls the fit, the fit
measures the listbox and feeds set_min_content_height, the per-row
estimate stays gone, and the scrollbar stays out of overlay mode.

* fix(settings): remeasure the unused-downloads list when it expands

The refresh measures while the expander is collapsed and the dialog may
not be mapped; if that first measurement ever comes back short, the
expand handler remeasures so the rows cannot stay clipped.
2026-08-26 23:58:55 -07:00
Kacper PaczosandGitHub 5e3265f87b fix(startup): resolve model size per engine instead of the legacy key (#684)
* fix(startup): resolve model size per engine instead of the legacy key

Startup read the generic "model_size" key, which always holds whichever
engine was saved last, so it initialized the configured engine with
another engine's model. With engine=whisper_cpp and a config left at
model_size=medium by a VOSK save, startup looked for ggml-medium.bin,
reported the model as missing and offered a fresh download while the
working ggml-small.bin named by whisper_cpp_model_size sat on disk.

Use ConfigManager.get_model_size_for_engine(), the resolver the settings
dialog already uses, so the generic key stays a write-only compatibility
shim. It falls back to the generic key when no per-engine key exists, so
older configs keep working.

* fix(config): migrate model_size into whisper_cpp_model_size

Review follow-up on #684.

get_model_size_for_engine() only falls back to the generic model_size key
when the engine key is missing from the in-memory dict, and DEFAULT_CONFIG
already ships whisper_cpp_model_size. After the merge in load_config() the
key is therefore always present, so a user file holding only
{engine: whisper_cpp, model_size: small} used to start on small and would
now start on the default tiny — the older-config path from #681.

_migrate_config already copies the model into vosk_model_size and
whisper_model_size; do the same for whisper_cpp so the resolver change is
behaviour-preserving for those files, and widen the migration trigger to
notice a missing whisper_cpp key.

Tests run against a real ConfigManager on a temp config, not a mocked
resolver: the migration case fails without this change, and the two
resolver cases pin the engine key winning over the generic one.
2026-08-26 23:58:42 -07:00
SergeyandGitHub f4e9fcb0ee fix(installer): survive a release without a checksum manifest, and stop inventing GPUs (#736)
* installer: leave model downloads to first run when the release pins no checksums

* installer: refuse an unpinned VOSK model before spending the download

* installer: require a real device before reporting a Vulkan GPU

* build: replace the deprecated license table and complete the packages list

* installer: treat software Vulkan renderers as no GPU, not as one

* installer: keep diagnostics off stdout so captured detection output stays clean
2026-08-26 22:53:29 -07:00
Jatin K MalikandGitHub 0ac361db19 fix(copy): VocaWin is Beta, still unsigned (#733)
* fix(copy): VocaWin is Beta, still unsigned

Call VocaWin Beta on the site family card, About blurb, and README table, same word as VocaMac. Keep the unsigned / SmartScreen wording. No version tag.

* fix(copy): cite VocaWin v0.1.0-beta.1, no release URL

The git tag exists on VocaHQ/vocawin. GitHub Releases does not. About, README, and the site family card name the tag. Links stay on vocawin.com and the repo.
2026-08-24 11:41:17 -07:00
f43f9dcd72 fix: leftover engine repair, just --no-sync, and ggml verify from #713 (#732)
* fix: leftover engine repair, just --no-sync, and ggml verify from #713

#713 only rewrote a broken config.json toward whisper_cpp, so a reinstall
that kept the old engine still died when vosk was the extra that installed.
verify_configured_engine now prefers SELECTED_ENGINE, then any working extra,
and the vosk fallback rewrites an existing file.

just recipes use uv run --no-sync so they do not prune extras from
just deps-all. CONTRIBUTING lists libgirepository-2.0-dev for Ubuntu 24.04.
An unverified ggml file is not loaded if unlink fails, and an unpinned name
is refused rather than deleted.

* fix(appimage): retry tooling downloads on curl reset

* fix(build): bootstrap .venv for --no-sync just recipes and cover the contributor path in CI

---------

Co-authored-by: Sergey Silaev <hello@8am.dev>
2026-08-24 10:42:52 -07:00
Sergey SilaevandGitHub 582d494633 chore(build): uv tooling, a Python 3.11 floor, and verified model downloads (epic #701, phases 2.5 & 5) (#713)
* chore(build): migrate justfile tooling to uv

All recipes now run through 'uv run' against .venv, with a shared
DEV_EXTRAS variable (dev + vad) because uv syncs the environment
exactly and would otherwise uninstall the dev tools between
recipes. 'deps' installs dev+vad; new 'deps-all' covers every
extra. 'build' switches to 'uv build'; 'version' uses --no-sync.

Bump [tool.uv] required-version to >=0.12,<0.13 for uv 0.12.x, and
raise the mypy python_version target to 3.12: mypy 1.19 in the lock
cannot target 3.9 and numpy 2.x stubs need a 3.12 target to parse
(mypy is not run in CI or pre-commit; this only affects 'just
typecheck').

* fix(tests): isolate test runs from real XDG state; fix Arch model-path crash

Local test runs failed en masse (185 failures) while CI stayed
green: tests picked up the developer's real ~/.local/share/vocalinux
models via the default XDG paths and switched IBus/keyboard code
paths through the desktop session env (XDG_CURRENT_DESKTOP/
XDG_SESSION_TYPE on GNOME/Wayland, issue #478 branches). conftest
now points XDG base dirs at a throwaway session-wide directory and
clears session variables at import time, mirroring a fresh CI
runner (paths like MODELS_DIR freeze at first import during
collection, so fixture-time isolation is too late).

Also guard the Arch branch of _get_system_model_paths(): it removed
/usr/local/share/vocalinux/models unconditionally and crashed with
ValueError on Arch hosts when a custom XDG_DATA_DIRS never added
that path. Add regression tests, and make the lock-file test assert
the actual $XDG_DATA_HOME/vocalinux contract instead of the
hardcoded ~/.local/share default.

* fix(tests): keep the IBus engine double a plain class

Python 3.14's unittest.mock re-instantiates a user subclass when it builds
child mocks (klass(name=...)). VocalinuxEngine inherits from whatever
IBus.Engine resolves to, and the test module bound that to the MagicMock
class itself, so every engine instantiation raised

    TypeError: VocalinuxEngine.__init__() got an unexpected keyword argument 'name'

on 3.14 while passing on 3.13. Replace it with a plain stand-in class that
still hands out per-instance MagicMocks for the IBus API surface the engine
touches.

The same pattern remains in test_ibus_engine.py, test_ibus_engine_utils.py,
test_ibus_active_engine_guard.py and test_ibus_xkb_wayland.py; those never
instantiate the engine, so they are unaffected for now.

* fix(install): build the venv from the system Python

install.sh looked up a bare `python3` from PATH. Started from an activated
virtualenv — a shell left in uv's .venv after `just deps` — it built venv/
from that interpreter, where distro PyGObject is invisible even with
--system-site-packages, and the run aborted with

    Distro PyGObject (python3-gi / python3-gobject) is not importable in the venv.

A mismatched venv/ then survived every retry, because the installer reuses
whatever venv it finds.

- drop an inherited VIRTUAL_ENV (and its bin from PATH) before any
  interpreter lookup
- select the interpreter explicitly: $SYSTEM_PYTHON (default
  /usr/bin/python3) or PATH python3, preferring one that can import gi,
  and create the venv with it instead of `python3`
- recreate venv/ when it was built by a different interpreter
- name the interpreter in the PyGObject guard's error

Documents the two environments (uv's .venv for tooling, venv/ for the
installed app) in CONTRIBUTING, the justfile header and AGENTS.md, and adds
the mismatch symptom to the INSTALL troubleshooting section.

* chore(python): raise the supported floor to 3.11

requires-python promised 3.9 while the CI matrix ran 3.9/3.10/3.11/3.13,
the classifiers advertised up to 3.14, mypy targeted 3.12 and install.sh
enforced 3.9 — five different answers to "which Python does this support".

Settle on 3.11 everywhere: pyproject (requires-python, classifiers, black
targets), install.sh's MIN_VERSION, check-system-deps.sh, the distro package
map, the CI matrix (now 3.11-3.14, matching the classifiers), the release and
nightly workflows, and the docs.

mypy now targets the floor instead of 3.12, so type checking covers what we
promise; numpy 2.x ships stubs in 3.12 syntax that a 3.11 target cannot
parse, so its imports are skipped. (mypy has 71 pre-existing errors either
way; no CI job gates on it.)

uv.lock and the requirements/* exports are regenerated — dropping 3.9/3.10
removes most of the version-conditional resolution branches.

tests/test_python_version_policy.py pins the invariant: install.sh's floor,
requires-python, the classifiers and the CI matrix must agree.

* ci: run lint and tests from uv.lock

The lock file and the hash-pinned exports were not enforced anywhere: CI
still built a venv by hand and installed unpinned `PyGObject` plus
`-e ".[dev,vad]"`, so the tool versions it checked against had nothing to do
with the ones `just lint` uses locally.

Both Python jobs now use astral-sh/setup-uv and run through `uv sync
--locked` / `uv run --locked`, which also fails the build when uv.lock drifts
from pyproject.toml.

Partially covers phase 5 of #701 (tests install from the lock, lock freshness
gate); trusted publishing, artifact checksums and action SHA pinning are
still open there.

* ci: lint from a dedicated dependency group

The lint job failed on this branch: `uv run --extra dev` syncs the whole
project, so it tried to build pyaudio from source on a runner that installs
no system dependencies.

    src/pyaudio/device_api.c:9:10: fatal error: portaudio.h: No such file or directory

Move black/isort/flake8 out of the `dev` extra into a `lint` dependency
group. CI runs `uv run --locked --only-group lint`, which installs the three
pure-Python tools and skips the project entirely — no portaudio or
girepository headers needed. `just deps` and DEV_EXTRAS request the group
alongside the dev extras, so the local environment still carries test and
lint tooling at once and `just lint` matches CI version for version.

pre-commit is unaffected: its hooks pin their own tool versions upstream.

* fix(install): never configure an engine the venv cannot import

vosk became an optional extra in #705, but the installer still writes
`"engine": "vosk"` into config.json on the whisper.cpp fallback path even
when installing the extra only produced a warning. config.json also survives
reinstalls — every writer guards on "file does not exist" — so an engine
picked by an earlier attempt outlives the venv that supported it. Either way
Vocalinux dies at startup:

    ModuleNotFoundError: No module named 'vosk'

- the fallback now writes the vosk config only when `import vosk` actually
  succeeds, and otherwise leaves the engine configuration alone
- a new verify_configured_engine() runs near the end of the install: if
  config.json selects vosk and the venv cannot import it, it says so and
  prints both fixes instead of leaving a broken install behind

* feat(models): verify every model download against a pinned digest

Pins a sha256/md5 per downloadable speech model in
src/vocalinux/utils/model_checksums.txt and verifies against it in both
install.sh and the runtime downloaders, before the file is moved into place.
Verification fails closed. Covers phase 2.5 of #701; see the PR for details.

* docs(agents): rebase AGENTS.md onto the #727 rewrite

* fix(install): hash models that are already on disk instead of trusting them

* refactor(models): one download path per engine, and fix Whisper's

* docs(install): drop distros that cannot meet the 3.11 floor

* docs: retire the distros and Python versions the 3.11 floor rules out

* fix(install): abort instead of warning when Python is below the floor

* fix(install): fall back to the system Python, not the one on PATH

* fix(install): match an existing venv by installation, not by X.Y

* fix(install): repair a config naming an engine the venv cannot import

* fix(models): pin VOSK by the sha256 of bytes we fetched, not upstream's md5

* fix(justfile): request the dev set in every recipe that syncs

* test(version): scan the docs and website trees for a stale Python floor

* docs: correct the stale model-checksum and distro claims

* fix(models): stamp the VOSK tree the app extracts, as the installer does

* fix(install): replace an unverified VOSK model only once its successor is ready

* docs(install): align the Fedora floor with the rest of the tree

* test(install): drop an f-string with no placeholders

* test(models): stub the downloader probe the VOSK installer test relies on

* docs: drop the long dashes from two model-checksum comments

* fix(install): gate on the interpreter, not on the distro release label

* fix(justfile): export the lint group into requirements/dev.txt

* fix(models): unpack the VOSK tree into staging and stamp it before the swap

* fix(whisper): adopt a checkpoint an earlier release stored under the catalog name

* docs(settings): state what a stray Whisper copy really means for deletion

* fix(models): hash a whisper.cpp model found on disk before it reaches ctypes

* docs(contributing): stop telling readers to uninstall their linters

* fix(install): stop warning off distros the project documents as supported

* docs(distros): file the supported derivatives under supported
2026-08-24 00:25:06 -07:00
a37469c8d5 feat(ui): About page for this app, VocaHQ, and talk to us (#718)
* feat(ui): About page for this app, VocaHQ, and talk to us

Settings About now has three groups. Vocalinux keeps the existing icon,
version, AGPL-3.0, updater, and What's New. The website button opens
vocalinux.com.

Part of VocaHQ lists the family sites with PRODUCT.md status. Talk to us
opens GitHub issues, Discord, X, and email. Official Design marks from
VocaHQ/.github are vendored as-is and painted with iron-white ink.

* fix(ui): unique About Open names and pin Design SVG hashes

Open buttons on About still say Open. Each one now uses its tooltip as
the accessible name so a screen reader can tell vocahq.com from
vocalinux.com from Latest Release.

The Talk-to-us SVG test pins sha256 of the four official marks from
VocaHQ/.github 61c8eee.

* fix(ui): circular app icon and About source code link

The launcher and About header used the square teal fill. Switch
both to the circular family logo (voca-logo.svg). Website stays
vocalinux.com; Source code next to it opens the GitHub repo.

* fix(appimage): pin pywhispercpp to the version install.sh declares

* fix(appimage): pin pywhispercpp at both install sites

* test(docs): match AGENTS.md uv.lock wording

#727 dropped "unused until those phases land". The test still
looked for that line, so any PR that runs Python tests against
current main failed. Pin the current contract instead: uv.lock
is authoritative, and requirements/*.txt are not hand-edited.

---------

Co-authored-by: Sergey Silaev <hello@8am.dev>
2026-08-23 00:50:40 -07:00
Jatin K MalikandGitHub d8ff7052cd fix(settings): stop Test Dictation calling a missing model "no speech" (#702)
* fix(settings): stop Test Dictation calling a missing model "no speech"

start_recognition now returns whether listening actually began. Test
Dictation checks that before starting its timer, so a missing or
auto-paused model gets a real error instead of "No speech detected".

* fix(settings): reconfigure Test Dictation against the live engine

UI matching config.json is not enough when speech_engine is still on
another engine or model size. Compare those too before the listen timer.

* fix(tests): keep settings_dialog lookups on one module

_load_settings_dialog() restored sys.modules but left
vocalinux.ui.settings_dialog pointing at the private reload.
Python 3.9/3.10 mock follows that attribute, so
test_whisper_list_and_delete patched a different copy than it
imported and the expanduser stub hid the real cache.

Point the package attribute back at the restored module, and
patch the imported module object in the deletion test.
2026-08-18 17:59:57 -07:00
Jatin K MalikandGitHub fd23ef0d9d feat(audio): add a family dictation tone picker (#707)
Settings → Audio can pick start/stop cues from the shared catalog
(lift, flick, ember, step, voca, soft, chirp, scale, drop, glass, off).
New installs and any missing or unknown sound_effects.tone value use
voca. A saved id, including off, is left alone.

The old start_recording/stop_recording files stay on disk. They are
no longer the implicit default. Error cues still follow the existing
enable switch.
2026-08-18 17:46:55 -07:00
Jatin K MalikandGitHub 994dda10a6 fix(install): leftover review notes from #700 and #705 (#706)
* fix(install): leftover review notes from #700 and #705

The scratch dir now holds pip logs and model download temps, so a failed
run has something to inspect. Dep, venv, leftover-process, and pip
failures use exit codes 2/3/4 instead of a blanket 1. --help says the
transcript is written during installation, and the tag hint is
--tag=<release>.

AGENTS.md no longer claims the hash-pinned exports are consumed (they
wait for phases 2/3/5). Docs add a just install line and
vocalinux[vosk] next to the PyPI commands. Settings points at
--engine=whisper. The whisper.cpp fallback installs ".[vosk]".

Fixes leftover review notes from #700 and #705.
Refs #701.

* fix(install): do not let pip build PyGObject from sdist

Fresh --auto --skip-system-deps died on pygobject-3.56.3 even with
distro python3-gi present. Install the other runtime deps from
pyproject.toml, then pip install --no-deps. Extra installs use the
extra's packages only, not pip install ".[extra]".

Fixes leftover review notes from #700 and #705.
Refs #701.

* fix(install): tolerate unset XDG_SESSION_TYPE under set -u

--auto with DISPLAY but no login session died on
XDG_SESSION_TYPE: unbound variable. The session probe now uses
${XDG_SESSION_TYPE:-}, ${WAYLAND_DISPLAY:-}, and ${DISPLAY:-}.
2026-08-18 17:41:37 -07:00
23ca16e317 fix(tests): stop test_whispercpp_advanced_persistence clobbering the real config (#694)
TestTypedAccessors builds its ConfigManager with __new__ and, unlike
TestConfigManager, never patches CONFIG_DIR/CONFIG_FILE. That is fine
for the accessor tests, but test_whispercpp_advanced_persistence calls
save_config(), which writes the synthetic fixture from setUp over the
developer's real ~/.config/vocalinux/config.json — running the suite
silently wiped every personal setting. Caught by making the real file
read-only and running the suite with live logging; it is the only
write to the real path in the entire suite.

Redirect the paths to a temp directory for this test, the same way
TestConfigManager's setUp already does.

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-08-17 23:32:53 -07:00
Kacper PaczosandGitHub 5a978d9875 fix(settings): report failed model downloads instead of claiming success (#690)
Both modal download paths ran _apply_settings_internal() on a worker
thread and then unconditionally marked the progress dialog complete.
Since _apply_settings_internal() swallows every exception internally,
a download that failed or was cancelled still ended with "Complete!",
the dedicated except blocks below (the "Download cancelled" and
Whisper-not-installed branches) were unreachable, and the swallowed
path built and ran a Gtk.MessageDialog from the download thread, off
the main loop.

Give _apply_settings_internal() a raise_errors flag and pass it from
the two thread call sites. Their handlers become reachable and already
report through the progress dialog via GLib.idle_add, so failures now
surface with the real reason and no Gtk work happens off the main
loop. Main-loop callers keep the old contract: swallow, show the error
dialog, return False.
2026-08-17 23:32:42 -07:00
Jatin K MalikandGitHub 279d99fea0 fix(whisper): honor bundled GPU libs and skip software Vulkan devices (#674)
* fix(whisper): use bundled GPU libs and skip software Vulkan devices

AppImage 0.15.0 shipped a CPU-only pywhispercpp wheel, so the Settings
GPU picker and host vulkaninfo never moved inference off the CPU. Rebuild
pywhispercpp with Vulkan in the AppImage, log the libraries that actually
load, skip llvmpipe in auto-select, and document how to verify.

Fixes #604

* fix(appimage): pin gcc/g++ for Vulkan pywhispercpp rebuild

Hosts that default cc/c++ to clang fail CMAKE's C++ test because clang
does not search gcc's libstdc++ path. Default the rebuild to gcc/g++.
2026-08-16 18:29:07 -07:00
Jatin K MalikandGitHub 32a17a9659 chore: point GitHub URLs at VocaHQ (#696)
Repo links, install one-liners, and release checks now use
github.com/VocaHQ/vocalinux. The site mentions VocaHQ without a redesign.

The Codeberg mirror, personal email, and X handle are unchanged.
2026-08-16 15:22:22 -07:00
Jatin K MalikandGitHub c3a9695a5b fix(audio): open stereo mics at native channel count (#673)
Prefer 2ch before 1ch when PortAudio reports two or more input
channels. Intel SOF digital mics (the Raptor Lake device in #666)
often only support stereo at the PCM; opening them as converted
mono can abort with heap corruption right after capture starts.

Mono-only devices are still never probed with 2 channels (#567).
2026-08-13 00:04:55 -07:00
00680c35c5 fix(installer): gate util-linux-extra to Ubuntu 24.04+ (#635)
* fix(installer): gate util-linux-extra to Ubuntu 24.04+

util-linux-extra does not exist on Ubuntu 22.04 (package split landed in
24.04). Listing it unconditionally in APT_PACKAGES_UBUNTU breaks
install.sh on 22.04-based distros such as Linux Mint.

Fixes #526

* fix(installer): probe util-linux-extra via apt-cache

Replace Ubuntu VERSION_ID gate with apt-cache show probe (per review
feedback). Handles Ubuntu derivatives (Mint, Zorin) whose VERSION_ID
does not match the noble base where util-linux-extra exists.

Fixes #526

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-08-12 23:49:12 -07:00
Jatin K MalikandGitHub ecd5839a13 feat(settings): delete unused downloaded speech models (#671)
* feat(settings): delete unused downloaded speech models

Add a Remove Model dropdown on the Speech Model page so whisper.cpp,
Whisper, and VOSK downloads can be deleted from Settings. The currently
selected model is marked in use and cannot be removed until you switch
away from it.

Fixes #650

* fix(settings): move unused model deletion below the picker

Unused downloads is its own group under the model info card. Only leftover
files appear, each with a Delete button. The group hides when empty.

* fix(settings): collapse unused downloads into an expander

Keep leftover models behind one row so a long list does not stretch the
Speech Model page. Expand to delete files one at a time; the list scrolls
once it grows past a few entries.

* fix(settings): keep leftover model list from clipping in the expander

Wrap the unused-downloads list so the expander can show a few rows and
scroll the rest, instead of painting only the first leftover.
2026-08-12 23:42:36 -07:00
Jatin K MalikandGitHub cfe93f0397 feat(settings): searchable language combobox (#672)
* feat(settings): make language selector a searchable combobox

Turn the Speech Model language picker into a ComboBoxText with an
entry and GtkEntryCompletion so typing filters the 34+ language list
instead of scrolling the whole alphabet.

Fixes #652

* fix(settings): use ComboBoxText text/id column order for language search

Gtk.ComboBoxText stores display text in column 0 and the id in column 1.
Point EntryCompletion at the text column so typing "span" lists Spanish.
2026-08-12 23:32:08 -07:00
Jatin K MalikandGitHub 73ae0def31 fix(tray): reuse Settings and Logs windows instead of duplicating them (#669)
A second tray click on Settings, About, or View Logs now focuses the
existing window instead of opening another copy.

Fixes #653
2026-08-12 23:14:43 -07:00
Jatin K MalikandGitHub 50e9200a1e fix(settings): separate Close from dictation test controls (#670)
Add a horizontal separator between the sidebar Test Dictation
controls and the in-window Close button so Close reads as dialog
chrome rather than part of the testing section.

Fixes #651
2026-08-12 23:12:16 -07:00
Jatin K MalikandGitHub 36b2d923c6 fix(ibus): restore XKB layout after scoped injection on X11 (#665)
Scoped IBus injection (#457) temporarily switches to the Vocalinux
engine but only restored the previous IBus engine, not the XKB map.
On X11 that left non-US layouts (e.g. Brazilian ABNT2) stuck on us
after dictation. Capture XKB before the switch and re-apply it in
the inject_text finally block (Wayland remains a no-op via #474).

Fixes #664
2026-08-11 02:32:46 +05:30
Jatin K MalikandGitHub c337d8f56d feat(shortcuts): default to hold Right Alt (push-to-talk) for new installs (#648)
* feat(shortcuts): default to hold Right Alt push-to-talk on first install

Double-tap Ctrl conflicts with many desktop and app shortcuts, so new
installs now use hold Right Alt (Option) in push-to-talk mode. Existing
config.json values are preserved unchanged.

Updates README, user guide, AGENTS.md, first-run dialog, website copy,
and tests to match the new defaults.

* refactor(shortcuts): trim redundant default-change test and comments

Drop the fresh-install assertion that duplicated DEFAULT_CONFIG coverage,
remove the duplicated Right-Alt rationale from config_manager, and drop a
vacuous source-string check from the settings shortcuts test.

* fix(shortcuts): align install.sh seeds and preserve legacy toggle mode

install.sh still wrote ctrl+ctrl without mode, so merges with the new
push_to_talk default became hold-Ctrl. Seed right_alt + push_to_talk for
new installs, and migrate existing shortcut-only configs to mode=toggle
so prior behavior is unchanged.

* style(config): fix black formatting after shortcuts migration

* fix(shortcuts): preserve legacy defaults when shortcuts section is missing

Existing config files without a shortcuts section previously inherited
ctrl+ctrl toggle from DEFAULT_CONFIG. Pin those historical defaults on
upgrade so the new first-install Right Alt push-to-talk defaults do not
silently change behavior.

* docs(agents): unwrap hard-broken dictation control paragraph

Keep the AGENTS.md paragraph as a single line like the surrounding prose.
2026-08-08 00:07:54 -07:00
06e8809c2a feat(updates): notify when a newer GitHub release is available (#645)
* feat(updates): notify when a newer GitHub release is available

Check the configured release channel shortly after startup and every six
hours. When an update exists, show a tray menu item and a green New badge
on Settings → About, plus one desktop notification per version, so users
can open release notes and update themselves.

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

* refactor(updates): slim UpdateMonitor after ponytail review

Drop unused APIs (tick/check_now/version injector/last_* state), integer-only
GLib scheduling, and the local demo launcher. Keep tray notify-send local to
avoid coupling to recognition_manager.

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

* fix(updates): address Bugbot and harden notify persistence

Persist last_notified_version only after notify-send spawns successfully,
and sync the tray Update Available item from About-page checks without
re-notifying. Also merge latest main and cover the new paths in tests.

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

* test(tray): pin config mock for update-notification assertions

CI on 3.9/3.10 showed empty MagicMock set call history after a successful
notify-send spawn. Pin tray.config_manager to the test mock and record set()
via side_effect so persistence checks are reliable across Python versions.

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

* fix(updates): clear pending About update on failed lookup

A failed About check hid the New badge but left _pending_update set, so
clearing settings search could revive the badge while status still showed
the failed lookup. Clear dialog pending state on failure (tray unchanged)
and restore the badge via _set_about_update_badge.

* fix(updates): ignore stale UpdateMonitor results after channel switch

Bind the channel for each background check and discard the callback when
the configured channel changed mid-flight, then re-check immediately —
matching About-page stale-channel handling so the tray cannot show the
wrong channel's update state.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
2026-08-08 00:04:39 -07:00
4a1d3ed9df fix(clipboard): text-only reads and safe overlapping restore (#646)
* fix(clipboard): text-only reads and safe overlapping restore

Force wl-paste/xclip to request text MIME types so image/file
clipboards are not decoded and "restored" as corrupted text.
Stop treating xclip's "target not available" as an empty clipboard.

Coordinate overlapping ydotool pastes with a generation counter and a
pending restore target so a second paste within the 300ms window
restores the original pre-injection content, not intermediate text.

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

* refactor(clipboard): simplify restore ownership after ponytail review

Bump the restore generation only after a successful clipboard overwrite
so a failed copy cannot cancel an in-flight restore. Clear pending
target on paste failure unconditionally. Drop redundant state reads in
the restore thread and the artificial stale-restore unit test.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
2026-08-06 00:01:44 -07:00
a15baa8170 fix: restore clipboard after ydotool clipboard-paste injection (#588)
* fix: restore clipboard after ydotool clipboard-paste injection

When ydotool is used on Wayland to inject non-ASCII text (Arabic,
accented letters, CJK, etc.), the injection path copies the text to
the clipboard and simulates Ctrl+V. This permanently overwrites whatever
the user had on their clipboard, which is surprising and disruptive —
especially for users dictating in languages that always trigger this path.

This commit adds two changes:

1. `_read_clipboard()` — reads the current clipboard content using the
   first available tool (wl-paste, xclip, xsel), returning None if the
   clipboard is empty or unreadable.

2. `_inject_via_clipboard_paste()` now saves the clipboard before
   overwriting it and schedules a background thread to restore it 300 ms
   after the Ctrl+V paste completes. The delay gives the target application
   time to process the paste before the clipboard content changes.

The previous note "there is no safe race-free way to restore" was
accurate in the strict sense, but a best-effort restore after a short
delay is far better than never restoring, and covers all practical use
cases (the user is not expected to trigger another paste within 300 ms of
a dictation commit).

Fixes the UX issue where every dictation commit silently destroyed the
user's clipboard content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* tests: add unit tests for clipboard save/restore (PR #588)

15 tests covering _read_clipboard() (tool fallback chain, error handling,
UTF-8 decoding) and the restore behaviour in _inject_via_clipboard_paste
(content restored after delay, no restore on empty clipboard or paste
failure, daemon thread assertion).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* style: apply ruff formatting to test_clipboard_restore.py

Fixes CI lint failure — ruff format requires line length ≤ 88 chars.
No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* style: apply black formatting to fix CI lint check

ruff format and black disagree on some style choices; the CI runs black.
No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* fix: use text=True in _read_clipboard and respect copy_to_clipboard setting

Two issues:

1. _read_clipboard() called subprocess.run without text=True, meaning
   stdout was bytes and .decode() was called. Existing tests that mock
   subprocess.run return stdout="" (str), causing AttributeError on
   .decode(). Switch to text=True + encoding/errors params so stdout is
   always a str and no .decode() is needed.

2. When the user has copy_to_clipboard=true, inject_text() copies the
   dictated text to clipboard after injection. The restore thread in
   _inject_via_clipboard_paste was firing 300ms later and overwriting
   that with the old content, silently breaking the setting. Skip the
   restore when _should_copy_to_clipboard() returns True.

Tests: adds test_no_restore_when_copy_to_clipboard_setting_enabled,
updates all _read_clipboard mock stdout to str (was bytes).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* fix: Python 3.9 compat and cover restore-failure branch

Use Optional[str] instead of str | None for _read_clipboard return type
so the code runs on Python 3.9 (PEP 604 union syntax requires 3.10+).

Add test_restore_failure_is_handled_gracefully to exercise the else-branch
inside the _restore() closure, covering the two previously uncovered lines
reported by Codecov.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* fix: restore clipboard on paste failure and handle empty clipboard

Fix 1 (comment #1): when Ctrl+V simulation fails the clipboard was already
overwritten with dictated text but the original content was silently lost.
Now restore it immediately in the except block before returning False.

Fix 3 (comment #3): wl-paste and xclip signal an empty clipboard with a
non-zero exit code, causing _read_clipboard() to return None and skip the
restore — leaving dictated text in the clipboard forever. Detect the
"Nothing is copied" / "target STRING not available" stderr patterns and
return "" so the restore path can clear the clipboard back to empty.

Tests added for both behaviours; existing test updated to assert the
correct post-fix behaviour for paste failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* test: cover paste-failure branch when clipboard is unreadable

Add test for the case where Ctrl+V fails and previous_clipboard is None
(clipboard was unreadable), ensuring the restore is correctly skipped.
Closes the 1298->1300 branch gap reported by Codecov.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* test: cover wl-paste fallback on X11 (XWayland) path

Add a test for the `not host_is_wayland and shutil.which("wl-paste")`
branch in _read_clipboard(), which was showing as yellow/red on Codecov.
Simulates an X11 session with wl-paste available (XWayland environments).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* fix: proper clipboard clear and race guard on restore

Fix 5 (comment #5): add _clear_clipboard() that uses each backend's
native clear command (wl-copy --clear, xsel --clear, xclip with empty
input) instead of passing "" to _copy_to_clipboard(). Called when
previous_clipboard is "" to truly clear rather than set an empty offer.

Fix 6 (comment #6): before restoring, read the clipboard inside the
_restore() thread and skip if it no longer holds the injected text —
protecting against the user manually copying something else during the
300ms window.

Tests added for _clear_clipboard() per backend and for both new
restore-path behaviours. Existing restore tests updated to use
side_effect lists since _read_clipboard() is now called twice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

* test: cover remaining _clear_clipboard() branches for Codecov

Add tests for:
- xclip path in _clear_clipboard() (line 931)
- health-check skip (line 911)
- exception fallthrough to next tool (line 947)
- _clear_clipboard() called immediately when paste fails with empty
  previous clipboard (line 1354)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg9NpTr6QaeXEyEBc5fvvz

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-08-05 22:08:13 -07:00
5d06d21838 fix(whisper): use CUDA device 0 when pywhispercpp is CUDA-backed (#636)
* fix(whisper): use CUDA device 0 when pywhispercpp is CUDA-backed

Vulkan GPU enumeration on hybrid laptops lists the iGPU as GPU0 and the
dGPU as GPU1, but CUDA ordinals always place the first NVIDIA GPU at 0.
Auto mode and the settings UI were passing Vulkan indices into CUDA
pywhispercpp, which silently fell back to CPU inference.

Detect the actual pywhispercpp backend before selecting gpu_device and
map CUDA installs to device 0. Vulkan-backed installs keep Vulkan indices.

* test(whisper): cover CUDA gpu_device selection branches for codecov

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-08-05 21:53:23 -07:00
7a5ddfde38 fix(ibus): restore engine after register_component teardown (#558) (#643)
* fix(ibus): restore engine after register_component teardown

Stopping the Vocalinux IBus engine process destroys its
register_component registration. IBus then runs check_global_engine(),
which only searches register_engine_list and treats XML engines like
xkb:es::spa as missing, clearing GlobalEngine even when Vocalinux was
never selected. Capture a restorable engine before teardown and
reselect it afterward so dead keys keep working after quit.

Fixes #558

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

* fix(ibus): wait for engine exit before restoring GlobalEngine

Adversarial review of #643 found that stop_engine_process() returned
right after SIGTERM, so switch_engine() could race ahead of IBus
check_global_engine() and still leave No global engine. Wait for the
process to exit (SIGKILL fallback), retry engine restore, capture a
shutdown fallback during prepare_engine, and re-apply XKB after the
post-teardown engine switch.

Addresses review of #558 / #643.

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

* fix(ibus): prefer live engine over stale warmup on quit

Bugbot on #643: stop() restored the warmup-cached engine even when the
user had switched input source afterward, and inject_text refused to
refresh that cache once set. Prefer the live non-Vocalinux engine at
quit; fall back to the cache only when stuck on vocalinux/unavailable.
Always refresh the shutdown fallback from inject_text.

Also cover SIGKILL teardown escalation and restore-retry failure paths
to lift patch coverage.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
2026-08-05 21:41:10 -07:00
45acb10d13 fix(appimage): ship transitive GI typelibs for non-Debian hosts (#637)
* fix(appimage): ship transitive GI typelibs for non-Debian hosts

The 0.15.0 AppImage pruned girepository down to a partial allowlist and
dropped xlib/Dbusmenu/GModule. On Ubuntu the missing typelibs fell back
to /usr/lib/x86_64-linux-gnu/girepository-1.0; on openSUSE/Fedora that
path does not exist, so Gtk failed to import and startup reported
missing GTK3/AppIndicator (#585).

Keep linuxdeploy's typelib extras, seed the full transitive set, bundle
AppIndicator/Notify shared libs, and smoke-test GI imports with host
typelibs hidden.

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

* style(test): format appimage packaging regression test

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

* fix(ci): avoid libappindicator/ayatana apt conflict

Ubuntu packages libappindicator3-1 and libayatana-appindicator3-1
conflict. Rely on gir1.2-* to pull a single implementation and keep
libdbusmenu/libnotify explicit for bundling.

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

* fix(ci): install gir1.2-dbusmenu-glib for AppImage typelib seed

Dbusmenu-0.4 is a required transitive typelib for AppIndicator; without
the GIR package the build fails the required-typelib check.

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

* fix(appimage): require only one AppIndicator typelib variant

Runtime accepts AppIndicator3 or AyatanaAppIndicator3. Treat them as
alternates in the AppImage typelib check so single-stack build hosts
do not fail require_all.

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

* fix(appimage): prefer Ayatana after rebase onto main

Align AppImage typelib seeding and GI smoke order with main's
Ayatana-first tray preference from #621/#638.

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

* fix(appimage): accept lowercase AyatanaAppindicator3 typelib

Match tray_indicator.py's three-step fallback so AppImage builds and
GI smoke succeed on hosts that only ship AyatanaAppindicator3-0.1.

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

* fix(main): accept lowercase AyatanaAppindicator3 in dep check

Align check_dependencies with tray_indicator.py and the AppImage GI
smoke path so a lowercase-only typelib host does not fail at startup.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
2026-08-03 18:37:48 -07:00
9c340029e1 docs(packaging): prefer Ayatana AppIndicator in Fedora/Arch hints (#638)
* docs(packaging): prefer Ayatana AppIndicator in Fedora/Arch hints

Follow-up to #621: align docs, AUR depends, package map, dependency
check messages, and troubleshooting copy with the Ayatana-first install
path so manual installs and AUR users get a working KDE tray icon.

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

* style(main): black-format Ayatana install hint lines

CI black --check failed on the multi-line logger.error calls for the
Fedora/Arch package hints.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
2026-08-04 04:53:34 +05:30
afc4b37c80 Fix missing KDE tray icon by preferring Ayatana AppIndicator (#621)
* Fix missing KDE tray icon by preferring Ayatana AppIndicator

Fedora and Arch installs pull libappindicator-gtk3, Canonical's original
AppIndicator library that hasn't had a real release since ~2013. It loads
fine and Indicator.new_with_path()/set_status() run without raising, but
it never registers a StatusNotifierItem with KDE's StatusNotifierWatcher,
so the tray icon silently never shows up on Plasma. Confirmed on Fedora 44
/ Plasma 6.7 by checking RegisteredStatusNotifierItems over D-Bus directly
before and after the fix.

The actively maintained Ayatana fork registers correctly and is already
what the Debian, Ubuntu, openSUSE and Gentoo branches of install.sh
prefer. Reorder the gi import in tray_indicator.py to try
AyatanaAppIndicator3 first and fall back to the legacy AppIndicator3
typelib only if neither Ayatana variant is available.

Update install.sh so Fedora and Arch installs pull the Ayatana package
too, via a shared install_preferred_appindicator() helper that falls back
to the legacy package if Ayatana isn't in the repos (e.g. RHEL/CentOS
without EPEL).

* Add tests for the AppIndicator/Ayatana import fallback chain

The reordered try/except in tray_indicator.py wasn't exercised by the
existing tests: they replace gi.repository with a MagicMock, which
auto-creates any attribute on access and so can never raise the
ImportError the fallback chain is built to catch.

Add a small test class that swaps in a plain object exposing only a
chosen subset of AppIndicator symbols, so importing anything else raises
a real ImportError, and verify each of the three branches (Ayatana,
lowercase Ayatana variant, legacy AppIndicator3) resolves correctly.

* Retry Ayatana install even when legacy AppIndicator is already present

install_preferred_appindicator() previously skipped the Ayatana install
whenever either package was already present, so re-running install.sh
on an existing Fedora/Arch setup with libappindicator-gtk3 from a prior
install would never pick up Ayatana, leaving the KDE tray broken for
exactly the users this fix targets. Now it only short-circuits when
Ayatana itself is installed, tries Ayatana first regardless of legacy
presence, and only falls back to a warning about the already-installed
legacy package if the Ayatana install fails.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-08-04 03:33:40 +05:30
Jatin K MalikandGitHub 27eb1d3807 feat(settings): in-app update checker with stable/nightly channels (#631)
* feat(settings): add in-app update checker with stable/nightly channels

Move About into the Settings sidebar and check GitHub Releases for updates.
Stable uses /releases/latest; Nightly follows nightly-YYYY-MM-DD tags.
Remove the standalone About dialog and wire tray About to Settings.

Fixes #183

* docs(readme): update About screenshot caption for Settings page

* fix(settings): address update-checker review and About polish

Discard stale in-flight results when the update channel changes, page
through GitHub releases for nightly tags, restore the About logo next
to the title, and replace the em dash in the app description.

* docs(screenshots): add About update-checker state captures

Host PR review screenshots on the branch so GitHub can render them
from raw.githubusercontent.com (Cursor artifact URLs 403 in PR bodies).

* fix(updates): recognize .devYYYYMMDD nightlies and clear Open style

Parse CI nightly versions like 0.14.2.dev20260802+sha when checking for
updates, and drop the suggested-action style from Open after a failed
GitHub lookup.
2026-08-02 20:25:40 -07:00
Jatin K MalikandGitHub 20375f8657 fix(audio): filter unsafe virtual devices that crash capture (#629)
* fix(audio): filter unsafe virtual devices that crash capture

This is a defensive fix for #624 heap corruption seen when PortAudio opens PipeWire pseudo-devices such as DeepFilterNet, default, pipewire, paplay, monitor, null, dummy, or filter-chain sources. The root cause is not fully proven, but avoiding explicit capture from these unsafe virtual rows should keep dictation on real microphones while still allowing System Default through device_index=None.

If this still crashes with a physical microphone selected, please confirm with the exact physical device name and audio stack details so the capture path can be narrowed further.

Fixes #624

* fix(settings): sync audio device fallback with raw name restore

Address Bugbot review on #629: match legacy "(default)" saved names and
clear stale config/engine state when a filtered device falls back to
System Default.

* fix(audio): keep system default capture off pseudo device indices

Stop converting System Default to an explicit PortAudio index in the
mic test, and fall back to host default capture when every enumerated
input is filtered as unsafe.

* test(audio): expect system-default fallback on reconnect

* test(audio): cover system-default fallback paths for capture
2026-08-02 19:41:34 -07:00
Jatin K MalikandGitHub 4f2a91055e feat(tray): allow disabling missing tray warning dialog (#628)
* feat(tray): allow disabling missing tray warning dialog

Fixes #620

* test(tray): stabilize missing-watcher opt-out coverage

Default the D-Bus ListNames mock to include StatusNotifierWatcher so
TrayIndicator construction no longer opens the warning dialog during
setUp, and bind the don't-show-again checkbox test to the module Gtk mock.

* test(tray): make missing-watcher opt-out tests deterministic
2026-08-02 19:41:22 -07:00
Jatin K MalikandGitHub 2152dbb90d fix(whispercpp): skip unsupported context_params on pywhispercpp 1.4 (#626)
* fix(whispercpp): skip unsupported context_params on pywhispercpp 1.4

Fixes #625

* test(whispercpp): cover context_params signature inspection failure
2026-08-02 19:41:12 -07:00
Jatin K MalikandGitHub ced8620e69 fix(injection): stop typing test during wtype probe (#627)
Fixes #622
2026-08-02 19:41:02 -07:00
Scrates1andGitHub 679ff9161b fix(ibus): require a restorable engine for scoped injection (#623)
* fix(ibus): require a restorable engine for scoped injection

* fix(ibus): preserve registered XKB restore targets

Resolve GNOME XKB sources against registered IBus engine IDs so bare-XKB Wayland sessions keep scoped injection without fabricated restore names. Keep unknown states on the safe fallback path.

* test(ibus): cover GNOME restore target failures
2026-08-02 19:09:38 -07:00
27ecf880a8 fix(settings): restore custom shortcut Record/Set controls (#619)
* fix(settings): restore custom shortcut Record/Set controls

Gtk.Widget.show_all() is a no-op while no_show_all is True, so the
contextual Custom Shortcut row never appeared after selecting it in the
searchable settings UI. Clear the flag before showing and restore it on
hide.

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

* fix(settings): sync custom row when reverting shortcut separators

Selecting Custom Shortcut then a combo group separator restored the
preset id but left Record/Set visible. Reuse _sync_shortcut_selection_ui
so combo and custom-row visibility stay aligned.

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

* fix(settings): clear stale Record/Set hint on separator revert

Selecting Custom Shortcut sets a temporary info hint; clicking a combo
separator already restored the row visibility but left that hint in
place. Refresh the mode description after syncing.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
2026-07-30 08:49:25 -07:00
Jatin K MalikandGitHub 3e78a9bc4e feat(settings): move dictation controls into sidebar (#618)
Keep the persistent test and close actions accessible without an awkward
bottom action row, while using portable sidebar icons.
2026-07-29 01:34:57 -07:00
cc69c7ebda fix(languages): map English (India) to Whisper code en (#617)
* fix(languages): map en-in to Whisper code en

English (India) used the catalog id as the Whisper language argument,
which is invalid. Resolve Whisper/whisper.cpp/remote_api codes via the
SUPPORTED_LANGUAGES whisper field so en-us and en-in both become en.

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

* test(languages): cover whisper language resolver fallbacks

Exercise unknown catalog ids and legacy en-us fallback paths so
codecov/patch covers resolve_whisper_language fully.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
2026-07-28 21:31:02 -07:00
28bed4c1fd feat(languages): expand speech catalog with Hungarian and more (#616)
Expose Hungarian and other high-demand Whisper languages in Settings/CLI,
wire official VOSK models where Alphacephei ships them, keep CLI choices
derived from SUPPORTED_LANGUAGES, and update the languages marketing page
with honest per-engine support.

Fixes #565

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
2026-07-29 07:32:37 +05:30
Jatin K MalikandGitHub ca24264c85 fix(audio): stop Bluetooth mic probing from corrupting the heap (#599)
* fix(audio): prevent Bluetooth mic probe heap corruption

Open PortAudio streams only once when negotiating capture format, and
always stop_stream() before close(). Separate channel/rate probing was
aborting on Bluetooth SCO devices (e.g. FreeBuds) with malloc corruption.

Fixes #567

* refactor(audio): open capture stream once instead of probe-close-reopen

Adversarial review of the first fix found it still closed the probe
stream and immediately reopened the device — the same rapid open/close
pattern blamed for the Bluetooth SCO heap corruption, just fewer cycles.

_open_capture_stream() now returns the successfully negotiated stream
and all three callers (mic test, recording, reconnect) use it directly,
so each capture session performs exactly one PortAudio open. Also drops
the dead known_working_rate parameter.

* test(audio): cover negotiation fallback paths for codecov patch

Add tests for mic-test, recording, and reconnect fallback branches when
_open_capture_stream returns no stream, plus Bluetooth settle-on-failure
and empty-read reconnect cleanup. Brings patch diff coverage above 80%.
2026-07-29 02:49:55 +05:30
8322306aac feat(dictation): append trailing space after completed transcription (#608)
* feat(dictation): append trailing space after completed transcription

Fix cross-session glueing ("Hello.This") for push-to-talk and toggle
by appending a trailing space to each injected segment instead of relying
on in-session leading-space memory that is cleared on IDLE.

Adds an optional Settings toggle (default on) and preserves legacy
leading-space behavior when disabled. Newlines from voice commands are
left without a trailing space.

Fixes #605

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

* fix(dictation): honor trailing-space setting from disk live

Read append_trailing_space from config.json on each injection so the
Settings toggle applies without restart. main() and TrayIndicator each
own a ConfigManager, so an in-memory read missed Settings writes — same
pattern as TextInjector.copy_to_clipboard.

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

* test(dictation): align capitalize tests with trailing-space default

Update auto-capitalize expectations to include the default trailing
space, and add edge-case coverage for legacy mode, newlines, empty
input, and config-read failures so patch coverage clears 80%.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
2026-07-29 02:00:18 +05:30
7422bf927e feat: auto-capitalize sentences after punctuation (#554)
* feat: auto-capitalize sentences after punctuation

Add capitalize_sentences() function that uppercases the first letter of
dictated text and after sentence-ending punctuation (., !, ?) followed by
whitespace. Preserves URLs, decimals, and abbreviations without trailing space.

- Config option text_injection.auto_capitalize (default: true)
- Settings toggle in General tab to opt out
- 17 tests covering edge cases

Fixes #553

* style: black-format auto-capitalize helpers for CI lint

Fixes the Lint Python check failure on the auto-capitalize PR.

* refactor: make auto-capitalize Vosk-only with clear UI indication

- Gate auto-capitalize feature to Vosk engine only (Whisper outputs proper casing)
- Update settings tooltip to explain Vosk-only behavior
- Update subtitle to show '(Vosk only)' for user clarity

* test(main): cover Vosk-only auto-capitalize injection path

Add callback wiring tests for enabled/disabled auto-capitalize on Vosk
and the Whisper skip path so codecov patch coverage clears the 80% bar.

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

* refactor(settings): move Auto-Capitalize to Recognition tab

Auto-capitalize is dictation output post-processing, not application
lifecycle behavior, so it belongs alongside Voice Commands rather than
with autostart/clipboard settings in General.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
2026-07-29 01:39:15 +05:30
8c76578200 fix(ui): flatten settings info notices (#615)
Drop the thick left accent strip on .info-box helpers so they read as
muted flat surfaces with a thin full border, matching the preference
cards instead of looking like callout banners.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
2026-07-29 01:15:09 +05:30
Benjamin EiseleandGitHub 29db3a05a4 fix(ibus): keep engine teardown correct when parent destroy fails (#613)
* fix(ibus): keep engine teardown correct when parent destroy fails

`do_destroy` passed `super().do_destroy` to `_handle_engine_destroy`.
PyGObject binds that vfunc to the GType rather than to the instance, so
calling it with no arguments raises:

    TypeError: IBus.Object.destroy() takes exactly 1 argument (0 given)

This fired on every engine teardown. The exception escaped
`_handle_engine_destroy` before its return, so neither
`_active_instance = ...` nor `_focus_event.clear()` in the caller ran,
leaving a destroyed engine registered as active with focus still set --
the two values `_ibus_ping_status` (#523) reads to decide commit
readiness.

Two changes:

- Bind the parent destroy to the instance, falling back to
  `IBus.Object.destroy(self)` only when the no-arg call raises TypeError,
  so this keeps working if PyGObject binds the vfunc correctly later.
- Guard the parent destroy call so no failure there can skip teardown.

Fixes #606

* refactor(ibus): extract parent-destroy workaround into a testable helper

Codecov flagged the new code as uncovered (36% patch, below the 80%
target). The whole `if IBUS_AVAILABLE:` branch of `do_destroy` is
unreachable from the suite, which only ever exercises that method with
IBUS_AVAILABLE False -- the single line it replaced was already
uncovered on main for the same reason.

Move the workaround into a module-level `_invoke_parent_destroy`, the
same pattern `_handle_engine_destroy` already uses to make `do_destroy`
testable, and bind it with functools.partial at the call site.

Behaviour is unchanged. Coverage of the branch goes from 0 to all but
the one-line partial, and three tests now pin the actual contract:
pass-through when the binding is correct, retry with the instance on the
GType-binding TypeError, and no masking of other exceptions.
2026-07-29 00:36:24 +05:30
Benjamin EiseleandGitHub 161129cf3c feat(text-injection): use IBus on unbridged compositors when ibus-wayland runs (#614)
* feat(text-injection): use IBus on unbridged compositors when ibus-wayland runs

The `_IBUS_UNBRIDGED_COMPOSITORS` denylist assumes nothing sits between
the compositor and ibus-daemon. Since IBus 1.5.32 that is no longer a
safe assumption: IBus ships `ibus-wayland`, which implements
`zwp_input_method_v2` and relays commits to native Wayland clients
speaking text-input-v3. Every compositor on the denylist exposes
`zwp_input_method_manager_v2`, so with the bridge running they are
bridged, and skipping IBus falls back to a slower path unnecessarily.

Add a live `pgrep -x ibus-wayland` probe and let it override the
denylist. Scoped to the denylist branch only, so GNOME and the KDE
VirtualKeyboard path (#574) are untouched.

Behaviour is unchanged when the bridge is not running, and because the
check is a live probe rather than configuration it degrades back to the
denylist by itself if the bridge dies or was never started. Cost is one
pgrep per TextInjector construction, not per injection.

Verified on Hyprland 0.56.1 with IBus 1.5.34: dictation into Electron
apps (VSCodium, Electron 39/41) and kitty commits through IBus, 303
chars in ~200ms versus 16.8s for 415 chars via `ydotool type`.

Also adds VOCALINUX_FORCE_BACKEND (ibus|wtype|ydotool|auto) as discussed
in #607. Autodetection has to infer whether IBus commits actually reach
the focused app and cannot verify it -- commit_text() reports success
even when the text is dropped -- so an escape hatch is useful when the
inference is wrong, and it makes the two paths A/B-testable without
editing code. Unknown values fall back to auto with a warning rather
than silently pinning a backend. Related to #476 and #402; happy to drop
this from the PR if it should follow their design instead.

Two existing tests asserted the unbridged outcome without pinning the
probe -- one blanket-mocks subprocess.run to returncode=0, the other
shells out for real -- so both now state the "no bridge" precondition
explicitly rather than depending on the host.

Note for users: Chromium only opens a zwp_text_input_v3 seat when
launched with --enable-wayland-ime; without it the bridge runs and the
app still receives nothing.

Closes #607

* test(text-injection): cover bridge override and forced-backend branches

Codecov flagged the new branches in _check_dependencies as uncovered
(74% patch, below the 80% target).

Adds four tests to TestCheckDependencies:

- unbridged compositor + ibus-wayland running selects IBus
- VOCALINUX_FORCE_BACKEND=wtype pins wtype where IBus would be chosen
- VOCALINUX_FORCE_BACKEND=ydotool pins ydotool even with wtype present
- VOCALINUX_FORCE_BACKEND=ibus bypasses the reachability guards, with
  neither a daemon nor a bridge available

Every line added by this PR is now covered.
2026-07-29 00:36:14 +05:30
Jatin K MalikandGitHub 4fad897f14 fix(appimage): smoke-test fixes for tray, labels, and packaging (#602)
* fix(ui): AppImage smoke fixes for tray icons, labels, and identity

Replace placeholder mic tray SVGs with opaque branded icons, show
whisper.cpp instead of Whisper_cpp, set GLib/AppIndicator identity to
Vocalinux (not main.py), and give About dialog buttons enough padding
so labels are not clipped under Adwaita.

* fix(packaging): harden AppImage build for Wayland and tray deps

Pin the build interpreter, install onnxruntime into the bundle, set
desktop Exec=AppRun + StartupWMClass, prune typelibs to an allowlist
after linuxdeploy, and stop forcing GDK_BACKEND=x11 on Wayland
(VOCALINUX_FORCE_X11=1 remains as an escape hatch). CI AppImage jobs
also pull Ayatana AppIndicator and librsvg SVG loader packages.

* fix(packaging): bundle full stdlib from base_prefix in AppImage

When build.sh runs under a venv, sys.prefix only has site-packages, so
the AppImage was missing encodings and crashed on start. Copy the
interpreter and stdlib from sys.base_prefix, force pip
--ignore-installed into AppDir, set Gdk program class to Vocalinux, and
tone down the muted tray slash so small StatusNotifier icons stay slate.

* fix(tray): use absolute icon paths so stale hicolor icons lose

XFCE StatusNotifier resolves IconName through the system theme and can
ignore IconThemePath. A leftover ~/.local/share/icons placeholder
(vocalinux-microphone-off.svg filled #e74c3c) was winning over the
AppImage's bundled icons. Outside Flatpak, pass absolute SVG paths to
set_icon_full / new_with_path instead of bare theme names.
2026-07-27 19:57:07 -07:00
e7022f83dd feat(settings): searchable sidebar settings instead of seven tabs (#601)
* feat(settings): replace tab navigation with searchable sidebar and topic pages

- Sidebar (icon + label) with Gtk.Stack replaces the 7-tab notebook
- Live settings search (Ctrl+F): filters rows in place, per-page match
  counts, dims pages without hits, empty-results state
- Topic-based pages per GNOME HIG: Dictation, Speech Model, Audio,
  Performance, Application, Advanced
- Persistent bottom status strip: recognition state, shared mic level
  bar, and Test Dictation with inline transcription output
- GPU device selection moves out of the power-user gate onto the
  Performance page; Reset to Defaults moves into the Advanced page
- Consolidated model info card (info + warning + legend in one surface)
- Contextual custom-shortcut row, in-list auto-pause empty state,
  uniform control widths, aligned group descriptions
- Esc clears search, Ctrl+W closes

* test(settings): update source-inspection tests for sidebar redesign, add search coverage

* fix(settings): index unlocked advanced controls in search

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

* fix(settings): restore selected page after clearing search

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

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
2026-07-27 14:31:39 -07:00