feat: add remote API speech recognition engine with installation and configuration support. (#335)

* feat: add remote API speech recognition engine with installation and configuration support.

* fix: reformatted by black

* fix: unit test in test_main_initializes_components

* fix typo

* Apply suggestion from @jatinkrmalik

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>

* feat: Validate remote API URL format, enhance connection testing, update default remote API endpoint.

* fix typo

* feat: add remote API recognition manager test suite

* fix: add Python 3.14 support and lxml>=6.1.0 (fixes #404)

- Add Python 3.14 to classifiers
- Add lxml>=6.1.0 to dependencies to fix install on Python 3.14 (CachyOS, Arch)
- Update black target-version to include py314

Fixes #404
Closes #405

* fix(install): validate pyproject.toml/setup.py content before entering local repo mode (fixes #396)

Fix false local repo detection when users run `uv init` before installation. Changed detection from naive file existence check to content validation:
- For pyproject.toml: check for `name = "vocalinux"`
- For setup.py: check for "vocalinux" string
- Falls through to remote clone mode if neither matches

Fixes #396

* fix(recognition): preserve final speech on stop (fixes #401)

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>

* fix(ibus): handle engine instance destruction on layout switch (fixes #388, closes #389)

Override do_destroy() to clear _active_instance when IBus destroys the
engine on keyboard layout switch (Super+Space). Add retry logic in
inject_text() — on NO_ENGINE, re-activates via switch_engine() and
retries once to recover cleanly.

Co-authored-by: Sbenazar <127324689+Sbenazar@users.noreply.github.com>

* chore: ignore opentree worktree dir and config (#412)

.opentree/ contains git worktrees created by the opentree CLI tool
(https://github.com/axelgar/opentree). These are local developer
artifacts and should not be tracked.

opentree.toml is the per-repo opentree config - also local/personal,
not something contributors need to share.

* test(ibus): fix do_destroy CI failure (issubclass TypeError without IBus) (#410)

* test(ibus): fix do_destroy super() test failing in CI without IBus installed

Patch builtins.super instead of relying on mock_ibus.Engine.do_destroy,
which fails with issubclass() TypeError when VocalinuxEngine is built
with object as its base (IBus unavailable in CI).

* test(ibus): fix MagicMock.__new__ TypeError on Python < 3.13

VocalinuxEngine inherits from MagicMock at import time (IBus is mocked
via sys.modules at test-file level). Calling VocalinuxEngine.__new__(VocalinuxEngine)
delegates to MagicMock.__new__ which runs issubclass(cls, AsyncMockMixin).
On Python < 3.13 that raises TypeError when the module-cached class is a
MagicMock spec object rather than a real type.

Fix: introduce _make_engine() helper that allocates via a thin concrete
subclass whose __new__ calls object.__new__ directly, bypassing the
MagicMock allocation path entirely. Verified across all three
TestVocalinuxEngineDestroy test methods.

* test(ibus): use unbound do_destroy call to avoid MagicMock interference

The previous fix (thin _ConcreteEngine subclass) still failed because
VocalinuxEngine inherits MagicMock.__getattribute__, which intercepts
.do_destroy on instances and returns a mock attribute rather than the
real method - regardless of Python version.

Call VocalinuxEngine.do_destroy(engine) directly with a plain object()
as the self argument instead. do_destroy only uses self for identity
comparison (is VocalinuxEngine._active_instance), so any object works.
This bypasses MagicMock's MRO entirely.

* test(ibus): reload module and instantiate real engine class for do_destroy tests

* refactor(ibus): extract destroy handler for stable cross-version tests

* test(ibus): cover destroy helper branches and do_destroy delegation

* test(ibus): stabilize do_destroy CI behavior across environments

Avoid brittle super() mocking in no-IBus environments and gate the IBus-only parent destroy path test behind runtime capability checks to prevent false CI failures on Python matrix runners.

* test(ibus): remove flaky do_destroy class-path assertions

Drop brittle class-method destroy tests that fail under mocked GI/IBus on older Python runners and keep deterministic helper-level coverage for destroy behavior.

* fix(text-injection): recover from IBus runtime failures (#411)

* fix(ibus): retry and recover from transient socket failures

Harden IBus injection against connection-refused, timeout, and engine process races so dictation is more resilient in real-world desktop sessions.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

* fix(text-injection): fallback to non-IBus backend on runtime IBus failures

Allow dictation to continue when IBus runtime injection fails by automatically switching to xdotool/wtype/ydotool fallbacks instead of returning an immediate failure.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

* fix(ibus): preserve live socket injection during recovery

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

* test(coverage): add runtime fallback and ibus retry coverage

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

* fix(ibus): probe engine readiness at startup and harden NO_ENGINE retries (#391)

* fix(ibus): wait for engine readiness before first injection

Add a startup readiness probe that pings the IBus engine after activation so first-use dictation no longer races do_enable(). Also switch NO_ENGINE retry behavior to exponential backoff and cover the new flow in tests.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* test(ibus): cover readiness probe edge cases

Add focused tests for the new startup readiness path, including direct PING success, retry exhaustion, unexpected responses, and engine re-activation before injection. This raises coverage on vocalinux.text_injection.ibus_engine from 68% to 73% in the targeted suite.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(text-injection): avoid blocking IBus and clipboard fallbacks

Warm IBus in the background so startup no longer stalls while readiness probing, and make clipboard helpers fail fast with per-session health caching so non-ASCII ydotool fallback does not lock dictation.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* test(ibus): update retry tests for merged NO_ENGINE recovery logic

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* chore(deps-dev): bump postcss from 8.4.39 to 8.5.10 in /web

Bump postcss dev dependency (CSS processing). No runtime impact.

* fix(logging): clean up runtime log noise and cache hardware detection

Clean up duplicated code artifacts from recent merges and cache hardware detection to eliminate ~1.3s UI lag when opening settings dialog.

* test: cover recognition internals and suppress desktop notifications (#414)

* 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

* fix(install): improve openSUSE dependency handling with fallbacks (#418)

The suse case in install_system_dependencies() previously installed all
packages blindly, causing the entire install to fail if a single package
was unavailable (e.g., glslang on some openSUSE repos).

Changes:
- Check each package individually with rpm -q before installing
- Add graceful fallback for glslang -> glslang-devel
- Add graceful fallback for libappindicator-gtk3 -> typelib-1_0-AppIndicator3-0_1 -> libayatana-appindicator3-1
- Continue installation with warning instead of hard failure

Fixes #407

* fix(remote_api): re-init engine on resume from suspend

Add a remote_api branch to reinitialize_after_resume mirroring the
dispatch chain in __init__, so the engine is recreated after a
suspend/resume cycle. Null self._http_session alongside the existing
self.model and self.recognizer resets so a stale pooled-connection
session is not reused after resume.

* perf(remote_api): pool HTTP connections via requests.Session

Introduces a manager-owned requests.Session (_http_session) so all
remote_api engine HTTP traffic reuses a single connection pool instead
of opening a new connection for every request.

Lifecycle:
- __init__: self._http_session = None (attribute always present)
- _init_remote_api: close prior session if it exists, then create a
  new requests.Session(); use session.get for the connection test.
  Prevents connection leaks on back-to-back inits (re-init guard).
- _try_openai_api / _try_whispercpp_server_api: replaced requests.post
  with self._http_session.post; local 'import requests' retained for
  the requests.exceptions.ConnectionError catch.
- reconfigure: captures old engine before self.engine is updated;
  closes and nulls _http_session when switching away from remote_api.
- reinitialize_after_resume: nulls _http_session before _init_remote_api
  recreates it.

Tests: added TestHTTPSession class (4 cases) in
tests/test_recognition_manager_remote_api.py; all 40 tests pass.
Updated _setup_requests_get_*/post_* helpers to configure the session
instance (mock_requests.Session.return_value) instead of the
module-level mock, keeping all 36 pre-existing tests green.

* docs: add remote HTTP transcription setup guide

* fix(install): avoid asking for remote server URL twice

run_interactive_install collects the URL upfront in Step 3, but
install_python_package re-declared REMOTE_API_URL as local and prompted
again, ignoring the wizard value. Inherit the wizard's value instead.

* ui(settings): hide remote API behind an Advanced tab

Addresses upstream PR #335 review feedback from @jatinkrmalik that
remote API is not plug-and-play and should not be a peer of the local
engines for casual users.

- Remove 'Remote API' from the Speech Engine tab's engine combo; the
  combo now shows local engines only (vosk / whisper / whisper_cpp).
- Add a new 'Advanced' notebook tab with a 'Remote Server' group
  containing a 'Use remote server' switch, the existing URL/Key/
  Endpoint fields, and the Test Connection button + status label.
- The switch overrides the local engine selection. get_selected_settings
  returns engine='remote_api' iff the switch is on, otherwise the combo
  selection. URL/Key/Endpoint persist independently of the toggle state.
- _load_and_apply_settings: if config has engine='remote_api', the
  switch is turned on and the combo is set to a sensible local fallback
  (whisper_cpp, or first available local engine) so toggling off has
  somewhere to land.
- _update_engine_specific_ui no longer special-cases the combo for
  remote_api (the combo never has it); it hides the model row, info
  card and legend whenever the remote switch is on.
- Update HTTP_REMOTE.md to reflect the new tab location.

* ui(remote-api): mark remote API as advanced and default toggle off

- install.sh: relabel option 4 as 'REMOTE API (ADVANCED)' to signal
  it as a non-default, advanced engine choice.
- settings_dialog.py: explicitly initialise the 'Use remote server'
  switch to inactive at widget creation, making the default state
  unambiguous (saved-config load logic still toggles it on for
  existing remote_api installs).

* fix(remote-api): preserve trailing newline from transcription responses

The remote-API transcription path stripped all whitespace from the
upstream response before injection, silently discarding a trailing
newline that some pipelines (post-processing proxies, fixup layers)
emit to signal "submit" / Enter. Whisper itself doesn't emit trailing
newlines, so for the common case this is a no-op; for users with a
post-processing layer it stops their submit signal from being eaten.

_filter_non_speech now uses a whitespace-stripped probe for its
non-speech pattern checks but returns probe + "\n" when the original
input ended with "\n" or "\r" (CRLF normalises to LF). The redundant
.strip() at the call site is removed so the function receives the raw
API text. Existing filter behaviour (BLANK_AUDIO, music notes, low
speech-content ratio, etc.) is unchanged. Four new unit tests cover
the trailing-newline cases.

* test(remote-api): cover transcribe exception handler and unknown-engine reinit branch

Adds two unit tests buttoning up previously-uncovered defensive branches:

- _transcribe_with_remote_api swallows unexpected exceptions and returns
  "" — verified by raising RuntimeError from _try_whispercpp_server_api.
- reinitialize_after_resume short-circuits without dispatching when the
  engine attribute is unknown — verified by mutating engine to a bogus
  value and asserting no _init_* method is called.

* feat(settings): add Advanced tab with whisper.cpp anti-hallucination parameters (#415)

* feat(settings): add Advanced tab with whisper.cpp anti-hallucination parameters

Add a new Advanced settings tab that exposes whisper.cpp decoding
parameters to help users reduce hallucinations and tune recognition
behavior.

Parameters exposed:
- No Timestamps (default: on)
- Suppress Non-Speech Tokens (default: on)
- No Context (default: on)
- Initial Prompt (default: empty)
- Temperature (default: 0.0)
- Temperature Increment (default: -1.0, disables fallback)
- Entropy Threshold (default: 2.4)
- Logprob Threshold (default: -1.0)
- No-Speech Threshold (default: 0.6)

All parameters are saved under the advanced config section and are
only active when the whisper.cpp engine is selected. The UI grays out
these settings and shows an explanatory note when another engine is
active.

Changes:
- config_manager.py: add whisper.cpp keys to DEFAULT_CONFIG[advanced]
- recognition_manager.py: read params from kwargs/config, pass to Model()
- main.py: pass advanced settings from config to SpeechRecognitionManager
- settings_dialog.py: new Advanced tab with switches, spin buttons, entry
- tests: update test_main.py assertions, add whispercpp_advanced_defaults
  and whispercpp_advanced_persistence tests

Fixes #363

* fix(whispercpp): gracefully filter unsupported params for pywhispercpp 1.4.1

pywhispercpp 1.4.1 does not expose no_timestamps or suppress_non_speech_tokens
to Python even though they exist in the underlying whisper.cpp C struct.
This causes AttributeError on startup.

Add _load_model_with_compatible_params() which catches AttributeError,
identifies the unsupported param from the error message, removes it, and
retries until the model loads successfully or a non-AttributeError occurs.

Also refactor _build_whispercpp_model_kwargs() from _load_whispercpp_model()
for testability.

Fixes local startup crash reported during PR testing.

* fix(whispercpp): persist advanced settings correctly

* chore(config): add advanced power-user default

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

* feat(settings): gate advanced controls behind power-user opt-in

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

* fix(whispercpp): prefilter unsupported native params

* fix(settings): polish advanced whispercpp controls

* fix(installer): reuse existing whispercpp builds (#421)

* docs: clarify PyPI installation requirements (#423)

* chore(deps): bump next (#429)

Bumps the npm_and_yarn group with 1 update in the /web directory: [next](https://github.com/vercel/next.js).


Updates `next` from 15.5.15 to 15.5.18
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.15...v15.5.18)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.18
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(installer): repair pywhispercpp library loading (#433)

* fix: play stop sound immediately on release (#426)

* fix(recognition): play stop sound after audio thread joins to prevent capture on short recordings (#436)

Moves play_stop_sound() to after audio_thread.join() but before the
final buffer processing. This closes the race where the audio thread's
in-flight chunk could capture the stop cue for short (≤ ~192 ms) recordings,
while still feeling immediate compared to the original placement after all
recognition cleanup.

* fix(install): correct openSUSE Tumbleweed dependencies (#420)

* fix(install): correct openSUSE Tumbleweed dependencies

* style: format openSUSE installer changes

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>

* fix(installer): harden Debian compatibility layer (#437)

Add missing pywhispercpp build prerequisites (libssl-dev, autoconf,
automake, libtool, patchelf) to both Debian and Ubuntu apt package lists.
These are not pulled in transitively on clean Debian installs and cause
CMake bootstrap failures during source compilation.

Add runtime smoke test for pywhispercpp to verify_installation() so
"installs but does not run" failures surface at install time with
actionable diagnostic steps instead of silently at first launch.

Treat Debian as a first-class target — remove the "primarily designed for
Ubuntu" warning for Debian systems. Remain conservative for non-Debian,
non-Ubuntu distros.

Add DEBIAN_FRONTEND=noninteractive to all apt install commands to prevent
interactive debconf prompts from blocking unattended installs.

Improve ydotool failure messaging on Debian with compile-from-source
instructions since ydotool is not packaged in Debian standard repos.

Add Debian-specific troubleshooting section to DISTRO_COMPATIBILITY.md
covering build deps, ydotool compilation, scoped source builds, and
libwhisper.so resolution diagnostics.

References: https://medium.com/@cslev/talking-to-my-linux-box-without-talking-to-the-cloud-vocalinux-on-debian-without-the-tears-10bf053ea21b

* fix(install): refresh ldconfig after openSUSE typelib install, clarify python3XY placeholder convention (#438)

- Add 'sudo ldconfig' after zypper install in suse_install_appindicator_runtime()
  to ensure GI typelib cache is refreshed so newly installed typelib packages
  are immediately discoverable by GObject introspection
- Add section-level comment in distro-package-map.yaml explaining that
  python3XY is a placeholder convention resolved at install time by
  suse_python_package_prefix() / suse_python_package_candidates()

Follow-up polish from review of #420.

* fix(whispercpp): reduce CPU threads and ensure GPU backend builds in dev mode (#439)

* fix(whispercpp): reduce CPU threads and ensure GPU backend builds in dev mode

- Reduce default n_threads from cpu_count() (16) to max 8 (CPU) / max 4 (GPU)
- Add runtime pywhispercpp GPU capability detection (_detect_pywhispercpp_gpu_backend)
- Make n_threads configurable via whispercpp_n_threads config option
- Fix install.sh regression: GPU source build was skipped in --dev mode
- Extract GPU install logic into install_whispercpp_with_gpu_support()
- Add is_pywhispercpp_gpu_capable() to detect GPU libs in installed pywhispercpp
- Force rebuild pywhispercpp when GPU is detected but lacks GPU support

Fixes CPU spiking to 100% on GPU systems due to excessive thread count
and ensures dev installs get GPU acceleration.

* fix(audio): skip output-only HDMI devices when selecting input device

Add _resolve_valid_input_device() that filters devices by
maxInputChannels > 0, falling back preferred → default → first valid.

Previously, HDMI output devices (0 input channels) could be selected
as the default audio input, causing 'Invalid number of channels' errors.
Now both _record_audio() and _attempt_audio_reconnection() validate the
device has input channels before probing or opening the stream.

* fix(whispercpp): treat n_threads=0 as auto-detect, not literal zero

Config default whispercpp_n_threads=0 was being passed directly
to whisper.cpp as n_threads=0, causing 'cannot create std::vector
larger than max_size()' transcription errors. Now 0 and negative
values are treated as auto-detect, falling through to the smart
thread count logic (max 4 with GPU, max 8 on CPU).

* test: add whispercpp_n_threads to mock assertions and config defaults

Update test_main.py mock to include the new whispercpp_n_threads=0 kwarg
and test_config_manager.py to assert the default config value.

* fix(audio): harden device enumeration against test mocks

The new HDMI-filter helper and GPU-backend detector crashed unit tests
that pass MagicMock() for the PyAudio/pathlib surfaces:

- _find_pywhispercpp_shared_library_dirs hit TypeError when tests
  monkey-patch os.stat (Path.is_dir internally calls S_ISDIR on a
  non-int st_mode). Widen the catch to OSError/TypeError/ValueError so
  unconfigured stat results just skip the candidate dir.

- _resolve_valid_input_device crashed with 'MagicMock > int' on
  audio.get_device_count() / info.get('maxInputChannels'). Coerce the
  count, treat zero/unknown as 'use system default', and include
  non-dict info objects rather than filtering everything out.

No production behavior changes: real PyAudio always returns int counts
and dict device info, so the HDMI filter still works as designed.

* test(recognition): cover new HDMI filter and GPU backend detector

- TestResolveValidInputDevice: 9 cases for the HDMI-filter resolver
  (preferred passthrough, HDMI fallback, no-default fallback, no-input
  None return, and three mock-tolerance escape hatches).
- TestDetectPywhispercppGpuBackend: 4 cases for runtime GPU library
  detection (cpu/vulkan/cuda/empty dirs).
- TestFindPywhispercppSharedLibraryDirsResilience: proves the widened
  exception catch survives a TypeError from pathlib.Path.is_dir.

* test(text-injection): wait for IBus init thread to avoid race on slow runners

The IBus environment promotion runs in a background daemon thread spawned
by TextInjector.__init__. The two TestIBusRuntimeFallback cases asserted
on injector.environment immediately after construction, which raced the
thread on Python 3.10 GHA runners and produced intermittent failures.

Join the init thread (with a generous timeout) before asserting, so the
test exercises the post-warmup state deterministically.

* chore(release): prepare v0.11.0-beta (#440)

- Bump version from 0.10.2-beta to 0.11.0-beta
- Update all version references across codebase
- Add What's New section for v0.11.0-beta in README and docs/UPDATE.md
- Add v0.11.0-beta entry to website changelog
- Update SECURITY.md supported versions to 0.11.x
- Update website schema, badge, and package version
- Update RELEASE_PROCESS.md version history table

* fix(startup): allow launch without pynput backend (#448)

* feat(vad): add Silero VAD and drop silence-only buffers (#447)

* 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>

* fix(web): clarify speech demo browser support (#449)

* fix black format check

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: jay <sevenjay@users.noreply.github.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
Co-authored-by: Fakhareddine <fe.sabbari@claranet.com>
Co-authored-by: Sbenazar <127324689+Sbenazar@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: parko636 <alex@alexparkinson.net>
Co-authored-by: juanfradb <94815906+juanfradb@users.noreply.github.com>
Co-authored-by: Pep Locanto <peplocanto@gmail.com>
This commit is contained in:
sevenjay
2026-06-06 15:52:46 -07:00
committed by GitHub
co-authored by Sbenazar jay Jatin K Malik Fakhareddine Sisyphus dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> parko636 juanfradb Pep Locanto
parent b0572a36dc
commit 84d2e1a929
9 changed files with 1702 additions and 54 deletions
+3
View File
@@ -231,6 +231,9 @@ class TestMainModule(unittest.TestCase):
whispercpp_logprob_thold=-1.0,
whispercpp_no_speech_thold=0.6,
whispercpp_n_threads=0,
remote_api_url="",
remote_api_key="",
remote_api_endpoint="/inference",
)
mock_text.assert_called_once_with(wayland_mode=True)
mock_action_handler.assert_called_once_with(mock_text_instance)
+17
View File
@@ -89,6 +89,23 @@ class TestFilterNonSpeech(unittest.TestCase):
self.assertIn("are", result)
self.assertIn("you", result)
def test_trailing_newline_preserved(self):
# A trailing '\n' from the upstream API is meaningful (e.g. a
# post-processing proxy emitting Enter) and must survive filtering.
self.assertEqual(_filter_non_speech("cd ..\n"), "cd ..\n")
def test_trailing_newline_with_leading_whitespace(self):
# Leading whitespace is stripped; trailing '\n' is preserved.
self.assertEqual(_filter_non_speech(" hello world\n"), "hello world\n")
def test_trailing_spaces_stripped_newline_kept(self):
# Spaces/tabs/CR before a trailing '\n' should be cleaned, '\n' kept.
self.assertEqual(_filter_non_speech("cmd \t\n"), "cmd\n")
def test_lone_newline_filtered_as_whitespace(self):
# A bare '\n' (no speech) is whitespace-only -> filtered to "".
self.assertEqual(_filter_non_speech("\n"), "")
class TestShowNotification(unittest.TestCase):
"""Test _show_notification function."""
@@ -0,0 +1,697 @@
"""
Test coverage for remote API speech recognition engine.
These tests cover the remote API engine added in PR #335.
"""
import sys
import unittest
from unittest.mock import ANY, MagicMock, Mock
import pytest
# autouse fixture: inject mock modules before each test, restore sys.modules after test,
# prevent polluting other test files in the same pytest session.
@pytest.fixture(autouse=True)
def _mock_heavy_deps(monkeypatch):
"""Mock heavy dependencies via sys.modules, restoring after each test."""
for mod in [
"vosk",
"whisper",
"pyaudio",
"wave",
"tqdm",
"numpy",
"torch",
"psutil",
]:
monkeypatch.setitem(sys.modules, mod, MagicMock())
# pywhispercpp requires submodule structure
mock_pywhispercpp = MagicMock()
mock_pywhispercpp.model = MagicMock()
mock_pywhispercpp.model.Model = MagicMock()
monkeypatch.setitem(sys.modules, "pywhispercpp", mock_pywhispercpp)
monkeypatch.setitem(sys.modules, "pywhispercpp.model", mock_pywhispercpp.model)
# requests needs to keep real exception classes for except statements to work properly
mock_requests = MagicMock()
mock_requests.exceptions.ConnectionError = ConnectionError
mock_requests.exceptions.RequestException = Exception
monkeypatch.setitem(sys.modules, "requests", mock_requests)
yield
def _import_manager():
"""Delayed import of SpeechRecognitionManager to ensure fixture mocks take effect before execution."""
from vocalinux.speech_recognition.recognition_manager import (
SpeechRecognitionManager,
)
return SpeechRecognitionManager
def _get_mock_requests():
"""Get the mock requests object currently injected into sys.modules."""
return sys.modules["requests"]
def _get_mock_session():
"""Get the mock session instance returned by requests.Session()."""
return _get_mock_requests().Session.return_value
def _setup_requests_get_ok(status_code=200):
"""Configure mock session.get to return a successful response."""
mock_session = _get_mock_session()
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = status_code
mock_session.get.return_value = mock_response
return mock_session.get
def _setup_requests_get_error(exception):
"""Configure mock session.get to throw an exception."""
mock_session = _get_mock_session()
mock_session.get.side_effect = exception
return mock_session.get
def _setup_requests_get_non_ok(status_code=500):
"""Configure mock session.get to return a non-successful response."""
mock_session = _get_mock_session()
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = status_code
mock_session.get.return_value = mock_response
return mock_session.get
def _setup_requests_post_ok(json_data, status_code=200):
"""Configure mock session.post to return a successful response."""
mock_session = _get_mock_session()
mock_response = Mock()
mock_response.status_code = status_code
mock_response.json.return_value = json_data
mock_session.post.return_value = mock_response
return mock_session.post
def _setup_requests_post_error(exception):
"""Configure mock session.post to throw an exception."""
mock_session = _get_mock_session()
mock_session.post.side_effect = exception
return mock_session.post
def _setup_requests_post_status(status_code, raise_for_status_error=None):
"""Configure mock session.post to return a response with a specific status code."""
mock_session = _get_mock_session()
mock_response = Mock()
mock_response.status_code = status_code
if raise_for_status_error:
mock_response.raise_for_status.side_effect = raise_for_status_error
mock_session.post.return_value = mock_response
return mock_session.post
class TestRemoteAPIEngine(unittest.TestCase):
"""Test cases for remote API speech recognition engine."""
def test_remote_api_init_without_url(self):
"""Test remote API initialization without URL."""
SpeechRecognitionManager = _import_manager()
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="",
)
# Should not raise, just set _model_initialized to False
self.assertFalse(manager._model_initialized)
def test_remote_api_init_with_invalid_url(self):
"""Test remote API initialization with invalid URL format."""
SpeechRecognitionManager = _import_manager()
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="invalid-url",
)
# Should not raise, just set _model_initialized to False
self.assertFalse(manager._model_initialized)
def test_remote_api_init_with_valid_url(self):
"""Test remote API initialization with valid HTTP URL."""
SpeechRecognitionManager = _import_manager()
_setup_requests_get_ok()
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:9090",
)
# Should set _model_initialized to True
self.assertTrue(manager._model_initialized)
def test_remote_api_init_with_valid_https_url(self):
"""Test remote API initialization with HTTPS URL."""
SpeechRecognitionManager = _import_manager()
_setup_requests_get_ok()
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="https://192.168.1.100:9090",
)
self.assertTrue(manager._model_initialized)
def test_remote_api_init_strips_trailing_slash(self):
"""Test remote API initialization strips trailing slash from URL."""
SpeechRecognitionManager = _import_manager()
_setup_requests_get_ok()
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:9090/",
)
# URL should have trailing slash stripped
self.assertFalse(manager.remote_api_url.endswith("/"))
def test_remote_api_init_with_connection_failure(self):
"""Test remote API initialization handles connection failure gracefully."""
SpeechRecognitionManager = _import_manager()
_setup_requests_get_error(Exception("Connection refused"))
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:9090",
)
# Should still set _model_initialized to True (will retry later)
self.assertTrue(manager._model_initialized)
def test_remote_api_init_with_server_error(self):
"""Test remote API initialization handles non-OK server response."""
SpeechRecognitionManager = _import_manager()
_setup_requests_get_non_ok(status_code=500)
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:9090",
)
# Should still set _model_initialized to True (warns but continues)
self.assertTrue(manager._model_initialized)
def test_remote_api_init_with_api_key(self):
"""Test remote API initialization includes API key in headers."""
SpeechRecognitionManager = _import_manager()
mock_get = _setup_requests_get_ok()
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:9090",
remote_api_key="test-api-key",
)
self.assertTrue(manager._model_initialized)
self.assertEqual(manager.remote_api_key, "test-api-key")
# Verify the API key was sent in the request headers
mock_get.assert_called_once_with(
"http://localhost:9090",
headers={"Authorization": "Bearer test-api-key"},
timeout=5,
)
def test_remote_api_init_with_inference_endpoint(self):
"""Test remote API initialization with whisper.cpp server endpoint."""
SpeechRecognitionManager = _import_manager()
_setup_requests_get_ok()
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:9090",
remote_api_endpoint="/inference",
)
self.assertEqual(manager.remote_api_endpoint, "/inference")
def test_remote_api_init_with_openai_endpoint(self):
"""Test remote API initialization with OpenAI compatible endpoint."""
SpeechRecognitionManager = _import_manager()
_setup_requests_get_ok()
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:8080",
remote_api_endpoint="/v1/audio/transcriptions",
)
self.assertEqual(manager.remote_api_endpoint, "/v1/audio/transcriptions")
def test_model_ready_remote_api_initialized(self):
"""Test model_ready returns True for remote API when initialized."""
SpeechRecognitionManager = _import_manager()
_setup_requests_get_ok()
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:9090",
)
# Should be available after initialization
self.assertTrue(manager.model_ready)
def test_model_ready_remote_api_not_initialized(self):
"""Test model_ready returns False when remote API not initialized."""
SpeechRecognitionManager = _import_manager()
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="",
)
# Should not be available without URL
self.assertFalse(manager.model_ready)
class TestRemoteAPITranscription(unittest.TestCase):
"""Test cases for remote API transcription functionality."""
def setUp(self):
"""Set up test fixtures."""
SpeechRecognitionManager = _import_manager()
_setup_requests_get_ok()
self.manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:9090",
remote_api_key="test-key",
remote_api_endpoint="/inference",
)
def test_transcribe_with_empty_buffer(self):
"""Test transcription with empty audio buffer."""
result = self.manager._transcribe_with_remote_api([])
self.assertEqual(result, "")
def test_transcribe_with_no_url(self):
"""Test transcription when URL is not set."""
self.manager.remote_api_url = ""
result = self.manager._transcribe_with_remote_api([b"test"])
self.assertEqual(result, "")
def test_transcribe_with_openai_endpoint_success(self):
"""Test successful transcription via OpenAI endpoint."""
from unittest.mock import patch
with patch.object(self.manager, "_try_openai_api") as mock_openai:
mock_openai.return_value = "test transcription"
# Change endpoint to OpenAI
self.manager.remote_api_endpoint = "/v1/audio/transcriptions"
result = self.manager._transcribe_with_remote_api([b"test-audio-data"])
self.assertEqual(result, "test transcription")
def test_transcribe_with_whisper_cpp_server_endpoint_success(self):
"""Test successful transcription via whisper.cpp server endpoint."""
from unittest.mock import patch
with patch.object(self.manager, "_try_whispercpp_server_api") as mock_server:
mock_server.return_value = "hello world"
result = self.manager._transcribe_with_remote_api([b"audio-bytes"])
self.assertEqual(result, "hello world")
def test_transcribe_with_whispercpp_api_failing(self):
"""Test transcription returns empty string when whisper.cpp server API fails."""
from unittest.mock import patch
with patch.object(self.manager, "_try_whispercpp_server_api") as mock_server:
mock_server.return_value = None
result = self.manager._transcribe_with_remote_api([b"some-audio"])
# Should return empty string when API returns None
self.assertEqual(result, "")
mock_server.assert_called_once()
def test_transcribe_with_openai_api_failing(self):
"""Test transcription returns empty string when OpenAI API fails."""
from unittest.mock import patch
self.manager.remote_api_endpoint = "/v1/audio/transcriptions"
with patch.object(self.manager, "_try_openai_api") as mock_openai:
mock_openai.return_value = None
result = self.manager._transcribe_with_remote_api([b"some-audio"])
# Should return empty string when API returns None
self.assertEqual(result, "")
mock_openai.assert_called_once()
def test_transcribe_with_language_en_us_conversion(self):
"""Test language code conversion from en-us to en."""
from unittest.mock import patch
with patch.object(self.manager, "_try_whispercpp_server_api") as mock_server:
mock_server.return_value = "test"
self.manager.language = "en-us"
self.manager._transcribe_with_remote_api([b"audio"])
# Should have been called with "en" (converted from "en-us")
mock_server.assert_called_once_with(ANY, "en", ANY)
def test_transcribe_with_language_auto(self):
"""Test with auto language detection."""
from unittest.mock import patch
with patch.object(self.manager, "_try_whispercpp_server_api") as mock_server:
mock_server.return_value = "test"
self.manager.language = "auto"
self.manager._transcribe_with_remote_api([b"audio"])
# Should pass None for auto detection
mock_server.assert_called_once_with(ANY, None, ANY)
def test_transcribe_unexpected_exception_returns_empty(self):
"""An unexpected exception inside transcription is caught and returns ''."""
from unittest.mock import patch
with patch.object(self.manager, "_try_whispercpp_server_api") as mock_server:
mock_server.side_effect = RuntimeError("boom")
result = self.manager._transcribe_with_remote_api([b"audio"])
self.assertEqual(result, "")
class TestOpenAIAPIFormat(unittest.TestCase):
"""Test cases for OpenAI compatible API format."""
def setUp(self):
"""Set up test fixtures."""
SpeechRecognitionManager = _import_manager()
_setup_requests_get_ok()
self.manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:8080",
remote_api_key="my-api-key",
remote_api_endpoint="/v1/audio/transcriptions",
)
def test_try_openai_api_success(self):
"""Test successful transcription via OpenAI API format."""
_setup_requests_post_ok({"text": "transcribed text"})
result = self.manager._try_openai_api(b"wav-bytes", "en", {"Authorization": "Bearer key"})
self.assertEqual(result, "transcribed text")
def test_try_openai_api_with_404(self):
"""Test OpenAI API format returns None for 404."""
_setup_requests_post_status(404)
result = self.manager._try_openai_api(b"wav", "en", {})
self.assertIsNone(result)
def test_try_openai_api_connection_error(self):
"""Test OpenAI API handles connection errors gracefully."""
_setup_requests_post_error(Exception("Connection refused"))
result = self.manager._try_openai_api(b"wav", "en", {})
self.assertIsNone(result)
def test_try_openai_api_with_language(self):
"""Test OpenAI API includes language in request."""
mock_post = _setup_requests_post_ok({"text": "result"})
self.manager._try_openai_api(b"wav-bytes", "fr", {})
# Check that language was included in the data
mock_post.assert_called_once()
call_kwargs = mock_post.call_args[1]
self.assertEqual(call_kwargs["data"]["language"], "fr")
def test_try_openai_api_server_error(self):
"""Test OpenAI API handles 500 server error via raise_for_status."""
_setup_requests_post_status(500, Exception("500 Server Error"))
result = self.manager._try_openai_api(b"wav", "en", {})
self.assertIsNone(result)
def test_try_openai_api_auth_error(self):
"""Test OpenAI API handles 401 unauthorized via raise_for_status."""
_setup_requests_post_status(401, Exception("401 Unauthorized"))
result = self.manager._try_openai_api(b"wav", "en", {})
self.assertIsNone(result)
class TestWhisperCppServerAPIFormat(unittest.TestCase):
"""Test cases for whisper.cpp server API format."""
def setUp(self):
"""Set up test fixtures."""
SpeechRecognitionManager = _import_manager()
_setup_requests_get_ok()
self.manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:9090",
remote_api_key="server-key",
remote_api_endpoint="/inference",
)
def test_try_whispercpp_server_api_success(self):
"""Test successful transcription via whisper.cpp server API."""
_setup_requests_post_ok({"text": "spoken words"})
result = self.manager._try_whispercpp_server_api(
b"audio-wav", "de", {"Authorization": "Bearer key"}
)
self.assertEqual(result, "spoken words")
def test_try_whispercpp_server_api_404(self):
"""Test whisper.cpp server returns None for 404."""
_setup_requests_post_status(404)
result = self.manager._try_whispercpp_server_api(b"wav", "es", {})
self.assertIsNone(result)
def test_try_whispercpp_server_api_connection_error(self):
"""Test whisper.cpp server handles connection errors."""
_setup_requests_post_error(Exception("Connection refused"))
result = self.manager._try_whispercpp_server_api(b"wav", "es", {})
self.assertIsNone(result)
def test_try_whispercpp_server_api_server_error(self):
"""Test whisper.cpp server handles 500 error via raise_for_status."""
_setup_requests_post_status(500, Exception("500 Server Error"))
result = self.manager._try_whispercpp_server_api(b"wav", "en", {})
self.assertIsNone(result)
def test_try_whispercpp_server_api_auth_error(self):
"""Test whisper.cpp server handles 401 unauthorized via raise_for_status."""
_setup_requests_post_status(401, Exception("401 Unauthorized"))
result = self.manager._try_whispercpp_server_api(b"wav", "en", {})
self.assertIsNone(result)
class TestRemoteAPIReconfiguration(unittest.TestCase):
"""Test cases for remote API settings updates."""
def setUp(self):
"""Set up test fixtures."""
SpeechRecognitionManager = _import_manager()
_setup_requests_get_ok()
self.manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:9090",
)
def test_update_remote_api_url(self):
"""Test updating remote API URL via reconfigure triggers reinit."""
_setup_requests_get_ok()
self.manager.reconfigure(remote_api_url="https://new-server:9090")
self.assertEqual(self.manager.remote_api_url, "https://new-server:9090")
# Verify successful reinitialization after reconfigure
self.assertTrue(self.manager._model_initialized)
self.assertTrue(self.manager.model_ready)
def test_update_remote_api_key(self):
"""Test updating remote API key via reconfigure."""
self.manager.reconfigure(remote_api_key="new-secret-key")
self.assertEqual(self.manager.remote_api_key, "new-secret-key")
def test_update_remote_api_endpoint(self):
"""Test updating remote API endpoint via reconfigure."""
self.manager.reconfigure(remote_api_endpoint="/v1/audio/transcriptions")
self.assertEqual(self.manager.remote_api_endpoint, "/v1/audio/transcriptions")
class TestRemoteAPIReinitializeAfterResume(unittest.TestCase):
"""Test cases for reinitialize_after_resume with remote_api engine."""
def test_reinitialize_after_resume_calls_init_remote_api(self):
"""Test that reinitialize_after_resume dispatches to _init_remote_api."""
from unittest.mock import patch
SpeechRecognitionManager = _import_manager()
_setup_requests_get_ok()
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:9090",
)
with patch.object(manager, "_init_remote_api") as mock_init:
manager.reinitialize_after_resume()
mock_init.assert_called_once()
def test_reinitialize_after_resume_without_url_does_not_raise(self):
"""Test that reinitialize_after_resume with empty URL sets _model_initialized False."""
SpeechRecognitionManager = _import_manager()
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="",
)
# Should not raise any exception
manager.reinitialize_after_resume()
self.assertFalse(manager._model_initialized)
def test_reinitialize_after_resume_unknown_engine_returns_silently(self):
"""Unknown engine value short-circuits reinit without raising or dispatching."""
from unittest.mock import patch
SpeechRecognitionManager = _import_manager()
_setup_requests_get_ok()
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:9090",
)
manager.engine = "bogus_engine"
with (
patch.object(manager, "_init_remote_api") as mock_remote,
patch.object(manager, "_init_vosk") as mock_vosk,
patch.object(manager, "_init_whisper") as mock_whisper,
patch.object(manager, "_init_whispercpp") as mock_whispercpp,
):
manager.reinitialize_after_resume()
mock_remote.assert_not_called()
mock_vosk.assert_not_called()
mock_whisper.assert_not_called()
mock_whispercpp.assert_not_called()
self.assertFalse(manager._model_initialized)
class TestHTTPSession(unittest.TestCase):
"""Test cases for manager-owned requests.Session (connection pooling)."""
def test_init_remote_api_creates_session(self):
"""_init_remote_api creates a requests.Session and stores it on _http_session."""
SpeechRecognitionManager = _import_manager()
_setup_requests_get_ok()
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:9090",
)
mock_requests = _get_mock_requests()
mock_requests.Session.assert_called()
self.assertIs(manager._http_session, mock_requests.Session.return_value)
def test_transcribe_uses_session_post(self):
"""_try_whispercpp_server_api calls self._http_session.post, not module-level requests.post."""
SpeechRecognitionManager = _import_manager()
_setup_requests_get_ok()
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:9090",
remote_api_endpoint="/inference",
)
mock_session = _get_mock_session()
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"text": "hello"}
mock_session.post.return_value = mock_response
result = manager._try_whispercpp_server_api(b"wav-bytes", "en", {})
self.assertEqual(result, "hello")
mock_session.post.assert_called_once()
# Module-level requests.post must NOT have been called
self.assertFalse(_get_mock_requests().post.called)
def test_reconfigure_away_from_remote_api_closes_session(self):
"""reconfigure(engine='vosk') from remote_api calls session.close() and nulls _http_session."""
from unittest.mock import patch
SpeechRecognitionManager = _import_manager()
_setup_requests_get_ok()
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:9090",
)
session = manager._http_session
with patch.object(manager, "_init_vosk"):
manager.reconfigure(engine="vosk")
session.close.assert_called_once()
self.assertIsNone(manager._http_session)
def test_reinit_closes_previous_session_before_creating_new(self):
"""A second _init_remote_api call closes the previous session before creating a new one."""
from unittest.mock import MagicMock
SpeechRecognitionManager = _import_manager()
_setup_requests_get_ok()
# Give Session() distinct return values for each call so we can track them
mock_requests = _get_mock_requests()
first_session = MagicMock(name="session1")
second_session = MagicMock(name="session2")
first_session.get.return_value = Mock(ok=True, status_code=200)
second_session.get.return_value = Mock(ok=True, status_code=200)
mock_requests.Session.side_effect = [first_session, second_session]
manager = SpeechRecognitionManager(
engine="remote_api",
remote_api_url="http://localhost:9090",
)
self.assertIs(manager._http_session, first_session)
# Call _init_remote_api a second time (simulates back-to-back inits)
manager._init_remote_api()
first_session.close.assert_called_once()
self.assertIs(manager._http_session, second_session)
if __name__ == "__main__":
unittest.main()