`_handle_delete_last` built its deletion as `"\b" * len(text)` and
passed it to `inject_text`, treating U+0008 as typed input. No backend
delivers that as a keypress:
- `ydotool type` has no keymap entry for U+0008, so it types nothing.
- IBus `commit_text()` commits it as a literal control character, which
the client inserts or drops rather than treating as a deletion.
Only an actual key event deletes, so the "delete that" voice command
did nothing on those paths while still reporting success.
Add `press_backspace(count)`, which sends `count` real BackSpace events
using the tool that fits the environment: `xdotool key --repeat` on X11
and the xdotool-backed Wayland path, `wtype -k BackSpace` or the
ydotool keycode pair `14:1 14:0` otherwise. WAYLAND_IBUS resolves a
virtual-keyboard tool of its own, since IBus cannot send key events.
`inject_text` is untouched; only the delete_last action changes.
The existing delete_last tests asserted the U+0008 string was injected,
so they now pin the new contract instead: press_backspace called once
with the character count, and inject_text not called at all.
`_inject_keyboard_shortcut` dispatched X11 and WAYLAND_XDOTOOL to
xdotool and sent everything else to
`_inject_shortcut_with_wayland_tool`, putting both IBus environments on
a path that cannot serve them. IBus commits text; it has no way to
synthesise a key combination.
WAYLAND_IBUS failed worse than it looked. `wayland_tool` is only set
when dependency detection picks a Wayland injection tool, so on an IBus
session it can be unset, and the attribute access raised inside the
enclosing try. The except logs and returns False, so every shortcut
reported a clean failure rather than a missing tool.
Route X11_IBUS through xdotool, which reaches the same X server the XIM
path is already using, and give WAYLAND_IBUS a virtual-keyboard tool,
preferring wtype and falling back to ydotool. With neither present the
error names the real cause instead of surfacing an AttributeError.
X11_IBUS folds into the existing xdotool condition rather than adding a
branch, since the call is identical. The `wayland_tool` write is taken
under `_state_lock`, matching every other write to it; the injection
call stays outside the block, as the lock is not reentrant.
Text injection is unchanged; this only affects shortcut delivery, and
only in the two IBus environments.
Five tests cover the routing: X11_IBUS reaching xdotool, WAYLAND_IBUS
preferring wtype, its ydotool fallback, the no-tool-available failure
injecting nothing, and an already-chosen tool being left alone.
`_inject_shortcut_with_wayland_tool` could not deliver a shortcut on
either backend:
- wtype was refused outright with "Keyboard shortcuts not supported
with wtype", but wtype does chord: `-M` presses a modifier, `-k`
types a key, `-m` releases it. Modifiers must be released explicitly,
as wtype only drops them when the process exits.
- ydotool was handed the shortcut string as-is, `ydotool key ctrl+z`.
`ydotool key` takes raw evdev keycodes ("29:1 44:1 44:0 29:0") and
documents non-interpretable values as only causing a delay, so this
was a silent no-op that still returned True.
Parse the shortcut into a list of `(modifiers, key)` steps and emit the
right form per tool. Tokens are classified by name rather than by
position, because the mappings in `ActionHandler._SHORTCUT_ACTIONS` are
not all modifiers-first: `select_line` is "Home+shift+End", meaning
press Home, then Shift+End -- two sequential steps, not one chord. Each
non-modifier token closes a step and consumes the modifiers seen since
the previous one.
An unmappable ydotool key now fails loudly instead of emitting a
partial sequence, and a trailing modifier with no key is rejected at
parse time.
The X11 and WAYLAND_XDOTOOL paths are untouched -- xdotool already
accepts the string form directly.
Verified on Hyprland 0.56.2 with wtype 0.4 and ydotool 1.0.4, injecting
into a GTK3 window that logs key-press events. `wtype -M ctrl -k z -m
ctrl` arrives as z with the control modifier set; the two-step form
arrives as Home with no modifier, then End with shift; the raw keycode
form arrives as Control_L followed by z with control. Run on its own,
`ydotool key ctrl+z` exits 0 and delivers no key event at all -- the
failure this fixes.
`test_wtype_not_supported` asserted the old refusal, so it is replaced
by tests pinning the emitted argv for both tools, the two-step case,
and the unmappable-key rejection.
* fix(inject): skip leftover IBus on KDE so dictation actually types
Test Dictation and clipboard paste worked. Inject claimed success and
nothing showed up in Kate, Obsidian, terminals, or browsers.
We started ibus-daemon on X11 even when the session never opted into
IBus, then treated that daemon plus a bare xkb engine as active.
commit_text() returns True. Qt/GTK on KDE never hear it.
Do not spawn the daemon unless GTK_IM_MODULE / QT_IM_MODULE /
XMODIFIERS already name IBus. On KDE, a running daemon without those
env vars is not the session IM. Prefer a live WAYLAND_DISPLAY over a
stale XDG_SESSION_TYPE=x11, and do not take the GNOME scoped-IBus
shortcut on KDE.
See #752
* style: wrap IBus helpers so black is happy
CI lint failed on two lines in the #753 inject fix. Same behavior.
* test(inject): leftover IBus on KDE should be skipped
The CheckDependencies case still expected IBus for a leftover daemon
on KDE. That is the #752 bug. It now expects skip and wtype instead.
* chore(release): build once, and publish sha256sums and provenance (epic #701, phase 5)
* chore(ci): pin every third-party action by commit sha (epic #701, phase 5)
* test(ci): anchor the dependabot guard on the ecosystem it must cover
* fix(nightly): stamp the version before building instead of renaming the wheel
* test(nightly): require the known build jobs rather than an exact count
* fix(install): Fedora and Arch need glslc, not glslang
ggml's Vulkan CMake looks for glslc and will not take glslangValidator.
install.sh was still installing glslang on Fedora, Arch, Alpine, Void,
Solus, and Gentoo, so the pywhispercpp source build fell back to a CPU
wheel. Ubuntu and Debian already pull glslc plus patchelf.
See #604.
* fix(install): Gentoo patchelf is under dev-util
sys-devel/patchelf does not exist. emerge fails and install.sh exits,
so a fresh Gentoo install never reaches the whisper.cpp build.
See #604.
* fix(site): VocaGateway family card is Beta
PRODUCT.md and vocagateway.vocahq.com already say Beta. The homepage
chip still said Early.
See #764.
* fix(docs): README family table also says Gateway Beta
Same leftover as the homepage chip. Still optional self-hosted, not
on-device.
See #764.
AUR --no-isolation uses extra python-setuptools 84, so the <82 cap blocked the 0.16.0 rebuild. AUR pywhispercpp backends are still 1.4 and crash if we pass context_params; skip that argument below 1.5.
Bump the stable line from 0.16.0 to 0.16.1. README and UPDATE keep the 0.16 series highlights and add the patch-only fixes. Website changelog, schema, AppStream, and AUR PKGBUILD follow.
* feat(settings): even out dropdowns and tidy the About page
Pin combos to a shared right-hand column so long labels cannot stretch a
row. Fold the dictation-tone preview into a two-stage Start/Stop button,
quiet the model status card, shorten Performance copy, and rebuild About
with combined links, channel+Check, family platform tiles, and Talk to us
moved up next to the license.
* fix(settings): keep tone preview on start if the cue fails
Do not flip Start/Stop when preview_tone_cue returns false, so a missing
file or silent player cannot skip the start cue. Also annotate
PreferenceRow.set_title / set_subtitle.
* fix(settings): paint About icons for dark dialogs
Dark ink on Adwaita/Yaru-dark buttons made the family tiles and Talk to us
marks disappear. Pick a light foreground on dark themes (and keep the
original ink on light ones) so the marks match the labels.
* fix(settings): show Android and iOS marks on VocaPhone
VocaPhone covers both, so the family tile now uses the Android robot and
the Apple mark instead of a single phone icon.
* fix(settings): cut repeated About copy
Name the AGPL once on the License row. The VocaHQ blurb is just the
private-dictation line. VocaPhone says TestFlight, not source-only, and
Gateway is self-hosted and headless.
* fix(settings): space About icons from their labels
Talk-to-us buttons and the family tiles were packing the mark against the
text. Give the image an end margin and a wider gap in the family tile.
* build(appimage): pin the interpreter, packages and tooling
* ci(appimage): build inside a pinned ubuntu 22.04 image
* test(appimage): guard the pins and the base image
* docs: state the AppImage glibc floor
* fix(appimage): bundle libibus with its typelib
* ci(appimage): let the container run the isolated GI smoke test
* fix(appimage): keep the bundle's libraries out of host binaries
* fix(appimage): bundle libgpg-error with the libgcrypt that needs it
* docs(agents): require host_env() for spawned binaries
* ci: run the AppImage jobs when its other inputs change
* fix(appimage): check every bundled typelib, not just the seeded ones
* fix(ibus): keep the bundle when respawning our own interpreter
* fix(injection): pass host_env() to setxkbmap and notify-send
* test(ibus): wait for the IBus warmup thread before asserting
* feat(tray): offer the recommended model from the missing-model notification
Dictation without a model on disk showed a dead-end notification telling
the user to open Settings, find the model picker and start the download
by hand, at the moment they wanted to talk. There was no Download button
anywhere: downloads only happen as a side effect of changing a selection.
The notification now carries a "Download <model> (<size>)" button for the
model the picker already stars for this machine, so recommendations stay
in one place: get_recommended_model() for whisper.cpp, including the
English-only variant when an English language is selected, and the RAM
based recommendations for VOSK and Whisper. Clicking it downloads in the
background, reports progress in the notification, and saves the model for
that engine only after it loaded.
Notification servers that do not advertise the actions capability, and
systems without libnotify, still get the plain text they got before.
Dictation is not started for the user afterwards: a download can take
several minutes, and grabbing the microphone unannounced would surprise
someone who has long since moved on.
* feat(tray): actually fetch the offered model, and only claim it when loaded
Review follow-ups on #687.
reconfigure() only re-initializes when engine, model or language change,
and force_download is read during that re-init. First-run defaults are
whisper_cpp + tiny, and tiny is what get_recommended_model() returns on a
CPU-only machine under 16GB, so the click was a no-op that logged
"Applied VAD/silence timeout changes.", saved the model, said "Speech
model ready", and left the next shortcut showing the same missing-model
notification.
Add force_reinit to reconfigure() and pass it here, then gate the save
and the success notification on speech_engine.model_ready instead of on
reconfigure() not raising.
The "starting..." notification is now created in
_download_recommended_model, which runs on the main loop where libnotify
dispatches the action; only the handle travels to the worker, which
already routes update/close/notify through _idle_once.
The offer body no longer promises a button the server cannot draw: with
no action it points at Settings, like the notification it replaced.
Tests cover the case that breaks the feature — the recommendation equal
to the configured model — behaviourally, through a stand-in engine that
gates re-init the way reconfigure() does, plus the real reconfigure()
contract in both directions. They also stop touching sys.modules at
collection time, which is the desync #686 hit, and black is clean.
* test(notifications): cover the libnotify/notify-send boundary directly
Every test that touches notifications so far patches the whole module
away, so the module itself had no coverage: the fallback to notify-send
when the server has no action support or libnotify fails to initialize,
the handle kept alive in _pending until the user answers, and update()
refusing a fallback handle. These drive it with a fake Notify installed
through the module's own lazy cache rather than by patching
gi.repository, which is shared with every other test in the process.
* fix(download): allow only one model download at a time across tray and Settings
The tray's download worker and the Settings dialog's both drive the same
engine, which has a single progress callback slot and a single
configuration. Started together, the later one overwrote the earlier
one's progress callback, the earlier one's finally block then cleared it
mid-download, and whichever finished last became the persisted model.
Give the engine a non-blocking claim. Both Settings entry points and the
tray take it before starting a download and release it when the worker
ends; whoever loses the race is told a download is already running and
the Settings picker goes back to the saved model.
---------
Co-authored-by: Kacper Paczos <23406555+kacperpaczos@users.noreply.github.com>
Super+Space during recording on GNOME Wayland updates Mutter and IBus
but can leave XWayland on the previous map. Scoped inject restores the
IBus engine and skips setxkbmap (#474), so X apps keep the old layout.
After engine restore, copy GNOME's current XKB source onto XWayland
when DISPLAY is set. Do not replay an empty capture and do not call
restore_xkb_layout for this. Sessions with no X display are left alone.
* fix(tray): stay idle after leftover transcription on toggle stop
Toggle stop can time out while leftover audio is still transcribing.
The recognition thread then painted PROCESSING and never went idle, so
the tray stayed red until another Alt+F12.
Skip PROCESSING and LISTENING updates once recording has stopped, always
set IDLE when that thread exits, and paint the engine's current state in
the tray instead of a stale GLib.idle_add argument.
* fix(tray): leftover drain must not idle a new dictation session
stop_recognition already marks IDLE. Forcing IDLE when the leftover
worker exits can clobber a new LISTENING start during leftover drain.
The IBus test stubs assigned Bus and Factory to the MagicMock class, then
set Factory.new. That attribute lands on unittest.mock.MagicMock itself,
so every mock in the pytest session shares the same .new.
Bus and Factory are instances now, matching Text. A regression test fails
if importing the module puts new on the class.
* fix(injection): use Ctrl+Shift+V when pasting into terminals
Clipboard injection always sent Ctrl+V, which most terminal emulators
ignore. Detect standalone terminal windows and send Ctrl+Shift+V, with a
Settings override for nested IDE panels that window-class detection cannot see.
* fix(injection): fail closed when terminal window probe is mocked
Clipboard tests patch text_injector.subprocess.run, which replaces
stdlib subprocess.run. Window detection now ignores non-string probe
output so injection still uses Ctrl+V instead of raising TypeError.
* docs: fix overclaimed terminal auto-detect in USER_GUIDE
Auto-detect only runs on X11 and on Hyprland, Sway, and niri. GNOME and
KDE Wayland stay on Ctrl+V unless you set Clipboard Paste Shortcut to
Ctrl+Shift+V.
* fix(settings): save the model only once the engine loaded it
Applying settings wrote the config first and reconfigured the engine
afterwards, and reconfiguring is what downloads a missing model. A
download that failed, was cancelled, or was interrupted therefore left
the config naming a model that never reached the disk: the next start
reported "No Speech Model" while the previously working model sat on
disk, deselected. Reconfigure first and persist only on success, so a
failed switch keeps the settings that are actually running.
On failure the pickers are put back on the saved model. They otherwise
kept showing the rejected one, which also blocked a retry, since
re-selecting the same entry emits no "changed" signal.
Selecting an engine now applies as well. Every other control on the page
applies on change, but the engine only repainted the pickers: the config
and the recognizer stayed on the previous engine until another control
was touched, and closing the dialog dropped the change without a word.
Remote API is deferred until its server URL is filled in, since applying
it empty can only fail validation.
* fix(settings): restore the engine picker after a failed engine switch
Fixes the known limitation called out in the previous commit (#692):
when applying a newly selected engine failed, the resync restored the
model pickers but left the engine combo displaying the engine that was
never applied, so the page described a state that was not running.
Set the combo back to the saved engine while holding the apply
suppression flag, so _on_engine_changed() repaints the pickers without
cascading into another apply or download prompt.
* fix(settings): resync the engine picker on every path that never applied
Review follow-ups on #685.
#692 was not actually closed. The resync lived only in the download
thread's except block, and _apply_settings_internal() reports failure by
returning False as well as by raising, so the thread fell through to
set_complete(True, "") and reported a switch that never happened. Act on
the return value instead of depending on #690 to make that except live,
and add the same handling to apply_settings()'s modal path, whose except
never resynced at all.
Add _resync_engine_ui_if_unapplied() as the backstop the review asked
for: after the modal closes, and when the dialog closes, compare the
engine on screen against the engine in the config and restore the picker
when they disagree. That also covers Remote API left without a server
URL, where applying stays deliberately deferred.
_on_engine_changed now checks _initializing / _applying_settings itself,
like every other control on this page, so the combo move made by a resync
repaints without rewriting the user's language or cascading into another
apply.
Tests no longer touch sys.modules at collection time: the reimport moved
into a module-scoped fixture that also restores the attribute on
vocalinux.ui, which is what desynced test_model_deletion on 3.9/3.10.
New cases cover the modal path that #692 is about — needs_download true
with the apply returning False, and with it raising "Download cancelled" —
plus the close hook. The stub now uses RecognitionState.IDLE rather than
the string "idle", so tests stop taking the stop_recognition branch and
sleeping 0.5s.
* fix(config): share one ConfigManager across the process
main(), the tray, and the sound-effects check each constructed their own
ConfigManager. Every instance loads the whole config into memory at
construction time and save_config() writes that whole cache back, so any
save from a stale instance silently reverts what another instance saved
in the meantime — no race needed, the second save just wins with old
values. The disk-read workarounds in main (_should_append_trailing_space)
already existed to dodge exactly this.
Add get_shared_config_manager() and route the three call sites through
it. It keeps ConfigManager constructible for tests, and the tray/dialog
pair already shared one instance, so this extends that arrangement to
the rest of the process.
Tests: a regression test runs the two-instance scenario through the
accessor, and a source guard fails if application code constructs
ConfigManager directly again. conftest drops the shared instance
between tests, since it would otherwise be created under whichever
mocks the first caller had active and leak them into later tests.
The two single-instance tests now patch acquire_lock itself: they used
patch.dict(sys.modules, ...), which main()'s "from . import" ignores
once an earlier test has imported the real module — they only passed
through an unrelated mock leaking into the tray's shortcut setup, which
this change stops.
* fix(config): lock the shared accessor and prove the revert it prevents
Review follow-ups on #691:
- guard the lazy create with a lock, so two threads racing the first
call cannot each build a ConfigManager and reintroduce #689
- take both handles before the first save in the accessor test, and
assert identity there — the old order let a fresh ConfigManager pass
- add a characterization test: two direct ConfigManager instances still
revert each other, which is the footgun the accessor exists to hide
- patch get_shared_config_manager in test_main and test_audio_feedback
instead of the class, so a leftover singleton cannot skip the mock
- close the ExitStack in _boot_under_patches when main() raises; the
caller only closes a stack it received, so a failure there leaked
those patches into every later test
* fix(audio): route the tone lookup through the shared ConfigManager
The tone picker merged on main (#707) constructs its own ConfigManager,
which is exactly the stale-cache overwrite this branch removes; the new
source guard in test_shared_config_manager.py flags it.
* fix(settings): size the unused downloads list to its real rows
The list reserved 56px per row, but a row renders taller than that once
its margins and the Delete button are counted. With two leftover models
the header read "2 leftover models" while only the first row fitted in
the viewport, and the second could neither be seen nor deleted. The
overlay scrollbar gave no hint either, since it stays invisible until
hovered.
Measure the list once its rows are in place and cap that at the existing
220px, and keep the scrollbar in the layout so a list that does reach the
cap looks scrollable.
* fix(settings): test the list sizing without reimporting the dialog
Review follow-ups on #686.
The test helper popped vocalinux.ui.settings_dialog from sys.modules at
collection time and reimported it under a patched Gtk, restoring only
sys.modules and not the attribute on the package. Whichever module object
a later test reached then depended on collection order, which is why
test_model_deletion::test_whisper_list_and_delete failed on 3.9/3.10 with
assert [] == ['tiny'] while main stayed green.
Move the arithmetic into a module-level _clamp_unused_downloads_height()
and unit-test that directly, the way _list_downloaded_whisper_models is
already tested. Nothing touches sys.modules now.
The dialog cannot be instantiated under the mocked-GTK harness, so the
wiring the old tests never covered is asserted against the method source,
following test_settings_mode_change.py: refresh calls the fit, the fit
measures the listbox and feeds set_min_content_height, the per-row
estimate stays gone, and the scrollbar stays out of overlay mode.
* fix(settings): remeasure the unused-downloads list when it expands
The refresh measures while the expander is collapsed and the dialog may
not be mapped; if that first measurement ever comes back short, the
expand handler remeasures so the rows cannot stay clipped.
* fix(startup): resolve model size per engine instead of the legacy key
Startup read the generic "model_size" key, which always holds whichever
engine was saved last, so it initialized the configured engine with
another engine's model. With engine=whisper_cpp and a config left at
model_size=medium by a VOSK save, startup looked for ggml-medium.bin,
reported the model as missing and offered a fresh download while the
working ggml-small.bin named by whisper_cpp_model_size sat on disk.
Use ConfigManager.get_model_size_for_engine(), the resolver the settings
dialog already uses, so the generic key stays a write-only compatibility
shim. It falls back to the generic key when no per-engine key exists, so
older configs keep working.
* fix(config): migrate model_size into whisper_cpp_model_size
Review follow-up on #684.
get_model_size_for_engine() only falls back to the generic model_size key
when the engine key is missing from the in-memory dict, and DEFAULT_CONFIG
already ships whisper_cpp_model_size. After the merge in load_config() the
key is therefore always present, so a user file holding only
{engine: whisper_cpp, model_size: small} used to start on small and would
now start on the default tiny — the older-config path from #681.
_migrate_config already copies the model into vosk_model_size and
whisper_model_size; do the same for whisper_cpp so the resolver change is
behaviour-preserving for those files, and widen the migration trigger to
notice a missing whisper_cpp key.
Tests run against a real ConfigManager on a temp config, not a mocked
resolver: the migration case fails without this change, and the two
resolver cases pin the engine key winning over the generic one.
* installer: leave model downloads to first run when the release pins no checksums
* installer: refuse an unpinned VOSK model before spending the download
* installer: require a real device before reporting a Vulkan GPU
* build: replace the deprecated license table and complete the packages list
* installer: treat software Vulkan renderers as no GPU, not as one
* installer: keep diagnostics off stdout so captured detection output stays clean
Replace the 100% offline claims with on-device-after-model-download
language, mark Linux Available now, and add Phone and Gateway to the
family table with live sites and status.
* fix(copy): VocaWin is Beta, still unsigned
Call VocaWin Beta on the site family card, About blurb, and README table, same word as VocaMac. Keep the unsigned / SmartScreen wording. No version tag.
* fix(copy): cite VocaWin v0.1.0-beta.1, no release URL
The git tag exists on VocaHQ/vocawin. GitHub Releases does not. About, README, and the site family card name the tag. Links stay on vocawin.com and the repo.
* fix: leftover engine repair, just --no-sync, and ggml verify from #713#713 only rewrote a broken config.json toward whisper_cpp, so a reinstall
that kept the old engine still died when vosk was the extra that installed.
verify_configured_engine now prefers SELECTED_ENGINE, then any working extra,
and the vosk fallback rewrites an existing file.
just recipes use uv run --no-sync so they do not prune extras from
just deps-all. CONTRIBUTING lists libgirepository-2.0-dev for Ubuntu 24.04.
An unverified ggml file is not loaded if unlink fails, and an unpinned name
is refused rather than deleted.
* fix(appimage): retry tooling downloads on curl reset
* fix(build): bootstrap .venv for --no-sync just recipes and cover the contributor path in CI
---------
Co-authored-by: Sergey Silaev <hello@8am.dev>
* chore(build): migrate justfile tooling to uv
All recipes now run through 'uv run' against .venv, with a shared
DEV_EXTRAS variable (dev + vad) because uv syncs the environment
exactly and would otherwise uninstall the dev tools between
recipes. 'deps' installs dev+vad; new 'deps-all' covers every
extra. 'build' switches to 'uv build'; 'version' uses --no-sync.
Bump [tool.uv] required-version to >=0.12,<0.13 for uv 0.12.x, and
raise the mypy python_version target to 3.12: mypy 1.19 in the lock
cannot target 3.9 and numpy 2.x stubs need a 3.12 target to parse
(mypy is not run in CI or pre-commit; this only affects 'just
typecheck').
* fix(tests): isolate test runs from real XDG state; fix Arch model-path crash
Local test runs failed en masse (185 failures) while CI stayed
green: tests picked up the developer's real ~/.local/share/vocalinux
models via the default XDG paths and switched IBus/keyboard code
paths through the desktop session env (XDG_CURRENT_DESKTOP/
XDG_SESSION_TYPE on GNOME/Wayland, issue #478 branches). conftest
now points XDG base dirs at a throwaway session-wide directory and
clears session variables at import time, mirroring a fresh CI
runner (paths like MODELS_DIR freeze at first import during
collection, so fixture-time isolation is too late).
Also guard the Arch branch of _get_system_model_paths(): it removed
/usr/local/share/vocalinux/models unconditionally and crashed with
ValueError on Arch hosts when a custom XDG_DATA_DIRS never added
that path. Add regression tests, and make the lock-file test assert
the actual $XDG_DATA_HOME/vocalinux contract instead of the
hardcoded ~/.local/share default.
* fix(tests): keep the IBus engine double a plain class
Python 3.14's unittest.mock re-instantiates a user subclass when it builds
child mocks (klass(name=...)). VocalinuxEngine inherits from whatever
IBus.Engine resolves to, and the test module bound that to the MagicMock
class itself, so every engine instantiation raised
TypeError: VocalinuxEngine.__init__() got an unexpected keyword argument 'name'
on 3.14 while passing on 3.13. Replace it with a plain stand-in class that
still hands out per-instance MagicMocks for the IBus API surface the engine
touches.
The same pattern remains in test_ibus_engine.py, test_ibus_engine_utils.py,
test_ibus_active_engine_guard.py and test_ibus_xkb_wayland.py; those never
instantiate the engine, so they are unaffected for now.
* fix(install): build the venv from the system Python
install.sh looked up a bare `python3` from PATH. Started from an activated
virtualenv — a shell left in uv's .venv after `just deps` — it built venv/
from that interpreter, where distro PyGObject is invisible even with
--system-site-packages, and the run aborted with
Distro PyGObject (python3-gi / python3-gobject) is not importable in the venv.
A mismatched venv/ then survived every retry, because the installer reuses
whatever venv it finds.
- drop an inherited VIRTUAL_ENV (and its bin from PATH) before any
interpreter lookup
- select the interpreter explicitly: $SYSTEM_PYTHON (default
/usr/bin/python3) or PATH python3, preferring one that can import gi,
and create the venv with it instead of `python3`
- recreate venv/ when it was built by a different interpreter
- name the interpreter in the PyGObject guard's error
Documents the two environments (uv's .venv for tooling, venv/ for the
installed app) in CONTRIBUTING, the justfile header and AGENTS.md, and adds
the mismatch symptom to the INSTALL troubleshooting section.
* chore(python): raise the supported floor to 3.11
requires-python promised 3.9 while the CI matrix ran 3.9/3.10/3.11/3.13,
the classifiers advertised up to 3.14, mypy targeted 3.12 and install.sh
enforced 3.9 — five different answers to "which Python does this support".
Settle on 3.11 everywhere: pyproject (requires-python, classifiers, black
targets), install.sh's MIN_VERSION, check-system-deps.sh, the distro package
map, the CI matrix (now 3.11-3.14, matching the classifiers), the release and
nightly workflows, and the docs.
mypy now targets the floor instead of 3.12, so type checking covers what we
promise; numpy 2.x ships stubs in 3.12 syntax that a 3.11 target cannot
parse, so its imports are skipped. (mypy has 71 pre-existing errors either
way; no CI job gates on it.)
uv.lock and the requirements/* exports are regenerated — dropping 3.9/3.10
removes most of the version-conditional resolution branches.
tests/test_python_version_policy.py pins the invariant: install.sh's floor,
requires-python, the classifiers and the CI matrix must agree.
* ci: run lint and tests from uv.lock
The lock file and the hash-pinned exports were not enforced anywhere: CI
still built a venv by hand and installed unpinned `PyGObject` plus
`-e ".[dev,vad]"`, so the tool versions it checked against had nothing to do
with the ones `just lint` uses locally.
Both Python jobs now use astral-sh/setup-uv and run through `uv sync
--locked` / `uv run --locked`, which also fails the build when uv.lock drifts
from pyproject.toml.
Partially covers phase 5 of #701 (tests install from the lock, lock freshness
gate); trusted publishing, artifact checksums and action SHA pinning are
still open there.
* ci: lint from a dedicated dependency group
The lint job failed on this branch: `uv run --extra dev` syncs the whole
project, so it tried to build pyaudio from source on a runner that installs
no system dependencies.
src/pyaudio/device_api.c:9:10: fatal error: portaudio.h: No such file or directory
Move black/isort/flake8 out of the `dev` extra into a `lint` dependency
group. CI runs `uv run --locked --only-group lint`, which installs the three
pure-Python tools and skips the project entirely — no portaudio or
girepository headers needed. `just deps` and DEV_EXTRAS request the group
alongside the dev extras, so the local environment still carries test and
lint tooling at once and `just lint` matches CI version for version.
pre-commit is unaffected: its hooks pin their own tool versions upstream.
* fix(install): never configure an engine the venv cannot import
vosk became an optional extra in #705, but the installer still writes
`"engine": "vosk"` into config.json on the whisper.cpp fallback path even
when installing the extra only produced a warning. config.json also survives
reinstalls — every writer guards on "file does not exist" — so an engine
picked by an earlier attempt outlives the venv that supported it. Either way
Vocalinux dies at startup:
ModuleNotFoundError: No module named 'vosk'
- the fallback now writes the vosk config only when `import vosk` actually
succeeds, and otherwise leaves the engine configuration alone
- a new verify_configured_engine() runs near the end of the install: if
config.json selects vosk and the venv cannot import it, it says so and
prints both fixes instead of leaving a broken install behind
* feat(models): verify every model download against a pinned digest
Pins a sha256/md5 per downloadable speech model in
src/vocalinux/utils/model_checksums.txt and verifies against it in both
install.sh and the runtime downloaders, before the file is moved into place.
Verification fails closed. Covers phase 2.5 of #701; see the PR for details.
* docs(agents): rebase AGENTS.md onto the #727 rewrite
* fix(install): hash models that are already on disk instead of trusting them
* refactor(models): one download path per engine, and fix Whisper's
* docs(install): drop distros that cannot meet the 3.11 floor
* docs: retire the distros and Python versions the 3.11 floor rules out
* fix(install): abort instead of warning when Python is below the floor
* fix(install): fall back to the system Python, not the one on PATH
* fix(install): match an existing venv by installation, not by X.Y
* fix(install): repair a config naming an engine the venv cannot import
* fix(models): pin VOSK by the sha256 of bytes we fetched, not upstream's md5
* fix(justfile): request the dev set in every recipe that syncs
* test(version): scan the docs and website trees for a stale Python floor
* docs: correct the stale model-checksum and distro claims
* fix(models): stamp the VOSK tree the app extracts, as the installer does
* fix(install): replace an unverified VOSK model only once its successor is ready
* docs(install): align the Fedora floor with the rest of the tree
* test(install): drop an f-string with no placeholders
* test(models): stub the downloader probe the VOSK installer test relies on
* docs: drop the long dashes from two model-checksum comments
* fix(install): gate on the interpreter, not on the distro release label
* fix(justfile): export the lint group into requirements/dev.txt
* fix(models): unpack the VOSK tree into staging and stamp it before the swap
* fix(whisper): adopt a checkpoint an earlier release stored under the catalog name
* docs(settings): state what a stray Whisper copy really means for deletion
* fix(models): hash a whisper.cpp model found on disk before it reaches ctypes
* docs(contributing): stop telling readers to uninstall their linters
* fix(install): stop warning off distros the project documents as supported
* docs(distros): file the supported derivatives under supported
Stable minor since v0.15.0. Bumps version to 0.16.0 across app, web, AUR, and Flatpak AppStream, and refreshes docs, changelog, and SECURITY.
Tag v0.16.0 after this lands on main.
* feat(ui): About page for this app, VocaHQ, and talk to us
Settings About now has three groups. Vocalinux keeps the existing icon,
version, AGPL-3.0, updater, and What's New. The website button opens
vocalinux.com.
Part of VocaHQ lists the family sites with PRODUCT.md status. Talk to us
opens GitHub issues, Discord, X, and email. Official Design marks from
VocaHQ/.github are vendored as-is and painted with iron-white ink.
* fix(ui): unique About Open names and pin Design SVG hashes
Open buttons on About still say Open. Each one now uses its tooltip as
the accessible name so a screen reader can tell vocahq.com from
vocalinux.com from Latest Release.
The Talk-to-us SVG test pins sha256 of the four official marks from
VocaHQ/.github 61c8eee.
* fix(ui): circular app icon and About source code link
The launcher and About header used the square teal fill. Switch
both to the circular family logo (voca-logo.svg). Website stays
vocalinux.com; Source code next to it opens the GitHub repo.
* fix(appimage): pin pywhispercpp to the version install.sh declares
* fix(appimage): pin pywhispercpp at both install sites
* test(docs): match AGENTS.md uv.lock wording
#727 dropped "unused until those phases land". The test still
looked for that line, so any PR that runs Python tests against
current main failed. Pin the current contract instead: uv.lock
is authoritative, and requirements/*.txt are not hand-edited.
---------
Co-authored-by: Sergey Silaev <hello@8am.dev>
* web: restyle vocalinux.com to the Voca family workbench
Bring the Linux marketing site onto the same paper, ink, and teal language
as VocaHQ, VocaMac, VocaPhone, and VocaGateway. Drop the site-wide dark
mode so the canvas stays paper-only, matching the other product sites.
* web: loop the home ribbon without a gap or jump
The ticker used a stretched full-width track, so a -50% slide left an empty
right edge before snapping back. Two equal groups, each wider than the
viewport, scroll by exactly one group width.
* web: match vocahq.com ribbon speed and teal band
The looped ticker was covering six item cycles in 32s, so it raced.
It now spends 28s per cycle like vocahq.com, uses the same dark-teal
band, and pauses when the tab is hidden.
* web: drive the hero panel clock from the visitor's local time
Replace the static 11:24 mock with a 24-hour HH:MM clock in the client
timezone, updated on the minute.
* web: add official platform marks on the family cards
Use the .github brand platform SVGs (Linux, Apple, Android, Windows)
plus the HQ server mark for VocaGateway, matching vocahq.com's product row.
* web: restore a Light/Dark flip on the screenshots page
The captured GTK shots exist in both appearances. A page-local switch
defaults to prefers-color-scheme and stores a tab override. The rest of
the site stays paper-only.
* web: make the homepage CTA say Install, not Get
The button already jumps to the one-line installer. Name that action,
note that there is no account, and keep the same copy in the closer
and the mobile menu.
* web: style install commands as GTK terminals
Command boxes use a headerbar with copy, a centered title, and
window controls on the right. The prompt is user@linux:~$, not
Mac traffic-light chrome.
* web: stop repeating the engine list in the hero
The proof line, the notes window, and the tray terminal all said
the same whisper.cpp / Whisper / VOSK / Remote API line. Engines
stay on the window chips. Proof is now desktop and license facts.
The terminal shows launching into the tray.
* web: reflow copy, color prompts, and add install paths
Drop pretty/balance wrapping so titles and ledes follow the
shell width. Color the user@linux prompt. Hero terminal now
says audio stays on this machine. Ribbon adds project words.
AppImage and source-build sit under the install command.
* web: put X, Discord, and GitHub by the footer mark
Official family social marks sit next to the Vocalinux logo.
Footer columns are Product, Install, Guides, and Project.
The bottom line is Part of VocaHQ, without X11 or whisper.cpp.
* web: add hello@vocahq.com to the footer social row
The official mail mark sits with X, Discord, and GitHub and opens
the family hello@vocahq.com address.
* web: use the circular mark in the header and footer
Header and footer now use the official teal circle lockup instead
of the square app icon.
* web: ship a circular favicon and a family OG card
Favicons use the teal circle lockup. The Open Graph image is redone
in the paper workbench language with the current homepage line.
* web: set the hero to Dictate locally / In any app
Shorter couplet in the VocaHQ display size, with a matching
lede and Open Graph card.
* web: gray the hero terminal note as a comment
The second line was reading as part of the vocalinux command.
It is now a dim # comment under the prompt.
* fix(web): drop stale 100% offline copy and mark VocaWin alpha
vocalinux.com still called VocaWin planned and said the Linux app was
100% offline. Local engines stay on the machine. Remote API is opt-in.
VocaWin is developer alpha: unsigned NSIS (~12 MB) and MSI (~34 MB),
latest prerelease v0.1.0-alpha.1.
Fixes#709
* fix(web): drop leftover never-leaves claims
Open source and privacy pages still said voice data never leaves the
machine. Local engines stay local. Remote API is opt-in.
#707 wrote generated start/stop files for the family picker. Swap those
for the downloaded preview bytes (voca is pair 05) and skip the catalog
ids in generate_sounds.py so they stay put.
* fix(settings): stop Test Dictation calling a missing model "no speech"
start_recognition now returns whether listening actually began. Test
Dictation checks that before starting its timer, so a missing or
auto-paused model gets a real error instead of "No speech detected".
* fix(settings): reconfigure Test Dictation against the live engine
UI matching config.json is not enough when speech_engine is still on
another engine or model size. Compare those too before the listen timer.
* fix(tests): keep settings_dialog lookups on one module
_load_settings_dialog() restored sys.modules but left
vocalinux.ui.settings_dialog pointing at the private reload.
Python 3.9/3.10 mock follows that attribute, so
test_whisper_list_and_delete patched a different copy than it
imported and the expanduser stub hid the real cache.
Point the package attribute back at the restored module, and
patch the imported module object in the deletion test.
Settings → Audio can pick start/stop cues from the shared catalog
(lift, flick, ember, step, voca, soft, chirp, scale, drop, glass, off).
New installs and any missing or unknown sound_effects.tone value use
voca. A saved id, including off, is left alone.
The old start_recording/stop_recording files stay on disk. They are
no longer the implicit default. Error cues still follow the existing
enable switch.
* fix(install): leftover review notes from #700 and #705
The scratch dir now holds pip logs and model download temps, so a failed
run has something to inspect. Dep, venv, leftover-process, and pip
failures use exit codes 2/3/4 instead of a blanket 1. --help says the
transcript is written during installation, and the tag hint is
--tag=<release>.
AGENTS.md no longer claims the hash-pinned exports are consumed (they
wait for phases 2/3/5). Docs add a just install line and
vocalinux[vosk] next to the PyPI commands. Settings points at
--engine=whisper. The whisper.cpp fallback installs ".[vosk]".
Fixes leftover review notes from #700 and #705.
Refs #701.
* fix(install): do not let pip build PyGObject from sdist
Fresh --auto --skip-system-deps died on pygobject-3.56.3 even with
distro python3-gi present. Install the other runtime deps from
pyproject.toml, then pip install --no-deps. Extra installs use the
extra's packages only, not pip install ".[extra]".
Fixes leftover review notes from #700 and #705.
Refs #701.
* fix(install): tolerate unset XDG_SESSION_TYPE under set -u
--auto with DISPLAY but no login session died on
XDG_SESSION_TYPE: unbound variable. The session probe now uses
${XDG_SESSION_TYPE:-}, ${WAYLAND_DISPLAY:-}, and ${DISPLAY:-}.
Phases 0-1 of #701. Drops dead deps, moves vosk to an optional extra, adds uv.lock and hash-pinned requirements exports, pins pywhispercpp to 1.5.0.
Closes#705. Refs #701.
Stacked bottom of #701. Replaces Makefile with justfile and hardens install.sh (strict mode, logging, HTTP probes, curl -f on model downloads, no v0.10.1-beta fallback).
Closes#700.
Replace the old traced app icon and tray SVGs with the approved family
mic geometry from VocaHQ/.github brand assets. Regenerate vocalinux.com
favicons and logo PNGs/WebPs from the same source. No version bump.
Per brand book §9 and §10.
TestTypedAccessors builds its ConfigManager with __new__ and, unlike
TestConfigManager, never patches CONFIG_DIR/CONFIG_FILE. That is fine
for the accessor tests, but test_whispercpp_advanced_persistence calls
save_config(), which writes the synthetic fixture from setUp over the
developer's real ~/.config/vocalinux/config.json — running the suite
silently wiped every personal setting. Caught by making the real file
read-only and running the suite with live logging; it is the only
write to the real path in the entire suite.
Redirect the paths to a temp directory for this test, the same way
TestConfigManager's setUp already does.
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
Both modal download paths ran _apply_settings_internal() on a worker
thread and then unconditionally marked the progress dialog complete.
Since _apply_settings_internal() swallows every exception internally,
a download that failed or was cancelled still ended with "Complete!",
the dedicated except blocks below (the "Download cancelled" and
Whisper-not-installed branches) were unreachable, and the swallowed
path built and ran a Gtk.MessageDialog from the download thread, off
the main loop.
Give _apply_settings_internal() a raise_errors flag and pass it from
the two thread call sites. Their handlers become reachable and already
report through the progress dialog via GLib.idle_add, so failures now
surface with the real reason and no Gtk work happens off the main
loop. Main-loop callers keep the old contract: swallow, show the error
dialog, return False.