Commit Graph
570 Commits
Author SHA1 Message Date
775863fd30 feat(settings): add proxy support for model downloads and update checks (#909)
* feat(settings): add proxy support for model downloads and update checks

Add a Proxy page to Settings (above About) with Off / System / Manual
modes. Manual mode supports SOCKS5 and HTTPS (HTTP CONNECT) proxies
with optional username/password auth; the password is stored in
config.json in plaintext like the other settings. System mode honors
the *_proxy environment variables and falls back to the GNOME
org.gnome.system.proxy manual configuration.

The resolved proxy is applied to every in-app outbound path that
fetches models or checks updates: the shared requests download path in
recognition_manager (_stream_model_download, used by the whisper.cpp,
whisper, Vosk, Parakeet and faster-whisper downloaders) and the
release checks in update_checker. A small Test Connection button on
the Proxy page verifies reachability through the configured proxy.

SOCKS5 needs PySocks, added as a runtime dependency; uv.lock, the
requirements/*.txt exports and the Flatpak dependency manifest are
regenerated by just lock (with a fix in sync_flatpak_deps.py for
packages like PySocks that publish more than one universal wheel).

Fixes #655

* fix(proxy): bracket IPv6 hosts, escape test errors, declare pysocks for AUR

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 17:16:48 -07:00
442a646a57 feat(history): add opt-in JSONL persistence for transcripts (#907)
* feat(history): add opt-in JSONL persistence for transcripts

Adds history.persist (default off) mirroring the bounded snippet list to
history.jsonl under the XDG data directory so transcripts survive
restarts. Entries carry timestamp, duration, language and model when
known. Clearing history truncates the file, turning persistence off
deletes it, and disabling history removes it so nothing is retained
anywhere. Settings gains a Keep on Disk switch and a Clear button in the
Transcription History group.

Fixes #758

* test(history): rebind real tempfile when siblings mock sys.modules

test_recognition_manager and test_speech_recognition replace
sys.modules["tempfile"] with a MagicMock at import time, so the new
persistence tests were sharing one mock-named store path in full-suite
runs. Guard the TemporaryDirectory binding the same way
test_whisper_model_info already does.

* fix(history): sweep stale temp files, survive damaged stores, and keep session fields on late commits

* fix(history): reject lone-surrogate records and survive encode errors on write

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 17:13:40 -07:00
ff57244dce ci(install): gate the remote install path end to end (#906)
* ci(install): gate the remote install path end to end

A curl|bash user takes a different path than the local install gate
covers: bootstrap outside a checkout, tag selection, clone/fetch of the
selected tag, then handoff to the tagged installer with the original
arguments. Add scripts/install-remote-test.sh, which publishes HEAD as a
tag in a local mirror, runs install.sh against it twice (fresh clone,
then fetch+reset of the existing clone), and fails closed when the
clone's HEAD is not the selected tag commit. install.sh gains a
VOCALINUX_REPO_URL override so the mirror can stand in for the public
remote. Wire a single-distro job into the distro matrix and a just
remote-install-gate recipe.

Refs #701

* ci(install): publish the gate tag as an orphan from HEAD's tree

CI checks out with fetch-depth 1, and git refuses to push or fetch a
shallow boundary commit into a ref. Build the tag on an orphan commit
created from HEAD's tree instead, and make tree equality the fail-closed
check that installer, modules and exports are this commit's files.

* test(install): force the re-run arm to prove fetch and reset

Review feedback on the remote-install gate: the second run reused a clone
already on the tag, so a broken fetch or reset would still pass. Move the
clone onto a descendant commit and delete its local tag ref before the
re-run, so only a real fetch + reset restores them. Also list the new
recipe in AGENTS.md's command guide.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 17:12:47 -07:00
6a6cc93919 feat(tray): remove Start on Login and About from the tray menu (#905)
* feat(tray): remove Start on Login and About from the tray menu

Both entries already live in Settings: the autostart switch is in the
general page and About is a dedicated sidebar page, so the tray items
are redundant. View Logs stays.

Removes the Start on Login checkbox item along with its
_update_autostart_checkbox/_on_autostart_toggled wiring and the now
unused _add_menu_checkbox helper, plus the About item and
_on_about_clicked. _show_settings_page("about") stays for the Update
Available item. autostart_manager is untouched since Settings uses it.

Fixes #654

* fix(settings): sync autostart switch with the real desktop entry

The tray's removed _update_autostart_checkbox also reconciled
general.autostart with the actual login desktop entry at startup. Move
that check into the settings load so the Start on Login switch still
reflects reality when the entry changes outside the app.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 17:12:01 -07:00
26c4f9052b fix(settings): place test dictation output above its controls (#904)
Reveal the transcription readout at the top of the sidebar footer so it
grows into the empty sidebar space above the Idle row instead of pushing
the Test Dictation button down when it appears.

Fixes #677

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 17:10:35 -07:00
4b7d57c077 feat(packaging): build thin .deb and .rpm and attach them to releases (#913)
* feat(packaging): build thin .deb and .rpm with nfpm and attach them to releases

Adds packaging/native: an nfpm config that produces a .deb and an .rpm
from the release wheel. The packages stay thin - Python, GTK, PyGObject
and the typelibs are distro Depends; only pywhispercpp and pynput are
vendored because the first is unpackaged on both distros and the second
is missing on Fedora. The Debian 12 build image and the nfpm download
are pinned in tool_checksums.txt.

just native-packages builds both in the pinned container, and
native-smoke installs the built package per distro: debian:12 and
ubuntu:24.04 exercise the .deb, fedora:42 the .rpm, then each runs
vocalinux --version plus an import smoke of the app and the vendored
extension. unified-pipeline.yml gates on the same three-distro matrix,
and release.yml attaches both arches to the release and covers them in
SHA256SUMS and the provenance manifest.

Refs #600

* fix(packaging): preserve launcher args and document Wayland input group

The launcher joined $* into an unquoted exec, re-splitting file paths
that contain spaces before argparse saw them. Exec "$@" directly and
re-quote each argument for the inner shell of 'sg input -c'.

On Wayland the packages could not grant the 'input' group membership
evdev hotkeys need; name the usermod step and its caveat in INSTALL.md
and the release notes.

* test(packaging): add return types to the native packaging tests

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 17:10:00 -07:00
Jatin K MalikandGitHub d53300841d fix(appimage): reuse the Vulkan pywhispercpp build across CI runs (#903)
* fix(appimage): reuse the Vulkan pywhispercpp build across CI runs

The x86_64 Build AppImage step spends most of its time recompiling
pywhispercpp with Vulkan. That compile depends on the base image,
tool_checksums.txt, and the pywhispercpp pin, not on the commit.
Cache the native tree under one key shared by CI, Nightly, and Release.

* fix(appimage): reject a partial Vulkan cache and key the build tools

A failed copy could leave a tree that still contained libggml-vulkan.so,
and the next run would reuse it. Check each copy, and keep the previous
cache until the new one is in place.

The cache id now also covers the pinned cmake and ninja export and the
Vulkan compile flags. The hit path is what the build calls, and a test
runs it with a stub compile command.

* chore(ci): rerun AppImage against the saved Vulkan cache

The review fix changed the native cache id, so run 37586979294
compiled pywhispercpp again and saved
appimage-native-X64-ebf478b87986e2f6b9d80c97c2f5869fe3120c2bcd48f5d8f5a65fe06ec03015.
This commit leaves the base image, tool checksums, pywhispercpp pin,
installer tool pins, and Vulkan compile flags alone.
2026-10-07 01:04:18 -07:00
af1029dc60 fix(evdev): skip pointer-motion devices in keyboard discovery (#902)
* fix(evdev): skip pointer-motion devices in keyboard discovery

find_keyboard_devices() treated any device with a nonzero EV_KEY bitmap
as a keyboard, which includes Logitech mice (G502, MX Master 3) whose
button interfaces report key bits. The hub then grabbed them (EVIOCGRAB)
and re-emitted every event, including EV_REL pointer motion, through a
paired uinput clone — piping all cursor movement through the reader
thread and showing up as periodic cursor stutter.

Discovery now excludes devices that report pointer motion (REL_X+REL_Y
or ABS_X+ABS_Y) in both the /proc/bus/input/devices parser and the
evdev.list_devices() fallback. Wheel-only devices (REL_WHEEL) and real
keyboards are unaffected, and a mouse's separate keyboard interface
node keeps working for shortcuts.

Fixes #900

* test(evdev): annotate new device-discovery tests

* chore: drop accidentally committed pr_body.md

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 00:10:50 -07:00
Jatin K MalikandGitHub 758f640787 fix(uninstall): stop deleting files in the caller's directory (#901)
* fix(uninstall): stop deleting files in the caller's directory

The curl uninstall command runs uninstall.sh from $PWD, and the script
treated that directory as a source checkout. It deleted venv, build, dist,
and Python bytecode there, then reported success.

Those paths are removed only when uninstall.sh itself lives in a Vocalinux
checkout, and only inside that checkout.

Fixes #897

* fix(uninstall): keep dev metadata and drop the false cleanup error

Tests put a pgrep and kill stub first on PATH, so they cannot signal a
Vocalinux process that is already running. egg-info cleanup stays under
src/ and the checkout root, and the bytecode walk prunes .venv. Artifact
cleanup runs before the curl clone is deleted, so an uninstall from
~/.local/share/vocalinux-install no longer prints an error and then success.
2026-10-07 00:06:37 -07:00
1d4dbe33cc chore(release): prepare v0.18.0 (#895)
* chore(release): prepare v0.18.0

Bump version surfaces for the v0.18.0 minor release: version.py,
web/package.json + lockfile, softwareVersion schema, SECURITY.md
supported-versions table, AUR pkgver/_tag (source and -bin),
AppStream metainfo release entry, and the release-history table in
RELEASE_PROCESS.md.

Docs and site copy for the delta since v0.17.0: README blurb, snap
sideload versions in README/INSTALL/UPDATE, CHANGELOG current-stable
entry, docs/UPDATE.md What's New with the 0.18 highlights table and
per-PR credits, website changelog + desktop-reliability entries, and
PRODUCT.md capability lines for Dictation Pad, per-language shortcuts,
tray history, overlay, ducking, PipeWire, portal injection, D-Bus
activation, custom dictionary, and file transcription.

* fix(release): address review findings on the v0.18.0 prep

- Keep the checked-in vocalinux-bin PKGBUILD on v0.17.0 so its real
  AppImage digests stay consistent with its sources; release.yml bumps
  pkgver/_tag and pins both-arch digests atomically at publish
- Qualify the Flatpak release note: PipeWire system-audio capture and
  RemoteDesktop portal injection are native-install paths
- Refresh dateModified on the changelog and desktop-reliability pages
- Restore the release-history table order in RELEASE_PROCESS.md
- Revert the build-regenerated web/next-env.d.ts route-types reference

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-06 14:03:21 -07:00
96e012d9bd fix(evdev): keep key suppression when /dev/uinput is write-only (#893)
* fix(evdev): keep key suppression when /dev/uinput is write-only

python-evdev opens /dev/uinput O_RDWR, but the kernel only needs write
access for the uinput setup ioctls and event injection. Installs that
still carry the original 80-dotool.rules line (MODE=0620, write-only
for the input group) therefore fail UInput() with EACCES and fall back
to ungrabbed monitoring, where the dictation shortcut is detected but
also reaches the focused application. Retry the clone on an O_WRONLY
descriptor via a small _WriteOnlyUInput subclass so suppression keeps
working there (refs #871).

Also populate _withheld_modifier lazily in _event_is_shortcut: the
AltGr-replay machinery was dead code because nothing created the
per-fd map, so a withheld modifier press was never replayed and
composition (e.g. RightAlt+e) lost its modifier.

* fix(evdev): byte-truncate clone names and cover the write-only path

- _clone_device_name now truncates the encoded name by bytes: the
  kernel limit is 79 bytes, and a long multibyte device name could push
  the " (vocalinux)" suffix past the cut so rescan would not skip the
  clone.
- _WriteOnlyUInput closes the fd explicitly when _prepare_events fails
  before the _uinput calls; the helpers close it themselves on any
  later failure.
- Tests now instantiate the real subclass with mocked OS/native calls
  (open flags, setup sequence, write/write_event/syn ordering, fd
  cleanup, name truncation).

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-01 23:45:49 -07:00
4497bfae6c fix(logs): let the View Logs dialog close via titlebar-X (#892)
* fix(logs): let the View Logs dialog close via titlebar-X

_on_response only destroyed on ResponseType.CLOSE and ignored
DELETE_EVENT, so the Logs window could not be dismissed with the
window-manager close button — unlike Settings, which handles both. A
window force-destroyed behind the dialog then crashed quit with a
BadWindow error instead of an orderly shutdown.

* test(logs): cover _on_response behavior; annotate its signature

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 05:55:33 -07:00
d11bd1bf0d feat(post-processing): pipe transcriptions through an optional postprocessing script (#479)
* feat(post-processing): pipe transcriptions through user-defined script

Add a configurable post-processing step that passes each transcription
result to an external executable via stdin and uses its stdout as the
text to type. Enables custom transforms (formatting, LLM rewriting, etc.)
without modifying Vocalinux. Falls back to original text on script
failure or timeout.

* test(post-processor): add unit tests and extract apply_post_processing

Move config-reading logic from main.py closure into apply_post_processing()
in post_processor.py so it can be tested independently. Add 11 unit tests
covering PostProcessor.process() and apply_post_processing().

* docs(post-processing): document post-processing script feature

* fix formatting of one file

* fix(post-processing): run the user script with a stripped host environment

Rebase compat: the host_process AST guard requires every subprocess
spawned for a host binary to pass env=host_env() so an AppImage's
LD_LIBRARY_PATH/GI_TYPELIB_PATH/PYTHONHOME do not leak in. Also give
apply_post_processing() a typed ConfigManager parameter and keep
text_callback_wrapper's local variable typed as str for mypy.

* fix(post-processing): preserve paragraph breaks and tighten script handling

Take script stdout verbatim instead of rstripping every trailing
newline, so the "new paragraph" voice command's break survives a
passthrough script. A single trailing newline is still dropped, but
only when the transcription had none — it is almost always just the
line terminator echo/print-style tools append.

Also stop writing transformed output to the log (it can carry
sensitive dictation into retained logs), catch only the expected
process/OS/encoding failures instead of every Exception, and add the
missing signature annotations.

* fix(main): run the post-processing script off the recognition thread

A user script may take seconds per segment, and text callbacks run on
the recognition thread that drains the bounded audio-segment queue;
stalling it drops queued dictation once the queue fills. Submit
post-processing and injection to a dedicated single-worker executor so
the backlog waits instead of losing speech, while keeping injection
order. On timeout or failure the worker falls back to the original
text, and an unexpected error is logged with a traceback and still
injects the original rather than silently dropping the segment.

Also pin the callback tests' get_str mock to no-script so they
exercise the synchronous path deterministically, and add a callback
test proving transformed text (including preserved paragraph breaks)
reaches the injector.

* docs(post-processing): annotate new callbacks and clarify stdout contract

Add the missing type annotations on the new Settings page callbacks
per the repository guide, and document that script stdout is used
verbatim (a lone trailing newline added by line-oriented tools is
dropped only when the transcription had none) and that scripts run on
a dedicated worker.

* test(main): annotate new post-processing callback tests

* fix(main): bind queued dictation to its target app and drain worker on quit

Route every transcription segment through the single post-processing
worker (a pass-through when no script is configured) so clearing the
script path mid-queue can no longer let a later segment overtake an
earlier one still running.

A segment that can sit in the backlog — a script is configured or a job
is still running — is bound to the application focused when it was
dictated; the worker drops it if focus has verifiably moved to another
application, so delayed dictation cannot land in the wrong app.

Wire the worker into the tray quit path via an on_quit hook: it clears
the acceptance flag, cancels pending jobs, and waits — bounded by the
script's own timeout — so nothing injects after the injector stops.

* fix(main): keep voice actions ordered and probe focus off the recognition thread

- Queue voice-command actions on the post-processing worker so an action
  can no longer overtake dictated text still waiting to be injected.
- Move the submit-time focus probe onto a dedicated executor; the
  recognition thread only submits the probe, and the job joins the result
  at inject time, so compositor tools can no longer stall consumption of
  the bounded audio-segment queue.
- Re-check the quit flag after the inject-time focus re-probe, then shut
  both workers down without waiting on a running script so quitting does
  not freeze on the script timeout.

* fix(main): capture submit-time focus on dedicated threads and serialize quit

- Replace the shared focus-probe executor with a fresh daemon thread per
  submission so a slow earlier probe cannot delay a later one past the
  focus change it is meant to capture; jobs read the result off a
  one-slot queue at inject time.
- Give queued voice actions the same submit-time focus binding as text
  segments so a queued shortcut is dropped instead of fired into a newly
  focused application.
- Hold injection_lock across the accept-check plus inject/action call so
  quit waits only for an in-flight injection instead of racing it.
- Queue the IDLE reset of last_injected_text behind pending worker jobs
  so a queued 'delete that' still sees the text it targets.

* fix(main): keep actions instant on a free worker and bound quit wait

- An action now starts a submit-time focus probe only when it actually
  queues behind other jobs; with a free worker there is no delay window
  to bind against, so the probe's compositor calls are skipped and the
  action stays immediate even when a script is configured.
- Probe and executor submission now happen inside pending_jobs_lock so
  queue order matches callback order — a job that saw an empty queue can
  no longer slip behind one that arrived while it was submitting.
- Every inject-path subprocess call (xdotool window/key/type, wtype and
  ydotool type) is bounded by a timeout, and the quit hook's
  injection_lock wait is capped, so quitting can no longer freeze on a
  stalled injector subprocess.

* fix(main): bind every action to submit-time focus without slowing it down

- Every voice action now starts a submit-time focus probe: submission
  never guarantees immediate execution, so skipping the binding left a
  focus-change window in which a shortcut could fire into another app.
  The worker waits on the probe for at most one second — milliseconds
  on a healthy desktop — so immediate actions stay immediate while a
  genuinely stalled probe falls back to the injector's own targeting.
- Quit waits for an in-flight injection without a cap again: every
  inject-path subprocess is individually bounded, so the wait always
  ends with the injection and a long transcription is never cut off.
- The wtype/ydotool typing timeout is now derived from the configured
  per-key delay (a generous multiple), so a legitimately slow type can
  never be cut mid-text; a timed-out type no longer falls through to
  the clipboard fallback, which would duplicate the partial fragment.

* fix(main): drop unverified actions and make quit-time injection abortable

- An action whose submit-time focus probe cannot answer inside its
  one-second bound is now dropped instead of running unverified: a
  stalled probe can no longer leave a shortcut pointing at whatever
  application happens to be focused.
- Quit asks the injector to abort in-flight injections, and injection
  paths honour it: the xdotool chunk loop stops between chunks, the
  wtype/ydotool type call runs as a polling Popen that can be killed,
  and paste/shortcut/backspace entry points exit early. The quit hook
  still waits for an in-flight injection, but the wait is bounded by at
  most one already-running subprocess call — never the injection's full
  length — and no subprocess is ever stopped mid-call by a timeout cap
  on the wait itself.
- Annotate the remaining untyped test helpers.

* fix(main): wait out slow focus probes; restore run() in wayland typing

- An action no longer races its submit-time focus probe on a one-second
  bound: the job waits for the probe's own answer, whose compositor
  calls already carry per-call timeouts and short-circuit absent tools.
  A slow but living probe delays the command instead of discarding it,
  so a valid "undo" can no longer be lost to timing alone, and focus
  verification still decides every drop.
- Wayland typing goes back to subprocess.run so tests keep intercepting
  the same entry point. Abortability is preserved by typing in
  200-character chunks with the shutdown check between calls — a
  whole-string call could not be interrupted, but no single chunk holds
  the injector for more than its own scaled budget.
- The abort flag gains a class-level default so injectors built without
  __init__ in tests still answer the checks; each real instance gets its
  own event in __init__.
- Update call-shape assertions for the typed-call timeout and finish
  annotating the test methods touched by the callback refactor.

* fix(inject): never retype text a failed chunked call already sent

- _PartiallyTyped carries the confirmed-delivered prefix length out of
  the chunked type loops, so every downstream fallback continues from
  the untyped remainder: the Wayland-to-xdotool switch types only the
  tail, and the clipboard fallback copies only the tail, never the
  whole string a second time.
- The xdotool retry loop gains the same accounting — a retry now
  resumes at the first untyped chunk instead of restarting at the top
  and duplicating everything the failed attempt already delivered.
- Annotate the remaining test signature the repository guide requires.

* fix(inject): report partial typing as failure even when clipboard lands

A chunked type call that dies mid-text leaves only a prefix on screen,
and the remainder-fallback keeps it that way: it copies the tail for a
manual paste but now returns False, so the caller no longer records the
full transcription as injected and a following "delete that" cannot
erase text that was never typed.

* fix(inject): keep deletion state aligned with delivered text

inject_text now records how many characters a call confirmed on screen
as last_typed_count: the whole length on success, the typed prefix
after a partial failure, -1 when the delivered amount is unknowable.
The transcription caller uses it to keep last_injected_text honest —
a following "delete that" erases the typed prefix or nothing, never
the previous segment or text that never arrived.

* fix(inject): never replay input a timed-out portal job may have delivered

- RemoteDesktopPortalError.delivered=None marks an unreported job's count
  as unknowable; all three portal fallbacks demote and skip instead of
  replaying, and text injection surfaces as TimeoutExpired so no
  full-text clipboard copy is offered
- _PartiallyTyped counts raised after a portal demote are rebased onto
  the original text (typed_prefix) so the caller clips the real prefix
- evdev: a consumed pure-modifier press is withheld and replayed ahead
  of the first non-target press, so holding RightAlt for AltGr
  composition still reaches the application
- history: extend_latest gains an expected_latest CAS guard so a late
  segment from an older worker merges only into its own session's
  snippet, never into a newer session's entry
- quit path aborts in-flight injections before waiting on the lock so
  quitting cannot freeze behind a long transcription's typing budget
- tests: stop _ensure_ydotoold leaking a real Popen in Wayland tests

* fix(shortcut): replay withheld modifiers across devices and after SYN_DROPPED

- Replay withheld modifier presses from every device, not only the fd the
  character arrived on: split keyboards deliver AltGr and the character on
  separate devices, and a per-fd check forwarded a plain character.
- Let the post-SYN_DROPPED resync run when only withheld modifiers exist
  for the fd, so a press whose release the dropped burst ate is pruned
  instead of replaying a stale modifier on the next ordinary key.

* fix(history,pads): keep late segments pad-bound deliveries and pad deletions correct

- Track the worker behind the ended session's snippet so late segments
  from an older worker cannot merge into a newer session's entry
- Skip the focus-change drop for dictation-pad segments and pad-bound
  editing commands, which never target the focused application
- Refuse 'delete that' when a manual pad edit blurred the segment
  boundaries instead of deleting the stale recorded length

* style: drop unneeded nonlocal for mutated worker set

* fix(shortcut): share one evdev device layer across shortcut listeners

Each KeyboardShortcutManager built its own EvdevKeyboardBackend, and on
evdev every backend opened and grabbed the same physical keyboards: the
first grabber received all events and the rest received none, so
per-language shortcuts could never fire on Wayland.

All backends now bind to a process-wide EvdevDeviceHub that opens each
keyboard once, owns the grabs and the paired uinput clones, and fans
every event out to each registered engine. An event is re-emitted to
applications only when no engine consumed it, so suppression semantics
stay the same while per-engine matching state (combo progress, withheld
modifiers, swallowed presses) remains independent.

* fix(history): own late segments per worker and decide pad routing once

- ended_worker_snippets maps each ended session's worker to its own
  (snippet, commit epoch): late deliveries merge into their session's
  entry however many sessions have committed since, so one session's
  stragglers never split across Recent Snippets entries.
- The leftover-segment commit reads the live history epoch inside the
  session lock: a mid-session clear() no longer makes the stale ended
  epoch refuse valid post-clear dictation.
- The pad-bound routing decision is captured once per job and passed to
  the injector/action handler, so a toggle flip between the focus check
  and delivery cannot reroute text or shortcuts into the focused app.
- Dead recognition workers are pruned from the tracking set and map so
  long runs don't accumulate finished threads.
- The custom-terms switch now suppresses GTK's own state-set on save
  failure so it shows the value that actually persisted.

* fix(shortcut): serialize evdev device layer teardown against restart

The last listener's unregister released the engine lock before joining
the monitor thread and closing devices, so a new listener registering in
that gap opened keyboards the stale teardown then closed — it stayed
active but dead, and briefly grabbed keyboards stopped forwarding input.

A lifecycle lock now covers the whole close/open transition in register,
unregister, and the monitor's unexpected-exit teardown, so a cold start
can only open devices after the previous generation finished closing
(also making the new generation's grabs succeed). A generation stamp
tells a monitor thread that outlived its join() its incarnation is over
even when running has gone True again.

* fix(injection): log focus-probe failures and narrow clipboard fallback catch

A crashed focus probe turned into an unknown result with no logged
error, indistinguishable from unavailable focus information; the
partial-typing clipboard fallback caught every exception. Log the probe
failure with context and narrow the clipboard catch to the exceptions
the fallback path can actually raise.

* fix(shortcut): join the evdev monitor outside the lifecycle lock

If the monitor exited unexpectedly while the last listener unregistered,
its cleanup queued on the lifecycle lock that unregister() held for the
join — stalling shutdown for the join's full 2s timeout. The close now
finishes inside the lock and the join waits after it is released, so the
monitor's own cleanup can run and exit instead of queueing behind it.

Closing before the join also dies the monitor's read/select on the
closed fds right away. A _closed gate on _open_keyboard_device keeps a
rescan from opening a device between detach and close that nothing
would ever close.

* fix(injection): narrow focus-probe catch to the raisable exception set

get_focused_window() reports Optional and never raises; the catch now
covers only what can realistically escape the queue write and logs it
with context, keeping the waiting job released.

* fix(shortcut): tag evdev device fds with the generation that opened them

A monitor that outlived join() could still touch a newer generation's
devices: the loop re-checked the generation only at the top, so mid-iteration
it could dispatch from, forward to, or remove devices a restarted listener
had just opened.

Every fd is now tagged with the generation that opened it, and the tag is
verified at each point a monitor could mutate shared state — fd lookup for
dispatch, clone forwarding, forwarder release, device removal, and rescan
opens — while dispatch re-checks the generation between events and the
per-fd loop breaks on it. Cold starts bump the generation before opening
so fresh devices are tagged for the new generation. Devices an older
generation opened stay tagged stale, so its reader leaves them alone.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 03:55:17 -07:00
dca937fc21 feat(dictionary): combined custom dictionary with corrections (#890)
* feat(dictionary): combined custom dictionary with corrections

Rebased onto latest main (conflict-resolution squash for PR #818).

* fix(dictionary): stabilize the terms and corrections file contracts

- corrections_path() stays at the fixed config_dir() location promised by the file contract instead of following a custom or --dictionary-file terms path, which could strand or duplicate corrections; a leftover file beside the terms file is still read once and copied over.

- _atomic_write() preserves the replaced file's permission mode instead of resetting it to the process umask.

- build_initial_prompt() streams terms and stops at the configured cap instead of reading and processing the whole file for every segment.

* fix(settings): restore the selected dictionary pane after a search

Searching can auto-switch the Custom Dictionary management pane to reveal a match, but clearing the query only restored widget visibility. Record the pane selection in the search baseline snapshot and restore it alongside the previous page so users return to the pane they chose.

* fix(dictionary): close the remaining custom dictionary file-contract gaps

- A --dictionary-file session override no longer imports a corrections
  file found beside the override into the standard config directory;
  the override scopes to terms only.
- Terms reads validate the whole file before yielding: corrupt UTF-8
  mid-file now yields no terms at all, matching the invalid status
  Settings reports instead of leaking a partial dictionary to
  recognition.
- The terms read is bounded by MAX_TERMS_FILE_BYTES (1 MiB); a larger
  file is invalid everywhere with a dedicated status instead of being
  read on every segment.

* fix(dictionary): keep usable terms prefix on oversized files and legacy corrections under overrides

* fix(dictionary): cap yielded terms and keep added terms visible on oversized files

* fix(dictionary): keep added terms visible without dropping scanner-owned lines

* fix(dictionary): keep oversized terms manageable and honor mid-search pane switches

- terms list gains a Show all / Show first 500 toggle; adding a term
  beyond the cap auto-expands so the new row renders with a Remove
  button instead of being invisible until the file is hand-edited
- a manual dictionary pane switch during an active search drops the
  saved pre-search pane so clearing the query no longer rolls back the
  user's newer selection
- annotate the new settings tests and type model_info so the parakeet
  branch stops adding fresh mypy errors

* fix(settings): do not auto-expand terms on add past display cap

Rebuilding up to thousands of term rows on the UI thread stalls the
dialog; the overflow button already shows the true count, so the added
term stays one click away.

* test(dictionary): stub segment_callbacks on bare manager for history dispatch

* fix(dictionary): stream oversized terms files when building the prompt

build_initial_prompt decoded the whole 1 MiB read window before every
transcription segment even though it only needs the first max_words
terms. _iter_terms_for_prompt now reads oversized files line by line and
stops at the prompt limit, keeping the same contract: complete leading
lines only, invalid UTF-8 inside the usable window yields nothing.

* fix(history): only the ended session's worker may extend its snippet

A late segment could extend the newest history entry regardless of which
session produced it, so a worker finishing after the next dictation
session leaked its text into that session's entry. Track the worker that
owned the ended session and require late segments to arrive on the same
thread before merging; anything else forms its own snippet.

* fix(speech): transcribe queued segments the reload worker never drained

The recognition worker can exit during stop_recognition's bounded
audio-thread join, so the released key's final buffer lands in the queue
after the worker is gone and was silently dropped when the reload
teardown replaced the queue. Drain whatever remains before swapping it
— except on capture failure or an explicit cancel, which still discard
the session's leftovers.

* fix(settings): build term rows in idle slices and pin hidden additions

Two problems shared one cause. Rebuilding up to 4096 rows synchronously
stalled the dialog, and a term appended past the 500-row cap reported
success while staying invisible. Rows now build in bounded GLib idle
slices guarded by a refresh token, and a saved term outside the visible
window is pinned to the top of the capped list until it disappears or
becomes visible on its own. Adding under "show all" scrolls to the new
term instead.

* test(engine): annotate new test method return types

* fix(ci): match a candidate row that leads the latest track block

snapcraft status normally prints candidate as a continuation line under
the latest track (channel in column 1). If candidate ever leads the
track block it rides the track line instead (channel in column 3), and
the resolver missed it and refused to promote. Match both layouts in
the resolve and the pre-release re-verify.

* fix(dictionary): bound prompt reads and scroll to landed terms

- _iter_terms_for_prompt now reads each line via readline() capped by the
  remaining byte window, so an overlong line can never pull more than
  MAX_TERMS_FILE_BYTES into memory
- the prompt path keeps decoding every complete line inside the usable
  window after the term limit is collected, so invalid UTF-8 anywhere in
  the window still yields no terms and matches the reader's invalid status
- under "show all" Settings scrolls to the index where an added term
  landed (prepended or appended), verified against the row's label,
  instead of always scrolling to the end

* fix(history,reload): keep late-segment ownership and drain stamped tuples

- Track session_workers_seen so a worker from an earlier session can
  never merge into a newer snippet while a session is open
- Unpack (segment, language) queue tuples in _transcribe_queued_segments;
  passing the tuple as audio raised TypeError and lost final speech
- Refuse 'delete that' when a manual pad edit blurred segment boundaries
- Translate term-row coordinates into the scrolled card's space so the
  scroll actually reaches rows below the fold
- Type _on_dictionary_pane_changed's varargs

* style: drop unneeded nonlocal for mutated worker set

---------

Co-authored-by: Graham <graham@thegeeky.ninja>
2026-09-30 02:33:49 -07:00
047b379bd2 feat(audio): add PipeWire capture path with system-audio sources (#889)
* feat(audio): add PipeWire capture path with system-audio sources

Enumerate PipeWire sinks via pw-dump and expose their monitors as
"System audio: <name>" capture devices at negative device indices
(-2..), alongside the existing PortAudio microphones. Recording,
device test, and stream-reconnection paths route to a
pw-record-backed stream shaped like a PyAudio stream, so the VAD and
silence-detection loop is unchanged.

Playback ducking is skipped when the selected source is a monitor,
since ducking the default sink would also duck the captured audio.

Closes #751

* test(audio): restore stashed numpy instead of re-importing it

test_audio_input imports numpy lazily; deleting the MagicMock that
earlier test modules leave in sys.modules re-imported a second numpy
module object whose lazy submodule attributes (linalg, testing) were
unpopulated while sys.modules kept the first object's entries. The
next test file to reach for numpy.testing hit a __getattr__ import
loop (RecursionError in TestDownmixToMono on CI).

Restore the conftest-stashed _vocalinux_real_numpy instead, matching
the pattern tests/test_recognition_manager_device_config.py already
uses.

* fix(pipewire): drop PyAudio requirement for PipeWire sources, rebind source after reconnect

* docs(pipewire): restore PipeWireCaptureSource class docstring

* fix(audio): initialize PyAudio for mic reconnection after PipeWire session

A session started on a PipeWire sink never creates a PyAudio instance;
when capture later reconnects onto a PortAudio device the source needs
one to reopen, and a failed open dereferenced None calling terminate.

* fix(audio): harden PipeWire source resolution, reads, and cleanup

- resolve stored names by node.name/description; a stored name that no
  longer resolves never falls back to a positional index
- require pw-record before listing system-audio sources in the picker
- bound pw-record chunk reads with a stall watchdog so stopping cannot
  block on a wedged stream
- close the reconnection probe stream when its verification read fails
- log cleanup failures instead of swallowing them; narrow exception
  handlers in enumeration and PyAudio reconnection init
- annotate test helpers and signatures per repo style

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 02:07:41 -07:00
9898bb8dd9 feat(ui): in-app Dictation Pad for Wayland-safe dictation (#887)
* feat(ui): add in-app Dictation Pad for Wayland-safe dictation

Dictated text can now be routed into a text box inside Vocalinux's own
window instead of being injected into other applications — an explicit
fallback for desktops where cross-app injection is unreliable or blocked
(notably Wayland). The pad opens from the tray menu, captures dictation
when enabled via Settings or its own checkbox, and lets the user copy
text out by hand. 'delete that' removes the last segment in-pad.

Closes #726.

* fix(ui): consult live config for pad auto-reveal and Copy All

The reveal gate used the controller's enabled flag, which only syncs on
pad show or its own checkbox — enabling capture via the Settings switch
while the pad was closed silently buffered dictation with no reveal.
_apply_append now reads dictate_to_pad from the shared config.

Also: Copy All copies the widget text (dictation plus in-pad edits), and
re-showing the pad no longer does a wholesale set_text — the view stays
incrementally in sync, so manual edits survive hide/reopen.

* fix(ui): keep dictation pad capture and commands inside the pad

Address all Greptile findings on PR #887:

- Read the capture toggle from the shared ConfigManager instead of
  re-parsing config.json per segment, so a torn mid-write read can no
  longer fail-open and send dictation to the focused app
- Sync edits made directly in the pad widget back into the controller,
  keeping delete semantics consistent with the visible text
- Stamp queued widget operations with a generation tag so a Clear drops
  appends still sitting in the GTK idle queue
- Route undo/redo, select all/line/word/paragraph and cut/copy/paste to
  pad-local implementations while capturing, so their shortcuts never
  reach whichever application holds focus
- Track where the last segment was delivered so "delete that" follows
  the text rather than the current capture toggle
- Annotate every new signature and add callback-level tests that pin the
  routing, queue, and undo behavior

* fix(ui): keep pad controller, queued ops, and delete target in sync

Manual edits could overwrite queued dictation in the controller because
GTK input events outrank idle callbacks; the changed handler now replays
pending widget ops before syncing the view. The controller also tracks
dictated segments across undo snapshots, so a pad undo pops the "delete
that" target instead of leaving a stale length. Catches in the pad are
narrowed to the GTK/config error set per repo style, and missing type
annotations are added.

* fix(ui): close flush races in pad idle queue and delete retarget

Flushed idle ops stayed scheduled with GLib and ran a second time,
duplicating appends; each queued op now only acts while its entry is
still pending. A queued full-view refresh could also erase a manual
edit that landed first, so the changed-handler flush now drops stale
set-text ops instead of replaying them. And clearing the pad
destination on undo-to-empty kept a redo from restoring the "delete
that" target; the destination flag is preserved so redo re-arms it.

* fix(ui): replay pad idle ops before edits land, not after

Flushing in the changed handler still let queued appends leapfrog the
keystroke that fired it, and a stale dispatch could cancel a later
queued op with identical text. Pending entries now match by identity,
and the replay moved to insert-text/delete-range — signals that fire
before the edit is applied — so queued dictation and deletes keep true
chronological order against manual edits.

* fix(history): target late segments to their own snippet by id

A straggler from a just-ended session could merge into whichever snippet
was newest when it arrived, and audio captured before a session opened
was attributed to that session while decoding on its worker.

- TranscriptionHistory.add returns the snippet id and the new
  extend_entry() extends a specific entry, replacing the positional
  extend_latest target in the late-segment path
- session_started_floor stamps when a session opens; in-session
  attribution also requires the capture to postdate it
- test_capture_ceiling closes the mic-test window at callback restore so
  test speech still decoding can no longer leak into history

* fix(settings): keep deferred edits from overwriting a newer choice

The deferred-persist filter treated "config still equals the baseline" as
untouched, so a reopened dialog that applied a key back to its baseline
value could still lose it to the closed dialog worker.

- Record the generation of the apply that last wrote each advanced key;
  a pending edit now survives only when no newer apply wrote that key
- A worker apply whose snapshot a newer apply predates now skips its
  reconfigure and save entirely, so collection order — not lock order —
  decides which snapshot wins

* fix(settings): skip stale applies before stopping dictation

The superseded-apply check ran after stop_recognition, so a stale worker still interrupted a live dictation session. Recognition now stops inside the apply lock, after the generation check.

* fix(shortcuts): keep language edits from breaking dictation

Two per-language shortcut fixes (#805):

- A binding edit landing while a session is live no longer stops
  recognition. The dialog's per-keystroke persist still saves the new
  binding, but the tray defers the listener rebuild to the next IDLE
  transition, so a held push-to-talk keeps its release callback and a
  toggle session is never cut off mid-utterance.
- A row edited onto a key another row holds can no longer displace that
  row's binding. Each row's remembered shortcut is reserved for its own
  row before claims run in row order, so the stealing edit is rejected
  and the held binding survives.
- Persists that resolve to the already-saved bindings (mid-edit
  keystrokes, rejected duplicates, reverted fields) skip the save and
  the listener rebuild entirely.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 01:35:21 -07:00
dbe0215aad feat(shortcuts): add language switching shortcuts, fix PTT races, and add dictation history (#880)
* feat(shortcuts): per-language dictation shortcuts (#805)

Bind extra keys that start dictation in a specific catalog language for
one utterance, then restore the configured language. Bindings live in
shortcuts.language_shortcuts as [{shortcut, language}], normalized
against the language catalog and the backend's shortcut parser.

The tray spawns one KeyboardShortcutManager per binding in the active
mode; toggle presses route through start_recognition_with_language, and
push-to-talk arms press/release the same way. The manager keeps the
override one-shot: it applies before the utterance starts (outranking
follow-keyboard-layout) and unwinds on the next IDLE/ERROR transition
or when a mid-dictation reconfigure picks a new language. Engines that
cannot re-language without a reload (VOSK, Parakeet, English-only
whisper.cpp) refuse with the error cue and a notification instead of
dictating in the wrong language.

Settings gains a Language Shortcuts group under Shortcuts: each row is
a language picker, an editable key entry, a Record button that retargets
the shared key-capture, and a remove button; edits persist immediately
and rebuild the live listeners. A non-dict shortcuts section now rebuilds
instead of crashing config load.

* fix(shortcuts): class-level default for language shortcut listeners

TrayIndicator stubs built with __new__ (e.g. test_suspend_handler)
skip __init__ but still run _quit(); give _language_shortcut_managers
a class-level default so teardown iterates an empty list.

* Fix Greptile review findings on per-language shortcuts (#805)

- Bind each dictation session's language for transcription workers so the
  one-shot restore cannot rewrite a queued segment's language
- Judge a shortcut's capability against the model the layout refresh will
  serve (whisper.cpp sibling swap) and refuse requests the engine drops
- Add _oneshot_language_restore to the reconfigure snapshot and settle a
  revived pending restore on failure
- Dedupe language bindings by gesture (modifier reordering, enter/return
  alias, pure-modifier side sets) instead of by string
- Stop per-language listeners under external activation and never re-arm
- Gate push-to-talk release on the binding that started the session, and
  stop a held hold before rebuilding listeners
- Keep a row's last valid binding during partial edits, report rejected
  duplicates, cancel a capture aimed at a removed row, disarm the previous
  Record target, persist a recorded key once, and add the missing type
  hints

* fix(shortcuts): resolve remaining Greptile race findings on PR #880

- Stamp each queued audio segment with the session language it was
  recorded under, so a worker that outlives stop_recognition's joins
  transcribes each utterance in its own language rather than whatever a
  newer session stored in _session_language.
- Serialize push-to-talk press/release behind _ptt_lock: a release
  landing while start_recognition runs now waits for the owner claim
  instead of being dropped, so a quick key tap cannot leave the
  microphone running.
- Add the missing return type hint on _stop_language_shortcut_managers.

* test(shortcuts): stub _segment_started_at on bare manager stubs

* fix(shortcuts): carry segment language as attribute, default language on bare managers

* fix(shortcuts): keep (segment, language) queue tuples, stamp segment.language too

* fix(settings): apply history toggle and limit to the live store

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 01:05:18 -07:00
ca40f7d8b0 fix(text-injection): keep Alt+Shift/Win+Space layout switching on GNOME Wayland (#876)
* fix(text-injection): restore full GNOME XKB map on XWayland after inject

sync_xwayland_layout_from_gnome wrote only the single active input
source via 'setxkbmap -layout <one>'. That left XWayland with a
one-group keymap: Alt+Shift 'grp:' toggles and the group index Mutter
drives for Win+Space became no-ops for every X11/XWayland client until
relogin.

Apply GNOME's complete XKB map instead: every xkb input source in
'sources' order plus all 'xkb-options', and only when the live rules
differ (which also keeps inject from resetting the active group).

Refs #848

* fix(text-injection): harden XWayland repair against bad reads and non-GNOME sessions

Three Greptile P1s on f005b6c:

- _read_gnome_input_sources_key now returns [] for a successfully read
  empty list, reserving None for unreadable keys. _get_gnome_xkb_keymap
  bails when the xkb-options read fails instead of treating it as an
  empty list, so a transient gsettings failure can no longer make the
  repair clear live XWayland options via 'setxkbmap -option ""'.

- _get_gnome_xkb_keymap restores the GNOME-session guard the previous
  source reader had, so a stale org.gnome.desktop.input-sources schema
  on another Wayland desktop is never applied to that desktop's
  XWayland server.

- _query_xserver_xkb_rules catches OSError, not just FileNotFoundError:
  launch failures like PermissionError run inside inject_text's finally
  and must not surface after a successful commit.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 00:48:17 -07:00
8ebc4ce2ba feat(diarization): per-speaker transcripts via TinyDiarize + --transcribe-file (#884)
* feat(transcribe): add TinyDiarize file transcription surface

Catalog small.en-tdrz (hosted at akashmjn/tinydiarize-whisper.cpp,
pinned commit) while keeping it out of every dictation-model selection
path: its [SPEAKER_TURN] markup would type noise into the focused
window. The checksum generator resolves side-repo models at their own
pinned revision, parakeet-style.

Add speech_recognition.diarization (strict-WAV fast path with ffmpeg
fallback, tdrz transcription, speaker-turn parsing, timestamped
formatting) and surface it through a tray Transcribe Audio File flow
with on-demand model download plus a headless --transcribe-file CLI.
TranscriptDialog offers copy and save of the diarized transcript.

Closes #756

* docs(user-guide): document --transcribe-file

* fix(transcribe): pass tdrz_enable as a pywhispercpp kwarg

pywhispercpp's Model takes decode params as keyword arguments; a
params= dict crashes on the C-level whisper_full_params object. Also
let --transcribe-file report unexpected failures cleanly instead of
dumping a traceback.

* fix(tray): dismiss the tdrz download dialog on response

* test(diarization): pin real wave/numpy/tempfile around sibling mocks

Several test modules replace sys.modules entries with MagicMocks at
import time and never restore them, so diarization's wave/numpy/
tempfile bindings resolved to mocks when this file ran after them in
CI. An autouse fixture reimports the real stdlib modules per test, the
same remedy test_audio_feedback already applies for wave.

* fix(transcribe): verify tdrz model, preload libs, gate formats on ffmpeg

- Hash the on-disk TinyDiarize model against the pinned digest before
  native code maps it, matching the dictation load path
- Extract the bundled-lib preloader to utils/pywhispercpp_loader.py and
  preload before the lazy pywhispercpp import so the headless
  --transcribe-file path works on source-built installs without RPATH
- Offer only WAV in the file picker when ffmpeg is absent; name the
  dependency in the filter label
- Type the new tray/diarization callbacks; document --transcribe-file
  and the new modules in AGENTS.md

* style(audio): finish type annotations on capture helpers and download path

_setup_alsa_error_handler now declares Optional[Any] and its ctypes
callback gets concrete parameter types; the remaining capture helpers
take audio: Any (PyAudio has no importable type) and test_audio_input's
device_index becomes Optional[int], which also clears the preexisting
implicit-Optional error. _download_whispercpp_model gains -> None and
its HF url is narrowed to str so the concatenation and download call
stop adding new mypy findings.

* style(tests): wrap long monkeypatch line in preload test

* refactor(main): narrow transcribe-file errors and add file context

* style(ui): annotate TranscriptDialog constructor

* fix(diarize): log worker tracebacks, isort test helpers, document --transcribe-file

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 00:44:55 -07:00
b4babcaeca feat(packaging): AUR -bin package publishing with per-arch digests (#879)
* feat(aur): add vocalinux-bin PKGBUILD shipping the AppImage

Closes #817

* fix(aur): hard-dep injection tools, pin real digests, gate the -bin package

* fix(aur): pin both-arch -bin digests and build the binpkg as builder

updpkgsums in the publish action only regenerates the x86_64 sum array, leaving aarch64 stale after every tag; release.yml now pins both AppImage digests itself and publishes with updpkgsums off. The AUR gate also created $WORKDIR/binpkg as root after handing WORKDIR to builder, so makepkg could not write there. AUR.md now matches the published -bin package.

* fix(ci): wait for aarch64 AppImage before publishing vocalinux-bin

publish-aur only needed build-and-release, so on a tag the digest-pinning
step could curl the aarch64 AppImage before build-appimage-arm64 attached
it — a 404 that failed the job between the source and -bin publishes.

Split the -bin bump/pin/publish steps into publish-aur-bin, which needs
[publish-aur, build-appimage-arm64]: the source package still ships as
early as before while the binary package only runs once both AppImages
exist on the release.

* fix(ci): ship -bin local sources and wait for the aarch64 asset on AUR publish

Split the -bin bump/pin/publish into publish-aur-bin needing
[publish-aur, build-appimage-arm64] so the digest-pinning step can no
longer curl a 404 for the not-yet-attached aarch64 AppImage mid-release,
and publish with asset_dir so the PKGBUILD's local sources
(vocalinux.desktop, vocalinux.svg, LICENSE) reach the AUR repo instead
of a lone PKGBUILD users cannot build.

* style(tests): apply black formatting to aur packaging test

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 00:27:28 -07:00
5c031e64fb feat(settings): bilingual dictation support with deferred settings edits (#424)
* feat: add configurable Whisper language candidates

* fix(settings): defer language candidate edits and tolerate list config

The candidates entry applied settings on every keystroke, restarting the
whisper.cpp model per character, and edits typed while an apply held the
guard were dropped so the saved value could differ from the entry. Defer
application like the initial prompt: mark edits dirty and apply on page
change or close; _finish_auto_apply re-applies edits that landed mid-apply,
and _persist_pending_text_edits saves them if the dialog closed first.

A JSON list in config also crashed the dialog on open because
Gtk.Entry.set_text needs a string; populate the entry through the engine's
_normalize_language_candidates so both stored forms render the same text.

* fix(recognition): validate whisper.cpp language candidates before selection

auto_detect_language's probability map names every language the model can
return, but candidate selection scored configured codes against it with a
0.0 default, so a typo or "auto" could still win the max() and reach
transcription. Only codes present in the probability map are eligible now;
unsupported codes are logged and skipped, and when none qualify the
unrestricted detection result is used instead of forcing a code the model
never scored.

Also add the missing input annotation on _normalize_language_candidates.

* fix(settings): persist deferred text edits off the GTK main loop

_finish_auto_apply runs on the main loop and called
_persist_pending_text_edits synchronously, so reconfigure() restarted
the model in-process and froze the UI when the dialog closed mid-apply.
Run the reconfigure + config save on a worker thread, matching the
normal apply path.

Also annotate _on_language_candidates_changed's signature.

* fix(settings): guard deferred saves and annotate new candidate tests

* fix(settings): reconcile the engine to winning settings after a mid-restart apply

* fix(settings): drop superseded deferred edits instead of saving them

A dialog closed mid-apply stashes its pending text edits for a worker to
persist after the running apply finishes. The worker's mtime check could
not tell the in-flight apply from a newer one started in a reopened
dialog, so the older snapshot could reconfigure the shared engine and
save over the settings that won.

Serialize applies and the deferred persist on a shared lock and order
them with a generation counter bumped when an apply begins. The worker
now skips reconfigure and the save entirely when a newer apply has
begun, and skips the restart when the running apply already landed the
same values.

* fix(settings): merge deferred text edits per key instead of dropping them

Closing the dialog mid-apply stashes the typed prompt/candidate values
with the apply's generation. When a reopened dialog applies newer
settings first, the generation check used to discard the whole stash —
losing the closed dialog's edits even where the newer apply never
touched those keys.

Persist keeps a pre-edit baseline per key: after a newer apply lands,
only keys it actually changed are dropped; the rest still reconfigure
the engine and save, so a stale value can never reach the shared engine
while untouched edits are no longer lost.

* refactor(settings): narrow deferred-edit persist exception with context

* chore: retrigger ci

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 00:16:44 -07:00
b71164a214 feat(tray): add recent dictation snippets history menu 🤖🤖🤖 (#487)
* feat(tray): add recent dictation snippets history menu

Add a "Recent Snippets" submenu to the tray indicator that lists recent
dictation sessions. Clicking a snippet copies its full text to the
clipboard for easy re-pasting; a "Clear History" entry empties the list.

Snippets are recorded on recognition, independent of whether text
injection succeeds, so text can be recovered even when injection silently
no-ops (e.g. on some Wayland compositors). One snippet corresponds to one
dictation session (everything said between start and stop); voice commands
are not recorded since they take a separate callback path.

History is kept in memory only (newest-first, capped, never written to
disk) and clears on quit — a deliberate privacy choice for a tool that
sees everything dictated.

Adds a "history" config section (enabled, max_items) and matching
Settings UI under General. Includes unit tests for the new
TranscriptionHistory store.

* fix(history): sanitize saved max_items to keep startup robust

A hand-edited config.json can store a non-numeric or out-of-range
history.max_items. Coerce the value on load (and in TranscriptionHistory
itself) with a positive-int fallback so a malformed preference warns and
falls back to the default instead of aborting startup or crashing the
Settings dialog spin button.

* fix(history): fold late-arriving segments into their originating session

stop_recognition() only waits on the recognition worker for a bounded
time before reporting IDLE, so a slow final segment can still fire its
text callback afterwards — landing in the next session's pending
snippet, or starting a phantom entry of its own.

Track the open session (state transitions, its worker thread, and its
pending segments) under a lock and file each segment by origin: while a
session is open only its own worker's segments accumulate into it; a
segment arriving after finalization extends that session's committed
snippet via the new TranscriptionHistory.extend_latest(), and a segment
from a session that produced nothing forms its own entry instead of
polluting the next one. ERROR now also finalizes the session, so a
partial result is still recorded and the tracker never wedges open.

Covered by new tests driving the callback and state wiring directly:
mid-session LISTENING/PROCESSING transitions, back-to-back sessions,
late callbacks from a lingering worker thread, disabled history, and
invalid saved limits.

* style(ui): type the new history handlers in tray and settings

* fix(history): keep cleared snippets from reappearing via late segments

The recognition worker can still emit a final segment after the manager's
bounded stop wait reported IDLE. Once the user cleared the history, that
late text could not extend the now-empty history and was appended as a
new entry — resurrecting snippets the user explicitly removed.

Give TranscriptionHistory a clear epoch: clear() (and disabling, which
clears) bumps it even when the store is already empty. add() and
extend_latest() accept an expected_epoch and refuse stale epochs
atomically under the store lock, so the check cannot race the clear.

main.py tracks the epoch each session opened under and the epoch of the
most recently ended session. Session commits and every late-segment
write are guarded by them, so text produced before a clear — whether a
session's whole snippet, its straggler segments, or leftovers committed
on the next session's start — cannot re-enter history afterwards.

* test(history): cover clear-epoch rejection and type new signatures

Add regression tests for the cleared-history race: a late segment after
clear() must not reappear, a session ending after a mid-session clear
leaves no snippet, and add/extend_latest refuse a stale expected_epoch
while unguarded writes keep working.

Annotate the new signatures per the repo guide: _boot's extra_settings
and return tuple, every test in the new TestSessionHistoryRecording and
history-menu tray classes, and all methods of the history store tests.

* fix(main): keep post-clear dictation by rebinding the session epoch

* fix(history): drop the boundary segment when a clear lands mid-session

* fix: scope dictation history to post-clear audio via capture-time stamps

The engine now tags each queued audio segment with the monotonic time
capture began and reports it to a new segment_callbacks hook. History
recording drops segments whose capture started before the last clear,
so pre-clear audio that decodes late can no longer resurrect cleared
entries or leak into a later session, while text dictated after a
mid-session clear is kept instead of being discarded by a stale epoch
check.

* fix(settings): keep Test Dictation speech out of transcription history

The mic test swaps text callbacks for its preview, but history recording
runs on segment callbacks, so test speech still landed in Recent
Snippets. The test now saves and clears the segment callback list
alongside the text list — restoring both on success and on the
start-failure path. register/unregister_segment_callback also gain the
missing return annotations and the engine gets symmetric
get/set_segment_callbacks accessors.

* fix(settings): floor test-dictation speech instead of swapping callbacks

Instead of clearing the segment callback list during the mic test —
which would have dropped leftovers from a still-finishing dictation —
the engine exposes test_capture_floor: segments captured at or after
the stamp are test speech and stay out of Recent Snippets, while
earlier-session leftovers continue to file normally.

* refactor(history): narrow change-callback exception surface

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 00:06:51 -07:00
f36c3f6ae5 feat(text_injection): add RemoteDesktop portal injection backend (#885)
* feat(text_injection): add RemoteDesktop portal injection backend

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

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

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

Closes #750

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

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

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 23:55:27 -07:00
24fddc4344 chore(release): add snap-promote workflow for gated stable releases (#881)
* chore(release): add snap-promote workflow for gated stable releases

Issue #783 option 2 (safer): v* tags keep auto-publishing the snap to
edge + candidate in release.yml, and latest/stable is promoted by a
manual snap-promote.yml dispatch after candidate QA.

The dispatch resolves the revision on latest/candidate and refuses to
promote unless its version matches the tag, so a typo or stale candidate
fails the run instead of shipping the wrong revision. Declaring the
snap-stable environment with required reviewers adds an approval gate
on top of the dispatch.

Fixes #783

* fix(ci): serialize snap promotions and re-verify candidate

awk used the gawk-only \S which mawk reads literally, so the latest-track candidate row never matched. The promote step also trusted a revision resolved minutes earlier, letting an overlapping promotion downgrade stable. Serializes dispatches and re-resolves the candidate right before release.

---------

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

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

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

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

Closes #760

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

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

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

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

---------

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

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

Fixes #678

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

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

---------

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

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

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

Fixes #871

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

---------

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

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

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

Fixes #679

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

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

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 22:52:12 -07:00
c9ea6c7de7 feat(flatpak): publish a signed self-hosted OSTree remote on releases (#875)
* feat(flatpak): publish a signed self-hosted OSTree remote on releases (#785)

release.yml gains publish-flatpak-remote: it imports the per-arch
.flatpak bundles into an OSTree repo, signs commits and the summary
with a provisioned GPG key, regenerates static deltas and the
vocahq.flatpakrepo/index page, and pushes to VocaHQ/vocalinux-flatpak
served by GitHub Pages — the Homebrew-tap analogue, so installs
auto-update via flatpak update instead of per-release downloads.

The job is secret-gated like publish-aur (FLATPAK_GPG_PRIVATE_KEY +
FLATPAK_REPO_TOKEN) and skips itself while unconfigured. Prior repo
objects are pulled back in from gh-pages so clients get incremental
deltas. The manifest now declares branch: stable and the flatpakrepo
declares DefaultBranch=stable + RuntimeRepo for the Flathub GNOME
runtime.

Docs: new docs/FLATPAK_REMOTE.md (one-time setup, rotation,
verification); README, docs/INSTALL.md, packaging/flatpak/README.md
and the release notes now lead with the remote and keep release
bundles as the no-auto-update fallback. tests/test_release_integrity.py
pins the job's shape.

Closes #785

* fix(flatpak-remote): publish a .flatpakref and gate the remote to stable tags

- needs build-and-release so the remote cannot lead the GitHub Release
- stable tags only: prereleases must not reach `flatpak update`
- probe gh-pages before the empty-repo fallback: a transient checkout
  failure no longer discards remote history
- publish com.vocalinux.Vocalinux.flatpakref (RuntimeRepo lives there, not
  on .flatpakrepo) plus vocahq.gpg, so clean installs resolve the GNOME
  runtime and key rotation works via remote-modify --gpg-import
- gate the release-notes remote instructions on the publish actually
  running; the bundle path stays primary otherwise
- document rotation via remote-modify --gpg-import / remote-delete
  --force and the explicit Flathub setup step

---------

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

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

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

Fixes #849

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

---------

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

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

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

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

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

* fix(recognition): recover buffered reload failures

* test(recognition): cover buffered reload edge cases

* fix(recognition): narrow buffered reload errors

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

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

* style: apply Black formatting to PR 851 files

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

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

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

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

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

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

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

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

Refs VocaHQ/vocalinux#851

---------

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

Co-authored-by: Alexander <ab@digital-benefits.de>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-29 21:19:29 -07:00
Sergey SilaevandGitHub c0217a8aec refactor(installer): generate distro package map (#872)
* refactor(installer): generate distro package map

* fix(installer): handle Debian derivative package maps

* docs(installer): document generated package map

* fix(installer): validate package map and refresh derivative probes

* fix(installer): name openSUSE package roles
2026-09-29 21:16:03 -07:00
Jatin K MalikandGitHub fb9166ed9e feat(settings): run local VocaGateway from Advanced (podman-first) (#774)
* feat(settings): run local VocaGateway from Speech Engine (podman-first)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(packaging): ship gateway_embed in the AppImage

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

* style(packaging): black the gateway_embed package guard

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(types): move Readable protocol to common_types

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

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

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

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

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

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

* docs: add dictation overlay screenshots

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

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

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

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

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

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

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

* refactor(ui): ponytail pass on dictation overlay

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two gaps in the pinnable backend set.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 12:24:52 -07:00
Jatin K MalikandGitHub 7b69ac2f46 fix(site): Phone chip, Free forever Cost copy, and privacy GA disclosure (#868)
* fix(site): align Phone chip, Cost copy, and privacy with GA

Use the official Android beta / iOS TestFlight chip on #ecosystem.
Replace Free forever with Free and open source on privacy and open-source.
Disclose vocalinux.com Google Analytics and scope no-telemetry claims to the installed app.

Fixes #867

* fix(site): make privacy GA disclosure match the live gtag

Spell out page views, the client-ID cookie, and IP/device processing for
G-7NBBNJNNQ7. Align Data Collection with PRODUCT.md, drop the hidden
telemetry weasel, and correct config.json plus the voice-storage row.
2026-09-29 01:07:51 -07:00
244b62c8ad refactor(installer): split implementation into sourced modules (#862)
* refactor(installer): split implementation into sourced modules

* test(installer): harden module path coverage

* fix(installer): qualify interactive privacy and engine claims

The guided installer overstated local-only privacy and ranked
whisper.cpp without evidence. Match PRODUCT.md: on-device local
engines, optional Remote API, and the ~33 language catalog plus
Auto-detect.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 01:07:16 -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
f1327b7d1d chore(deps): bump the github-actions group across 1 directory with 2 updates (#870)
* chore(deps): bump the github-actions group across 1 directory with 2 updates

Bumps the github-actions group with 2 updates in the / directory: [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) and [codecov/codecov-action](https://github.com/codecov/codecov-action).


Updates `astral-sh/setup-uv` from 10.1.0 to 10.2.0
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/bec219d24cd3e171d82865faccec33120bb574f4...c18668ad3cf93ea998bef934396af7bb5c839dc7)

Updates `codecov/codecov-action` from 7.0.0 to 7.1.1
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/fb8b3582c8e4def4969c97caa2f19720cb33a72f...303a32d7a59b442fa8d48b6a1cc6825c09c847a5)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: codecov/codecov-action
  dependency-version: 7.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: retrigger greptile review

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-28 09:07:47 -07:00
Kanishk PachauriandGitHub f4e86f6325 fix(deps): clear open dependency advisories (#869) 2026-09-24 04:42:08 +05:30