* 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.
* fix(whisper): use bundled GPU libs and skip software Vulkan devices
AppImage 0.15.0 shipped a CPU-only pywhispercpp wheel, so the Settings
GPU picker and host vulkaninfo never moved inference off the CPU. Rebuild
pywhispercpp with Vulkan in the AppImage, log the libraries that actually
load, skip llvmpipe in auto-select, and document how to verify.
Fixes#604
* fix(appimage): pin gcc/g++ for Vulkan pywhispercpp rebuild
Hosts that default cc/c++ to clang fail CMAKE's C++ test because clang
does not search gcc's libstdc++ path. Default the rebuild to gcc/g++.
Prefer 2ch before 1ch when PortAudio reports two or more input
channels. Intel SOF digital mics (the Raptor Lake device in #666)
often only support stereo at the PCM; opening them as converted
mono can abort with heap corruption right after capture starts.
Mono-only devices are still never probed with 2 channels (#567).
* fix(whisper): use CUDA device 0 when pywhispercpp is CUDA-backed
Vulkan GPU enumeration on hybrid laptops lists the iGPU as GPU0 and the
dGPU as GPU1, but CUDA ordinals always place the first NVIDIA GPU at 0.
Auto mode and the settings UI were passing Vulkan indices into CUDA
pywhispercpp, which silently fell back to CPU inference.
Detect the actual pywhispercpp backend before selecting gpu_device and
map CUDA installs to device 0. Vulkan-backed installs keep Vulkan indices.
* test(whisper): cover CUDA gpu_device selection branches for codecov
---------
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
* fix(audio): filter unsafe virtual devices that crash capture
This is a defensive fix for #624 heap corruption seen when PortAudio opens PipeWire pseudo-devices such as DeepFilterNet, default, pipewire, paplay, monitor, null, dummy, or filter-chain sources. The root cause is not fully proven, but avoiding explicit capture from these unsafe virtual rows should keep dictation on real microphones while still allowing System Default through device_index=None.
If this still crashes with a physical microphone selected, please confirm with the exact physical device name and audio stack details so the capture path can be narrowed further.
Fixes#624
* fix(settings): sync audio device fallback with raw name restore
Address Bugbot review on #629: match legacy "(default)" saved names and
clear stale config/engine state when a filtered device falls back to
System Default.
* fix(audio): keep system default capture off pseudo device indices
Stop converting System Default to an explicit PortAudio index in the
mic test, and fall back to host default capture when every enumerated
input is filtered as unsafe.
* test(audio): expect system-default fallback on reconnect
* test(audio): cover system-default fallback paths for capture
* fix(audio): prevent Bluetooth mic probe heap corruption
Open PortAudio streams only once when negotiating capture format, and
always stop_stream() before close(). Separate channel/rate probing was
aborting on Bluetooth SCO devices (e.g. FreeBuds) with malloc corruption.
Fixes#567
* refactor(audio): open capture stream once instead of probe-close-reopen
Adversarial review of the first fix found it still closed the probe
stream and immediately reopened the device — the same rapid open/close
pattern blamed for the Bluetooth SCO heap corruption, just fewer cycles.
_open_capture_stream() now returns the successfully negotiated stream
and all three callers (mic test, recording, reconnect) use it directly,
so each capture session performs exactly one PortAudio open. Also drops
the dead known_working_rate parameter.
* test(audio): cover negotiation fallback paths for codecov patch
Add tests for mic-test, recording, and reconnect fallback branches when
_open_capture_stream returns no stream, plus Bluetooth settle-on-failure
and empty-read reconnect cleanup. Brings patch diff coverage above 80%.
* fix(vulkan): auto-select discrete GPU and add manual device selection
On hybrid GPU systems (Intel iGPU + NVIDIA dGPU), whisper.cpp's Vulkan
backend always selected device 0 (the integrated GPU), resulting in
significantly slower performance.
Changes:
- Add detect_vulkan_devices() to enumerate all Vulkan GPUs and identify
discrete vs integrated devices
- Set GGML_VULKAN_DEVICE env var before model init to select the correct
GPU (auto-selects discrete GPU when available)
- Add whispercpp_gpu_device config option for manual device selection
- Add GPU device dropdown in Settings > Advanced for users with multiple
Vulkan GPUs
Fixes#589
* test: add coverage for Vulkan GPU device selection
- Test detect_vulkan_devices() with single/multiple GPUs
- Test _prefer_discrete_vulkan_device() preference logic
- Test GPU device selection in recognition_manager
- Verify GGML_VULKAN_DEVICE env var is set correctly
* fix(speech_recognition): pass whispercpp GPU device to pywhispercpp Model context_params
The Vulkan device-selection logic from PR #590 set the GGML_VULKAN_DEVICE
environment variable, but pywhispercpp did not pick it up. Pass the selected
GPU device through the pywhispercpp Model context_params so whisper.cpp
actually initializes the requested Vulkan device.
Fixes#589
* fix(speech_recognition): fallback when pywhispercpp lacks context_params
Older pywhispercpp releases (< ~1.4.0) reject the context_params keyword
and raise TypeError. Detect that specific failure and retry without GPU
device selection so the engine still loads on those versions.
Fixes#589
* refactor(speech_recognition): inline context_params assignment
Simplify the GPU device selection path and remove the temporary dict.
* fix(vulkan): parse real vulkaninfo GPU0: headers
detect_vulkan_devices() only recognized fictional "GPU id = N" headers,
so real vulkaninfo --summary output (GPU0:/GPU1:) returned no devices and
hybrid NVIDIA systems fell through to CUDA. Also resolve preferred device
names by GPU id instead of list position.
Fixes#589
* refactor(whispercpp): drop ineffective GGML_VULKAN_DEVICE env var
pywhispercpp ignores GGML_VULKAN_DEVICE; device selection already goes
through context_params={"gpu_device": N}. Remove the misleading env write
so maintainers do not think the env var is the selection path.
* feat(vad): replace amplitude-based VAD with Silero neural network
Replace the simple amplitude threshold VAD with Silero VAD, a lightweight
neural network model that classifies audio as speech/non-speech. This
addresses the root cause of #302 (silence not stopping recording) and
- Add silero_vad.py wrapper using ONNX Runtime with bundled Silero v5 model
- Accumulate 512-sample chunks with 64-sample context window per official API
- Map existing vad_sensitivity (1-5) to Silero thresholds (0.8-0.3)
- Use max() across sub-chunks to avoid missing speech onsets
- Fall back to amplitude VAD when onnxruntime is unavailable
- Add onnxruntime as optional dependency (pip install vocalinux[vad])
Closes#344
* address review: add tests, LICENSE, logging, validation fixes
- Add unit tests for SileroVAD (process, reset, fallback, sensitivity mapping)
- Add MIT LICENSE for bundled Silero VAD model in data/
- Log which VAD backend is active on init
- Validate chunk length in SileroVAD.process()
- Skip is_speech check until a full Silero chunk is processed
- Add thread safety note to SileroVAD docstring
- Document resampling rate effect on VAD resolution
- Comment explaining why load_silero_vad() catches broad Exception
* address review: bundle license, document [vad] extra, surface backend in UI
- Pack data/LICENSE into the wheel (pyproject package-data) so attribution
travels with the model
- README: short note on enabling neural VAD via vocalinux[vad]
- Settings: VAD Sensitivity row subtitle shows the active backend; if
onnxruntime is missing, an info-row beneath the group spells out the
install command and the restart requirement
- silero_vad.is_silero_available() probes onnxruntime + model presence
w/o instantiating an InferenceSession
- CI installs .[dev,vad] so Silero paths get exercised
- Integration test for _record_audio Silero branch (mocked pyaudio +
scripted SileroVAD), covering silence flush, speech reset, and
sensitivity threshold mapping at sens=1 vs sens=5
- Tests no longer skip when other modules globally mock numpy: pop the
top-level numpy ref so a fresh import gives the real package, and
swap sys.modules["numpy"] inside the _record_audio call so the lazy
import inside the function picks up real numpy too
* fix(vad): drop silence-only recording buffers
* test(vad): align stop buffer expectations
* test(vad): preserve real numpy module
* fix(install): install vad support opportunistically
* test(vad): cover recognition manager vad edges
---------
Co-authored-by: Sbenazar <127324689+Sbenazar@users.noreply.github.com>
* test(recognition): cover _perform_recognition and _enqueue_audio_segment
The previous commit demoted logger.info('DEBUG: ...') to logger.debug(...)
and removed a dead loop body in _perform_recognition. Codecov flagged 12
uncovered lines — all inside _perform_recognition and _enqueue_audio_segment,
which existing tests always mocked out.
Add direct (non-mocked) tests for both methods:
- _enqueue_audio_segment:
* empty buffer → early return
* normal buffer → enqueues correctly
* full queue → drops oldest and retries
- _perform_recognition:
* processes a segment then exits on None signal
* drains remaining items on None signal
* exits cleanly when idle with empty queue
* continues looping on queue timeout while recording
These tests exercise the actual threading/queue logic and cover the
logger.debug / logger.warning calls as a side effect.
* test(conftest): suppress real desktop notifications during test runs
_show_notification shells out to notify-send, which pops up real system
notifications when tests exercise the model-not-ready path. Add an autouse
fixture that patches _show_notification for all tests EXCEPT those whose
name contains 'show_notification' (so direct tests of the helper still
verify subprocess.Popen calls).
* chore(gitignore): ignore .sisyphus directory
Replace hard-coded stop tail trim with configurable stop_sound_guard_ms
(default 200ms) so quick key release no longer clips the final spoken
word in push-to-talk mode.
Co-authored-by: Fakhareddine <fe.sabbari@claranet.com>
* test(coverage): improve test coverage from 57% to 80%+
Add 30 new test files with 861 additional test cases to improve
overall test coverage from 57% to 80%+ (statement coverage).
Key modules covered:
- recognition_manager.py: 43% → 69% (audio device detection, transcription,
model download, engine initialization, buffer management)
- text_injector.py: improved coverage for X11/Wayland text injection,
environment detection, clipboard fallback, shortcut injection
- ibus_engine.py: 22% → 73% (engine lifecycle, text injection, socket server)
- evdev_backend.py: 11% → 78% (device detection, key event handling)
- pynput_backend.py: 43% → 76% (start/stop, key handling, callbacks)
- keyboard_backends/__init__.py: 54% → improved (backend creation, env detection)
- whispercpp_model_info.py: 32% → improved (GPU detection, model recommendations)
- about_dialog.py, first_run_dialog.py, main.py, single_instance.py,
common_types.py, config_manager.py, autostart_manager.py: various improvements
All 1433 tests pass with proper test isolation to prevent mock pollution
between test modules.
* test(quality): audit and fix test quality issues
Code review audit identified and fixed the following issues:
- Remove bare try/except blocks that swallow all errors (7 tests fixed
in recognition_manager_deep, replaced with proper assertions or deleted)
- Delete source code inspection tests that read .py files as strings
instead of testing behavior (deleted test_about_dialog_coverage.py and
test_first_run_dialog_coverage.py entirely - ~35 useless tests removed)
- Add meaningful assertions replacing weak assertIsNotNone/assertIsInstance
checks (common_types_coverage, ibus_engine_coverage, keyboard_backends_extra)
- Fix tests that mock the system under test instead of its dependencies
(text_injector_extra now mocks subprocess.run instead of injection methods)
- Add missing assertions to tests with no verification
(ibus_engine focus_out, log window info, copy to clipboard)
- Verify download tests check requests.get was called with correct URLs
(coverage_boost download tests)
- Fix pytest warning by renaming test_audio_input import to avoid
pytest collecting it as a test function
All 1356 tests pass with 0 failures and 0 warnings.
Statement coverage: 80.3% (3257/4054 lines covered).
* style(tests): fix black formatting and isort import ordering
Auto-format all new test files with black and isort to pass CI lint checks.
* fix(tests): resolve CI hangs and rename test files for clarity
- Fix CI hangs by properly mocking gi/GTK/IBus in all test files
- Fix sys.modules pollution in recognition manager tests by using tmp_path
- Add pytest-timeout to prevent future hangs
- Rename 28 test files to use descriptive, consistent names mapping to source modules
* fix(tests): resolve Python 3.13 CI failures
- Fix _load_stdlib_module() in test_recognition_manager_downloads.py to
handle stdlib modules that are packages (directories with __init__.py)
rather than single .py files. In Python 3.13, zipfile became a package,
causing FileNotFoundError during test collection (exit code 2).
- Fix invalid escape sequence '$' in test_cross_distro_compatibility.py
by using a raw string. Python 3.13 reports this as SyntaxWarning and
Python 3.14 will make it a SyntaxError.
* fix(tests): use direct import for zipfile instead of _load_stdlib_module
The _load_stdlib_module approach fails on Python 3.13 where zipfile is a
package (zipfile/__init__.py). Loading it via spec_from_file_location with
a custom name breaks relative imports inside the package (e.g.
'from ._path import ...' resolves to '_stdlib_zipfile._path' which doesn't
exist).
Simply import zipfile at module level before any test mocks can pollute
sys.modules — this is safe because the module-level code runs during test
collection, before any test fixtures or patches are applied.
* fix(tests): mock gi/GTK in conftest to prevent CI hangs
On CI, real PyGObject is installed and module-level imports in vocalinux
source files (ibus_engine, tray_indicator, settings_dialog, etc.) trigger
GTK initialization or IBus daemon connections that hang indefinitely in
headless environments. pytest-timeout's signal-based timeout cannot
interrupt these C-level blocking calls.
Fix by mocking gi and gi.repository in conftest.py (which runs before any
test module is collected), ensuring all test files are protected regardless
of whether they remember to mock individually.
Also add timeout-minutes: 10 to the CI test job as a safety net so that
any remaining hangs fail fast instead of running indefinitely.
* fix(tests): unconditionally mock gi in conftest for CI
The previous 'if gi not in sys.modules' guard was ineffective on CI
because PyGObject is installed via pip, so the real gi module is already
loaded. This caused GTK initialization and IBus daemon connections that
hang indefinitely in headless CI environments.
Now unconditionally replace gi and gi.repository with mocks during test
collection, regardless of whether the real package is installed.
* fix(tests): reduce timeout to 10s and use thread-based timeout
Use timeout_method=thread instead of signal (default) so that
pytest-timeout can interrupt C-level blocking calls (GTK operations,
socket accepts, subprocess waits) that the signal-based method cannot
handle. Also reduce per-test timeout from 30s to 10s since no legitimate
unit test should take longer, and hanging tests were consuming the entire
CI job timeout budget.
* fix(tests): set global socket timeout to prevent blocking on CI
Tests in test_ibus_engine.py create real Unix sockets with daemon threads
that block on socket.accept(). When tests fail or timeout, these threads
remain blocked indefinitely, preventing pytest from completing. The
thread-based timeout catches the hang but cannot terminate the blocking
accept() call.
Set socket.setdefaulttimeout(5) in conftest.py so all socket operations
(accept, connect, recv) will raise socket.timeout after 5 seconds instead
of blocking forever.
* fix(tests): add cleanup fixture for IBus socket server threads
Add autouse fixture that stops any leftover VocalinuxEngine socket server
threads after each test. Tests that trigger _start_socket_server() spawn
daemon threads blocking on socket.accept() that persist after the test
completes, eventually causing pytest-timeout to kill the process.
The cleanup fixture sets _server_running=False and closes the server
socket, which unblocks the accept() call and allows the daemon thread
to terminate.
* fix(tests): reduce socket timeout to 0.1s to avoid slowing tests
The 5-second socket timeout caused socket operations in daemon threads
to block for 5 seconds per iteration before timing out, significantly
slowing the test suite on CI. Reduce to 0.1s so blocking socket calls
fail fast without noticeably impacting test execution time.
* fix(tests): use 2s socket timeout and increase CI timeout to 15min
The 0.1s socket timeout was too aggressive and caused legitimate test
socket operations to fail. Use 2s which is long enough for test sockets
to work but short enough to unblock daemon threads.
Increase CI job timeout to 15 minutes to accommodate the socket timeout
overhead from daemon threads that retry every 2 seconds.
* fix(tests): block known-blocking daemon threads to prevent CI hangs
Instead of using socket timeouts (which either break tests or are too
slow), prevent specific known-blocking daemon threads from starting:
- ibus_engine.server_thread: blocks on socket.accept()
- evdev_backend._monitor_devices: blocks on select.select()
These threads are identified by their target function name and skipped
during Thread.start(). The thread is marked as started/stopped so
join() calls in cleanup code don't raise RuntimeError.
Test-created daemon threads (e.g. mock socket servers) are unaffected
since they use different target function names.
* fix(tests): patch Thread.join for Python 3.13 compatibility
Python 3.13 changed threading internals — join() now uses _handle.join()
which raises 'thread not started' for threads that were never actually
started. Instead of trying to fake internal state, patch join() to be a
no-op for threads that were skipped by our _safe_thread_start guard.
This fixes the 2 remaining test_evdev_backend_ext failures on Python 3.13.