* feat(asr): add Parakeet engine via sherpa-onnx Adds a "parakeet" engine backed by sherpa-onnx, running NVIDIA Parakeet TDT 0.6B int8 models on CPU: v3 (25 European languages, auto-detected) and v2 (English). Output is punctuated and capitalized. On an Intel Core Ultra 7 155H it decodes at about 12x realtime on CPU (RTF 0.08), roughly 8x faster than whisper.cpp large-v3-turbo over the same clips with its Vulkan GPU backend (RTF 0.59). Being a transducer it also has no 30s window to pad, so a short push-to-talk utterance returns in ~0.3s instead of ~2.4s. Follows the existing engine layout: utils/parakeet_model_info.py holds the catalog and the get_model_path / is_model_downloaded / list_downloaded_models / delete_model helpers, while _init_parakeet / _transcribe_with_parakeet / _download_parakeet_model follow the same shape as their whisper.cpp counterparts and reuse _stream_model_download for progress and cancellation. Registered in the engine dispatch, the settings engine/model/language pickers, the model info card, the download prompts, the unused-downloads list and install.sh. Models download to <models_dir>/parakeet/<model>/ via temp files renamed on success; is_model_downloaded requires all four files, so an interrupted download is retried rather than loaded. Requires the new "parakeet" extra (sherpa-onnx). * fix(install): write only the engine key for parakeet The parakeet branch duplicated a full default config.json. ConfigManager deep-merges DEFAULT_CONFIG on load, so every other key was redundant: a config holding only "engine": "parakeet" still resolves parakeet_model_size to v3-european along with the VAD, shortcut and text-injection defaults. * fix(install): keep push-to-talk defaults and switch existing configs Writing only the engine key left config.json without a shortcuts section, which _migrate_shortcuts_config reads as a pre-push-to-talk upgrade and pins back to ctrl+ctrl/toggle - contradicting the installer's own "Hold Right Alt while you speak" message. Seed that section too. Point the config at parakeet with set_configured_engine rather than only on first create, so --engine=parakeet also switches a machine that already has a config.json, and give print_welcome_message a parakeet arm so the summary is not blank. The interactive picker overwrites SELECTED_ENGINE, so document the flag combination that reaches this branch. * fix(asr): pin Parakeet bundles and report one progress bar Pin each bundle to a Hugging Face commit instead of resolve/main, so a file replaced upstream cannot change what an install downloads. This is the same approach whispercpp_model_info takes with its pinned revision. Remap each file's progress into its slice of the bundle. The four files were reported through the single-file downloader independently, so the fraction ran to 1.0 and restarted four times; it now advances once and names the file being fetched. * fix(asr): reject a truncated Parakeet download _stream_model_download only rejects an empty body, so a truncated but nonempty response was renamed into the model directory and then reported as installed, because is_model_downloaded checks existence. Record the byte size of each file at the pinned revision and check it before the rename, the same order whisper.cpp verifies its digest in. A short file is a truncated transfer rather than an upstream change, since the revision is pinned, so it raises and the temp file is removed. * style(asr): annotate the Parakeet model catalog mypy inferred the catalog values as object, so reading file_sizes out of an entry raised attr-defined and no-any-return under `just typecheck`. * fix(asr): pin Parakeet digests in model_checksums.txt AGENTS.md requires every speech model to be verified against a pinned digest before install, failing closed on an unpinned one. Parakeet was checking only the byte size. Teach scripts/generate-model-checksums.py to emit Parakeet entries: the ONNX files are LFS so Hugging Face publishes their sha256, and tokens.txt is hashed from the downloaded bytes the way VOSK archives are. Several bundles ship the same file names, so a key carries the model name. _download_parakeet_model now calls verify_model_file before the rename and catches ChecksumError alongside the other download failures, matching the whisper.cpp downloader. * fix(asr): verify on-disk Parakeet models before load Existing bundle files were trusted by path alone, so a leftover or copied-in tree could reach sherpa-onnx unchecked. Hash each file against its pin on download skip and on init, and cover the Parakeet pins in CI. * fix(ui): hide Parakeet language picker that ignores the setting Parakeet language coverage is the model choice, not a catalog language. Hide the picker and persist auto so Settings does not write a lying value. * fix(asr): normalize Parakeet language to auto at startup CLI --language and stale saved languages still reached SpeechRecognitionManager even though Parakeet ignores catalog language. Force auto on the shared helper used by main and the manager so stored settings stay honest. * fix(ui): restore language after leaving Parakeet Remember the prior Whisper/cpp language across a Parakeet visit so switching back does not leave recognition stuck on auto. Keep Parakeet forced to auto and the picker hidden. Also satisfy black on the startup normalizer path. * fix(ui): keep language memory across Vosk fallback Vosk's unsupported-language en-us coercion was writing into _last_non_parakeet_language, so Whisper→Parakeet→Vosk→Whisper restored en-us instead of the user's Greek (or other) preference. Remember the pre-coercion catalog language and prefer it when leaving Vosk. * fix(ui): keep language memory when entering Parakeet from Vosk Vosk's coerced en-us fallback was still written into _last_non_parakeet_language on Parakeet entry, so Vosk→Parakeet→Whisper restored en-us instead of the pre-coercion preference (Greek or auto). Share one coerced-fallback predicate across entry, leave-Vosk, and language sync, and cover those paths with regression tests. * fix(tests): set initializing flags false on language-change stubs Plain Mock attributes are truthy and were short-circuiting the handler. --------- Co-authored-by: Alex Thunder <AlexThunder@gmail.com>
1024 lines
42 KiB
Python
1024 lines
42 KiB
Python
"""
|
|
Coverage boost tests targeting major gaps in recognition_manager and ibus_engine.
|
|
|
|
Key focus areas:
|
|
- Model download methods with progress tracking
|
|
- Audio reconnection logic
|
|
- IBus engine utility functions
|
|
"""
|
|
|
|
import base64
|
|
import os
|
|
import sys
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
# Mock GI imports before importing any vocalinux modules that use gi.
|
|
# On CI, real gi/IBus packages are installed; without mocks, importing
|
|
# ibus_engine would connect to a real IBus daemon and hang.
|
|
if "gi" not in sys.modules:
|
|
sys.modules["gi"] = MagicMock()
|
|
if "gi.repository" not in sys.modules:
|
|
sys.modules["gi.repository"] = MagicMock()
|
|
|
|
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
|
from vocalinux.utils.model_checksums import (
|
|
VERIFICATION_STAMP_NAME,
|
|
ChecksumError,
|
|
expected_for,
|
|
)
|
|
from vocalinux.utils.model_checksums import verify_model_file as verify_model_file_real
|
|
from vocalinux.utils.parakeet_model_info import MODEL_FILES as PARAKEET_MODEL_FILES
|
|
from vocalinux.utils.parakeet_model_info import manifest_key as parakeet_manifest_key
|
|
|
|
|
|
def _make_manager(engine="whisper_cpp", **kw):
|
|
"""Create a SpeechRecognitionManager with mocked initialization."""
|
|
with patch.object(SpeechRecognitionManager, "_init_vosk"):
|
|
with patch.object(SpeechRecognitionManager, "_init_whisper"):
|
|
with patch.object(SpeechRecognitionManager, "_init_whispercpp"):
|
|
with patch.object(SpeechRecognitionManager, "_init_parakeet"):
|
|
mgr = SpeechRecognitionManager(
|
|
engine=engine,
|
|
model_size="small",
|
|
language="en-us",
|
|
defer_download=True,
|
|
**kw,
|
|
)
|
|
# Ensure vosk_model_map is set (normally done in _init_vosk)
|
|
if not hasattr(mgr, "vosk_model_map"):
|
|
# The names _init_vosk() would pick for en-us. They have to be
|
|
# real: the download path looks each one up in the checksum
|
|
# manifest to stamp the tree it extracts.
|
|
mgr.vosk_model_map = {
|
|
"small": "vosk-model-small-en-us-0.15",
|
|
"medium": "vosk-model-en-us-0.22",
|
|
"large": "vosk-model-en-us-0.22",
|
|
}
|
|
return mgr
|
|
|
|
|
|
# A real zip, embedded rather than built here: by the time this module is
|
|
# imported, other test modules have already replaced zipfile/BytesIO in
|
|
# sys.modules with mocks, so building one here yields empty bytes. Its single
|
|
# entry sits under the directory VOSK's small en-us archive unpacks to, because
|
|
# that is the tree the downloader stamps after extracting.
|
|
VOSK_ZIP_BYTES = base64.b64decode(
|
|
"UEsDBBQAAAAIAHV2F12oDuXYFgAAAIgTAAAoAAAAdm9zay1tb2RlbC1zbWFsbC1lbi11cy0wLjE1L2FtL2Zp"
|
|
"bmFsLm1kbO3BMQEAAADCoPVPbQo/oAAAAACAtwFQSwECFAMUAAAACAB1dhddqA7l2BYAAACIEwAAKAAAAAAA"
|
|
"AAAAAAAAgAEAAAAAdm9zay1tb2RlbC1zbWFsbC1lbi11cy0wLjE1L2FtL2ZpbmFsLm1kbFBLBQYAAAAAAQAB"
|
|
"AFYAAABcAAAAAAA="
|
|
)
|
|
|
|
|
|
MODEL_NAME = "vosk-model-small-en-us-0.15"
|
|
|
|
# Same shape, but its top-level directory is not the model name: the downloader
|
|
# has to notice rather than report a success that installed nothing.
|
|
WRONG_LAYOUT_ZIP_BYTES = base64.b64decode(
|
|
"UEsDBBQAAAAIAHeGF11E9MkCEQAAANAHAAAcAAAAc29tZS1vdGhlci1uYW1lL2FtL2ZpbmFsLm1kbGNgGAWj"
|
|
"YBSMglEwCkbBUAcAUEsBAhQDFAAAAAgAd4YXXUT0yQIRAAAA0AcAABwAAAAAAAAAAAAAAIABAAAAAHNvbWUt"
|
|
"b3RoZXItbmFtZS9hbS9maW5hbC5tZGxQSwUGAAAAAAEAAQBKAAAASwAAAAAA"
|
|
)
|
|
|
|
|
|
class FakeRequestError(Exception):
|
|
"""Stands in for requests.exceptions.RequestException.
|
|
|
|
A leaked mock makes ``except requests.exceptions.RequestException`` a
|
|
TypeError, so the mocked module needs a real class here. It must not be
|
|
``Exception`` itself, or that clause swallows every other failure the
|
|
downloader is supposed to surface unwrapped.
|
|
"""
|
|
|
|
|
|
def _fake_clock(step=0.2):
|
|
"""Monotonic stand-in for time.time().
|
|
|
|
A fixed side_effect list runs out unpredictably: time.time is patched
|
|
globally, so logging consumes ticks too.
|
|
"""
|
|
from itertools import count
|
|
|
|
counter = count()
|
|
return lambda: next(counter) * step
|
|
|
|
|
|
@pytest.fixture
|
|
def skip_checksum():
|
|
"""Accept the synthetic payloads these tests stream.
|
|
|
|
Downloads are verified against the digests pinned in model_checksums.txt, so
|
|
a few hundred bytes of ``b"x"`` are correctly rejected. These tests cover
|
|
download *mechanics* (progress, content-length, URL shaping); integrity
|
|
itself is covered by tests/test_model_checksums.py. Yielding the mock lets
|
|
each test still assert that verification was reached.
|
|
"""
|
|
with patch("vocalinux.speech_recognition.recognition_manager.verify_model_file") as mock_verify:
|
|
yield mock_verify
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def cleanup_sys_modules():
|
|
"""Cleanup sys.modules after each test - full snapshot/restore."""
|
|
# Take a complete snapshot of sys.modules before the test
|
|
saved_modules = dict(sys.modules)
|
|
|
|
yield
|
|
|
|
# Restore sys.modules to exact pre-test state
|
|
added_keys = set(sys.modules.keys()) - set(saved_modules.keys())
|
|
for key in added_keys:
|
|
del sys.modules[key]
|
|
for key, value in saved_modules.items():
|
|
if key not in sys.modules or sys.modules[key] is not value:
|
|
sys.modules[key] = value
|
|
|
|
|
|
class TestDownloadWhispercppModel:
|
|
"""Test _download_whispercpp_model() with runtime import mocking."""
|
|
|
|
def test_download_whispercpp_success_basic(self, tmp_path, skip_checksum):
|
|
"""Test successful whisper.cpp model download."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
model_file = str(tmp_path / "ggml-small.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {"content-length": "1000"}
|
|
mock_response.iter_content.return_value = [b"x" * 500, b"y" * 500]
|
|
mock_requests.get.return_value = mock_response
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=model_file,
|
|
):
|
|
manager._download_whispercpp_model()
|
|
|
|
assert os.path.exists(model_file)
|
|
assert os.path.getsize(model_file) == 1000
|
|
# The model is only installed after it is verified.
|
|
skip_checksum.assert_called_once()
|
|
|
|
def test_download_whispercpp_progress_callback(self, tmp_path, skip_checksum):
|
|
"""Test progress callback is invoked during download."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
progress_calls = []
|
|
|
|
def track_progress(progress, speed, status):
|
|
progress_calls.append((progress, speed, status))
|
|
|
|
manager._download_progress_callback = track_progress
|
|
model_file = str(tmp_path / "ggml-small.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {"content-length": "1000"}
|
|
mock_response.iter_content.return_value = [b"x" * 500, b"y" * 500]
|
|
mock_requests.get.return_value = mock_response
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=model_file,
|
|
):
|
|
with patch("time.time", side_effect=_fake_clock()):
|
|
manager._download_whispercpp_model()
|
|
|
|
mock_requests.get.assert_called_once()
|
|
call_args = mock_requests.get.call_args
|
|
assert call_args is not None
|
|
assert len(call_args[0]) > 0 or "url" in call_args[1]
|
|
assert len(progress_calls) >= 1
|
|
|
|
def test_download_whispercpp_no_content_length(self, tmp_path, skip_checksum):
|
|
"""Test download when content-length header is missing."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
model_file = str(tmp_path / "ggml-small.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {} # No content-length
|
|
mock_response.iter_content.return_value = [b"x" * 500, b"y" * 500]
|
|
mock_requests.get.return_value = mock_response
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=model_file,
|
|
):
|
|
manager._download_whispercpp_model()
|
|
|
|
assert os.path.exists(model_file)
|
|
|
|
def test_download_whispercpp_request_error(self, tmp_path):
|
|
"""Test download request error handling."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
model_file = str(tmp_path / "ggml-small.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_error = Exception("Network error")
|
|
mock_requests.get.side_effect = mock_error
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=model_file,
|
|
):
|
|
with pytest.raises(RuntimeError, match="Failed to download"):
|
|
manager._download_whispercpp_model()
|
|
|
|
def test_download_whispercpp_appends_download_true(self, tmp_path, skip_checksum):
|
|
"""Hugging Face URLs get ?download=true for reliable binary responses."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
model_file = str(tmp_path / "ggml-small.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {"content-length": "4", "content-type": "application/octet-stream"}
|
|
mock_response.iter_content.return_value = [b"data"]
|
|
mock_requests.get.return_value = mock_response
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=model_file,
|
|
):
|
|
manager._download_whispercpp_model()
|
|
|
|
called_url = mock_requests.get.call_args[0][0]
|
|
assert "huggingface.co" in called_url
|
|
assert "download=true" in called_url
|
|
assert mock_requests.get.call_args[1].get("timeout") == manager._MODEL_DOWNLOAD_TIMEOUT
|
|
|
|
def test_download_whispercpp_timeout_message(self, tmp_path):
|
|
"""Timeout-like errors surface a dedicated user-facing message."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
model_file = str(tmp_path / "ggml-small.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_requests.get.side_effect = Exception("Read timeout")
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=model_file,
|
|
):
|
|
with pytest.raises(RuntimeError, match="timed out"):
|
|
manager._download_whispercpp_model()
|
|
|
|
def test_stream_model_download_rejects_html(self, tmp_path):
|
|
"""HTML error pages must not be written as model binaries."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
dest = str(tmp_path / "bad.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {"content-type": "text/html; charset=utf-8"}
|
|
mock_response.status_code = 200
|
|
mock_requests.get.return_value = mock_response
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with pytest.raises(RuntimeError, match="HTML"):
|
|
manager._stream_model_download("https://example.com/model.bin", dest)
|
|
|
|
assert not os.path.exists(dest)
|
|
|
|
def test_stream_model_download_empty_body(self, tmp_path):
|
|
"""Zero-byte downloads are treated as failure and cleaned up."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
dest = str(tmp_path / "empty.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {"content-length": "0", "content-type": "application/octet-stream"}
|
|
mock_response.iter_content.return_value = [b"", b""]
|
|
mock_requests.get.return_value = mock_response
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with pytest.raises(RuntimeError, match="0 bytes"):
|
|
manager._stream_model_download("https://example.com/model.bin", dest)
|
|
|
|
assert not os.path.exists(dest)
|
|
|
|
def test_stream_model_download_cancelled(self, tmp_path):
|
|
"""User cancel mid-stream removes the partial file."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager._download_cancelled = True
|
|
dest = str(tmp_path / "partial.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {
|
|
"content-length": "100",
|
|
"content-type": "application/octet-stream",
|
|
}
|
|
mock_response.iter_content.return_value = [b"chunk"]
|
|
mock_requests.get.return_value = mock_response
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with pytest.raises(RuntimeError, match="cancelled"):
|
|
manager._stream_model_download("https://example.com/model.bin", dest)
|
|
|
|
assert not os.path.exists(dest)
|
|
|
|
def test_stream_model_download_eta_minutes_and_empty_chunks(self, tmp_path):
|
|
"""Progress path covers multi-minute ETA and skips empty chunks."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
progress_calls = []
|
|
manager._download_progress_callback = lambda progress, speed, status: progress_calls.append(
|
|
status
|
|
)
|
|
dest = str(tmp_path / "big.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
# Large total so remaining/speed yields ETA >= 60s
|
|
mock_response.headers = {
|
|
"content-length": str(100 * 1024 * 1024),
|
|
"content-type": "application/octet-stream",
|
|
}
|
|
mock_response.iter_content.return_value = [b"", b"x" * 1024]
|
|
mock_requests.get.return_value = mock_response
|
|
|
|
# start=0, then 0.2 for progress update (elapsed > 0, small speed)
|
|
times = [0.0, 0.2, 0.2]
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch("time.time", side_effect=lambda: times.pop(0) if times else 1.0):
|
|
manager._stream_model_download("https://example.com/model.bin", dest)
|
|
|
|
assert os.path.exists(dest)
|
|
assert os.path.getsize(dest) == 1024
|
|
assert any("m " in s or "ETA" in s for s in progress_calls)
|
|
|
|
|
|
class TestDownloadWhisperModel:
|
|
"""OpenAI Whisper downloads run through the shared streaming helper.
|
|
|
|
Vocalinux fetches the checkpoint only to give the UI progress and a working
|
|
cancel; whisper.load_model() is what verifies it. Both were briefly lost when
|
|
the downloader was removed altogether, so they are pinned here.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _fake_stream(url, dest_path):
|
|
with open(dest_path, "wb") as handle:
|
|
handle.write(b"checkpoint")
|
|
|
|
def test_writes_the_name_whisper_expects(self, tmp_path):
|
|
manager = _make_manager(engine="whisper")
|
|
manager.model_size = "large"
|
|
|
|
with patch.object(manager, "_stream_model_download", side_effect=self._fake_stream):
|
|
manager._download_whisper_model(str(tmp_path))
|
|
|
|
# large is stored as large-v3.pt; "large.pt" here would mean load_model
|
|
# downloads the 2.9GB checkpoint a second time.
|
|
assert (tmp_path / "large-v3.pt").exists()
|
|
assert not (tmp_path / "large.pt").exists()
|
|
|
|
def test_reports_progress(self, tmp_path):
|
|
manager = _make_manager(engine="whisper")
|
|
progress_calls = []
|
|
manager._download_progress_callback = lambda f, s, st: progress_calls.append(st)
|
|
|
|
with patch.object(manager, "_stream_model_download", side_effect=self._fake_stream):
|
|
manager._download_whisper_model(str(tmp_path))
|
|
|
|
assert progress_calls, "the download dialog would sit at zero"
|
|
|
|
def test_cancel_propagates_and_cleans_up(self, tmp_path):
|
|
"""Cancel raised by the stream helper must reach the caller."""
|
|
manager = _make_manager(engine="whisper")
|
|
|
|
def cancel(url, dest_path):
|
|
with open(dest_path, "wb") as handle:
|
|
handle.write(b"partial")
|
|
raise RuntimeError("Download cancelled")
|
|
|
|
# A leaked requests mock from another module makes
|
|
# `except requests.exceptions.RequestException` a TypeError, so pin a
|
|
# real exception class here as the other download tests do.
|
|
mock_requests = MagicMock()
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch.object(manager, "_stream_model_download", side_effect=cancel):
|
|
with pytest.raises(RuntimeError, match="cancelled"):
|
|
manager._download_whisper_model(str(tmp_path))
|
|
|
|
assert not list(tmp_path.glob("*"))
|
|
|
|
|
|
class TestDownloadVoskModel:
|
|
"""Test _download_vosk_model() with runtime import mocking."""
|
|
|
|
@staticmethod
|
|
def _serve_zip(manager, tmp_path, payload=None):
|
|
"""Run a full download+extract of an archive into tmp_path."""
|
|
payload = VOSK_ZIP_BYTES if payload is None else payload
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {"content-length": str(len(payload))}
|
|
mock_response.iter_content.return_value = [payload]
|
|
mock_requests.get.return_value = mock_response
|
|
mock_requests.exceptions.RequestException = FakeRequestError
|
|
|
|
# Extraction has to really happen here — the stamp lands in the tree it
|
|
# creates — and other modules leave a MagicMock in sys.modules["zipfile"].
|
|
real_zipfile = getattr(sys, "_vocalinux_real_zipfile", None) or __import__("zipfile")
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests, "zipfile": real_zipfile}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.MODELS_DIR", str(tmp_path)
|
|
):
|
|
with patch("time.time", side_effect=_fake_clock()):
|
|
manager._download_vosk_model()
|
|
return mock_requests
|
|
|
|
def test_download_vosk_progress_callback(self, tmp_path, skip_checksum):
|
|
"""Test progress callback during Vosk download."""
|
|
manager = _make_manager(engine="vosk")
|
|
progress_calls = []
|
|
|
|
def track_progress(progress, speed, status):
|
|
progress_calls.append((progress, speed, status))
|
|
|
|
manager._download_progress_callback = track_progress
|
|
|
|
mock_requests = self._serve_zip(manager, tmp_path)
|
|
|
|
mock_requests.get.assert_called_once()
|
|
call_args = mock_requests.get.call_args
|
|
assert call_args is not None
|
|
assert len(call_args[0]) > 0 or "url" in call_args[1]
|
|
assert len(progress_calls) >= 1
|
|
# Otherwise the digest check could be deleted and this stay green.
|
|
skip_checksum.assert_called_once()
|
|
|
|
def test_download_vosk_stamps_the_extracted_tree(self, tmp_path, skip_checksum):
|
|
"""A directory has no digest; install.sh reads this stamp instead.
|
|
|
|
Leave it out and every model fetched at first run or from Settings looks
|
|
unverified to the next ./install.sh, which downloads it again.
|
|
"""
|
|
manager = _make_manager(engine="vosk")
|
|
|
|
self._serve_zip(manager, tmp_path)
|
|
|
|
stamp = tmp_path / "vosk-model-small-en-us-0.15" / VERIFICATION_STAMP_NAME
|
|
pinned = expected_for("vosk-model-small-en-us-0.15.zip")
|
|
assert pinned is not None, "the fixture model must be in the manifest"
|
|
assert stamp.read_text().strip() == pinned.digest
|
|
|
|
@staticmethod
|
|
def _leftovers(tmp_path):
|
|
"""Everything in MODELS_DIR that is not the finished model tree."""
|
|
return sorted(p.name for p in tmp_path.iterdir() if p.name != MODEL_NAME)
|
|
|
|
def test_download_vosk_fails_when_the_stamp_cannot_be_written(self, tmp_path, skip_checksum):
|
|
"""An unstampable tree is one we would silently refetch; say so instead."""
|
|
manager = _make_manager(engine="vosk")
|
|
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.write_verification_stamp",
|
|
side_effect=OSError("read-only models directory"),
|
|
):
|
|
with pytest.raises(OSError):
|
|
self._serve_zip(manager, tmp_path)
|
|
|
|
# And it must not leave the tree behind: _init_vosk would call that
|
|
# installed while install.sh, finding no stamp, refetched it every run.
|
|
assert not (tmp_path / MODEL_NAME).exists()
|
|
assert self._leftovers(tmp_path) == []
|
|
|
|
def test_a_failed_unpack_keeps_the_model_that_was_already_there(self, tmp_path, skip_checksum):
|
|
"""Replacing is a swap: a broken download must not cost the old model."""
|
|
old = tmp_path / MODEL_NAME / "am"
|
|
old.mkdir(parents=True)
|
|
(old / "final.mdl").write_bytes(b"the model that was already installed")
|
|
|
|
manager = _make_manager(engine="vosk")
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.write_verification_stamp",
|
|
side_effect=OSError("read-only models directory"),
|
|
):
|
|
with pytest.raises(OSError):
|
|
self._serve_zip(manager, tmp_path)
|
|
|
|
assert (old / "final.mdl").read_bytes() == b"the model that was already installed"
|
|
assert self._leftovers(tmp_path) == []
|
|
|
|
def test_an_archive_with_the_wrong_layout_is_refused(self, tmp_path, skip_checksum):
|
|
"""A zip that unpacks somewhere else must not look like a success."""
|
|
manager = _make_manager(engine="vosk")
|
|
|
|
with pytest.raises(RuntimeError, match="does not contain"):
|
|
self._serve_zip(manager, tmp_path, payload=WRONG_LAYOUT_ZIP_BYTES)
|
|
|
|
assert not (tmp_path / MODEL_NAME).exists()
|
|
assert self._leftovers(tmp_path) == []
|
|
|
|
def test_a_verified_download_replaces_an_older_tree(self, tmp_path, skip_checksum):
|
|
old = tmp_path / MODEL_NAME
|
|
(old / "am").mkdir(parents=True)
|
|
(old / "am" / "final.mdl").write_bytes(b"stale")
|
|
(old / "stale-file").write_text("must not survive the swap")
|
|
|
|
manager = _make_manager(engine="vosk")
|
|
self._serve_zip(manager, tmp_path)
|
|
|
|
assert (old / "am" / "final.mdl").read_bytes() != b"stale"
|
|
assert not (old / "stale-file").exists(), "the swap must replace, not merge"
|
|
assert (old / VERIFICATION_STAMP_NAME).exists()
|
|
assert self._leftovers(tmp_path) == []
|
|
|
|
def test_download_vosk_request_error(self, tmp_path):
|
|
"""Test Vosk download request error handling."""
|
|
manager = _make_manager(engine="vosk")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_error = Exception("Network error")
|
|
mock_requests.get.side_effect = mock_error
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.MODELS_DIR", str(tmp_path)
|
|
):
|
|
with pytest.raises(RuntimeError, match="Failed to download"):
|
|
manager._download_vosk_model()
|
|
|
|
|
|
class TestWhispercppRejectsAnUnverifiedModelOnDisk:
|
|
"""Wiring test: the hash has to happen where the model is picked up.
|
|
|
|
_download_whispercpp_model verifies before its rename, so the exposure is a
|
|
file that never came through it: releases before verification fetched ggml
|
|
models from `resolve/main` unchecked, and install.sh re-hashes only
|
|
ggml-tiny.bin. Without this the file goes to pywhispercpp/ctypes as-is.
|
|
"""
|
|
|
|
def test_a_model_that_fails_its_pin_is_removed_and_not_loaded(self, tmp_path):
|
|
model = tmp_path / "ggml-tiny.bin"
|
|
model.write_bytes(b"not the model that is pinned")
|
|
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager._defer_download = True
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{"pywhispercpp": mock_pywhispercpp, "pywhispercpp.model": mock_pywhispercpp},
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=str(model),
|
|
):
|
|
with patch.object(manager, "_load_whispercpp_model") as load:
|
|
manager._init_whispercpp()
|
|
|
|
assert not model.exists(), "an unverifiable model must not stay on disk"
|
|
load.assert_not_called()
|
|
assert manager._model_initialized is False
|
|
|
|
def test_a_model_that_cannot_be_deleted_is_still_not_loaded(self, tmp_path):
|
|
model = tmp_path / "ggml-tiny.bin"
|
|
model.write_bytes(b"not the model that is pinned")
|
|
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager._defer_download = False
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{"pywhispercpp": mock_pywhispercpp, "pywhispercpp.model": mock_pywhispercpp},
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=str(model),
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.os.remove",
|
|
side_effect=OSError("read-only"),
|
|
):
|
|
with patch.object(manager, "_load_whispercpp_model") as load:
|
|
with pytest.raises(RuntimeError, match="failed verification"):
|
|
manager._init_whispercpp()
|
|
|
|
assert model.exists()
|
|
load.assert_not_called()
|
|
|
|
def test_an_unpinned_model_is_refused_not_deleted(self, tmp_path):
|
|
model = tmp_path / "ggml-not-in-manifest.bin"
|
|
model.write_bytes(b"bytes")
|
|
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager._defer_download = True
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{"pywhispercpp": mock_pywhispercpp, "pywhispercpp.model": mock_pywhispercpp},
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=str(model),
|
|
):
|
|
with patch.object(manager, "_load_whispercpp_model") as load:
|
|
manager._init_whispercpp()
|
|
|
|
assert model.exists(), "a missing pin is not a reason to delete the file"
|
|
load.assert_not_called()
|
|
assert manager._model_initialized is False
|
|
|
|
|
|
class TestParakeetRejectsAnUnverifiedModelOnDisk:
|
|
"""Wiring test: the hash has to happen where the model is picked up.
|
|
|
|
_download_parakeet_model verifies before its rename, so the exposure is a
|
|
bundle that never came through it: files copied in, or left over from a
|
|
cancelled download. sherpa-onnx loads these through native code. Without
|
|
this the files go to OfflineRecognizer.from_transducer as-is.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _write_bundle(tmp_path, payload=b"not the model that is pinned"):
|
|
model_dir = tmp_path / "v3-european"
|
|
model_dir.mkdir()
|
|
for name in PARAKEET_MODEL_FILES:
|
|
(model_dir / name).write_bytes(payload)
|
|
return model_dir
|
|
|
|
def test_a_model_that_fails_its_pin_is_removed_and_not_loaded(self, tmp_path):
|
|
model_dir = self._write_bundle(tmp_path)
|
|
|
|
manager = _make_manager(engine="parakeet")
|
|
manager.model_size = "v3-european"
|
|
manager._defer_download = True
|
|
|
|
mock_sherpa = MagicMock()
|
|
with patch.dict("sys.modules", {"sherpa_onnx": mock_sherpa}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.parakeet.get_model_path",
|
|
return_value=str(model_dir),
|
|
):
|
|
manager._init_parakeet()
|
|
|
|
for name in PARAKEET_MODEL_FILES:
|
|
assert not (model_dir / name).exists(), "an unverifiable model must not stay on disk"
|
|
mock_sherpa.OfflineRecognizer.from_transducer.assert_not_called()
|
|
assert manager._model_initialized is False
|
|
|
|
def test_a_model_that_cannot_be_deleted_is_still_not_loaded(self, tmp_path):
|
|
model_dir = self._write_bundle(tmp_path)
|
|
|
|
manager = _make_manager(engine="parakeet")
|
|
manager.model_size = "v3-european"
|
|
manager._defer_download = False
|
|
|
|
mock_sherpa = MagicMock()
|
|
with patch.dict("sys.modules", {"sherpa_onnx": mock_sherpa}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.parakeet.get_model_path",
|
|
return_value=str(model_dir),
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.os.remove",
|
|
side_effect=OSError("read-only"),
|
|
):
|
|
with pytest.raises(RuntimeError, match="failed verification"):
|
|
manager._init_parakeet()
|
|
|
|
for name in PARAKEET_MODEL_FILES:
|
|
assert (model_dir / name).exists()
|
|
mock_sherpa.OfflineRecognizer.from_transducer.assert_not_called()
|
|
|
|
def test_an_unpinned_model_is_refused_not_deleted(self, tmp_path):
|
|
"""Parakeet pins are constructed keys; simulate a missing pin for one file."""
|
|
model_dir = self._write_bundle(tmp_path, payload=b"bytes")
|
|
unpinned_name = PARAKEET_MODEL_FILES[0]
|
|
unpinned_key = parakeet_manifest_key("v3-european", unpinned_name)
|
|
|
|
def fake_verify(path, filename=None):
|
|
key = filename or os.path.basename(path)
|
|
if key == unpinned_key:
|
|
raise ChecksumError(f"No checksum is pinned for {key}")
|
|
verify_model_file_real(path, filename)
|
|
|
|
def fake_expected(filename):
|
|
if os.path.basename(filename) == unpinned_key:
|
|
return None
|
|
return expected_for(filename)
|
|
|
|
manager = _make_manager(engine="parakeet")
|
|
manager.model_size = "v3-european"
|
|
manager._defer_download = True
|
|
|
|
mock_sherpa = MagicMock()
|
|
with patch.dict("sys.modules", {"sherpa_onnx": mock_sherpa}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.parakeet.get_model_path",
|
|
return_value=str(model_dir),
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.verify_model_file",
|
|
side_effect=fake_verify,
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.expected_for",
|
|
side_effect=fake_expected,
|
|
):
|
|
manager._init_parakeet()
|
|
|
|
assert (
|
|
model_dir / unpinned_name
|
|
).exists(), "a missing pin is not a reason to delete the file"
|
|
mock_sherpa.OfflineRecognizer.from_transducer.assert_not_called()
|
|
assert manager._model_initialized is False
|
|
|
|
|
|
class TestParakeetDownloadVerifiesExistingFiles:
|
|
"""Existence is not a pin: a leftover dest must still match its digest.
|
|
|
|
A previous continue-on-exists skipped verification and left a copied-in or
|
|
truncated file for sherpa-onnx to load. OfflineRecognizer is not involved
|
|
here; this is the download loop only.
|
|
"""
|
|
|
|
def test_an_existing_file_that_fails_its_pin_is_redownloaded(self, tmp_path):
|
|
manager = _make_manager(engine="parakeet")
|
|
manager.model_size = "v3-european"
|
|
model_dir = tmp_path / "v3-european"
|
|
model_dir.mkdir()
|
|
|
|
encoder_name = PARAKEET_MODEL_FILES[0]
|
|
encoder = model_dir / encoder_name
|
|
encoder.write_bytes(b"not the encoder that is pinned")
|
|
encoder_key = parakeet_manifest_key("v3-european", encoder_name)
|
|
|
|
streamed = []
|
|
verify_calls = []
|
|
|
|
def fake_stream(url, dest_path):
|
|
streamed.append(dest_path)
|
|
with open(dest_path, "wb") as handle:
|
|
handle.write(b"good-enough")
|
|
|
|
def fake_verify(path, filename=None):
|
|
verify_calls.append((path, filename))
|
|
# Existing dests fail; freshly streamed temps pass.
|
|
if not str(path).endswith(".tmp"):
|
|
raise ChecksumError("digest mismatch")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.parakeet.get_model_path",
|
|
return_value=str(model_dir),
|
|
):
|
|
with patch.object(manager, "_stream_model_download", side_effect=fake_stream):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.verify_model_file",
|
|
side_effect=fake_verify,
|
|
):
|
|
manager._download_parakeet_model()
|
|
|
|
assert any(
|
|
path == str(encoder) and filename == encoder_key for path, filename in verify_calls
|
|
), "an existing dest must be hashed, not skipped because it is already on disk"
|
|
assert any(
|
|
os.path.basename(path) == encoder_name + ".tmp" for path in streamed
|
|
), "an existing bad file must be removed and re-downloaded"
|
|
assert encoder.read_bytes() == b"good-enough"
|
|
|
|
|
|
class TestAudioReconnection:
|
|
"""Test audio reconnection logic."""
|
|
|
|
def test_attempt_audio_reconnection_success(self):
|
|
"""Test successful audio reconnection."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
|
|
mock_pyaudio_mod = MagicMock()
|
|
mock_pyaudio_mod.paInt16 = 8
|
|
mock_stream = MagicMock()
|
|
mock_stream.read.return_value = b"\x00" * 1024
|
|
mock_audio_instance = MagicMock()
|
|
mock_audio_instance.open.return_value = mock_stream
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}):
|
|
with patch("time.sleep"):
|
|
result = manager._attempt_audio_reconnection(mock_audio_instance)
|
|
|
|
assert result is True
|
|
assert manager._audio_stream == mock_stream
|
|
|
|
def test_attempt_audio_reconnection_falls_back_to_default_resolver(self):
|
|
"""Test reconnection falls back when saved device name/index cannot resolve."""
|
|
manager = _make_manager(engine="whisper_cpp", audio_device_name="Missing Mic")
|
|
|
|
mock_pyaudio_mod = MagicMock()
|
|
mock_pyaudio_mod.paInt16 = 8
|
|
mock_stream = MagicMock()
|
|
mock_stream.read.return_value = b"\x00" * 1024
|
|
mock_audio_instance = MagicMock()
|
|
mock_audio_instance.get_default_input_device_info.return_value = {"index": 0}
|
|
mock_audio_instance.open.return_value = mock_stream
|
|
|
|
with (
|
|
patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}),
|
|
patch("time.sleep"),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._resolve_device_by_name",
|
|
return_value=None,
|
|
) as mock_resolve_name,
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device",
|
|
return_value=1,
|
|
) as mock_resolve_default,
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._get_supported_channels",
|
|
return_value=1,
|
|
),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._get_supported_sample_rate",
|
|
return_value=16000,
|
|
),
|
|
):
|
|
result = manager._attempt_audio_reconnection(mock_audio_instance)
|
|
|
|
assert result is True
|
|
assert manager._audio_stream == mock_stream
|
|
mock_resolve_name.assert_called_once_with(mock_audio_instance, "Missing Mic", None)
|
|
mock_resolve_default.assert_called_once_with(mock_audio_instance, None)
|
|
|
|
def test_attempt_audio_reconnection_no_resolved_device(self):
|
|
"""When no safe device is enumerated, reconnect via system default."""
|
|
manager = _make_manager(engine="whisper_cpp", audio_device_name="Missing Mic")
|
|
|
|
mock_pyaudio_mod = MagicMock()
|
|
mock_pyaudio_mod.paInt16 = 8
|
|
mock_audio_instance = MagicMock()
|
|
mock_stream = MagicMock()
|
|
|
|
with (
|
|
patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}),
|
|
patch("time.sleep"),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._resolve_device_by_name",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._open_capture_stream",
|
|
return_value=(1, 16000, mock_stream),
|
|
) as mock_open,
|
|
):
|
|
result = manager._attempt_audio_reconnection(mock_audio_instance)
|
|
|
|
assert result is True
|
|
mock_open.assert_called_once_with(mock_audio_instance, None)
|
|
|
|
def test_attempt_audio_reconnection_max_attempts(self):
|
|
"""Test reconnection stops after max attempts."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager._reconnection_attempts = manager._max_reconnection_attempts
|
|
|
|
mock_audio_instance = MagicMock()
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": MagicMock()}):
|
|
result = manager._attempt_audio_reconnection(mock_audio_instance)
|
|
|
|
assert result is False
|
|
|
|
def test_attempt_audio_reconnection_open_failure(self):
|
|
"""Test reconnection when stream open fails."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
|
|
mock_pyaudio_mod = MagicMock()
|
|
mock_pyaudio_mod.paInt16 = 8
|
|
mock_audio_instance = MagicMock()
|
|
mock_audio_instance.open.side_effect = IOError("Cannot open stream")
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}):
|
|
with patch("time.sleep"):
|
|
result = manager._attempt_audio_reconnection(mock_audio_instance)
|
|
|
|
assert result is False
|
|
|
|
def test_attempt_audio_reconnection_exponential_backoff(self):
|
|
"""Test exponential backoff in reconnection attempts."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager._reconnection_delay = 0.1
|
|
|
|
mock_pyaudio_mod = MagicMock()
|
|
mock_pyaudio_mod.paInt16 = 8
|
|
mock_stream = MagicMock()
|
|
mock_stream.read.return_value = b"\x00" * 1024
|
|
mock_audio_instance = MagicMock()
|
|
mock_audio_instance.open.return_value = mock_stream
|
|
|
|
sleep_durations = []
|
|
|
|
def track_sleep(duration):
|
|
sleep_durations.append(duration)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}):
|
|
with patch("time.sleep", side_effect=track_sleep):
|
|
manager._reconnection_attempts = 0
|
|
manager._attempt_audio_reconnection(mock_audio_instance)
|
|
first_delay = sleep_durations[-1]
|
|
|
|
manager._reconnection_attempts = 1
|
|
manager._attempt_audio_reconnection(mock_audio_instance)
|
|
second_delay = sleep_durations[-1]
|
|
|
|
assert second_delay > first_delay
|
|
assert second_delay == first_delay * 2
|
|
|
|
def test_attempt_audio_reconnection_negotiation_fallback(self):
|
|
"""When negotiation returns no stream, reconnect falls back to plain open."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
|
|
mock_pyaudio_mod = MagicMock()
|
|
mock_pyaudio_mod.paInt16 = 8
|
|
mock_stream = MagicMock()
|
|
mock_stream.read.return_value = b"\x00" * 1024
|
|
mock_audio_instance = MagicMock()
|
|
mock_audio_instance.open.return_value = mock_stream
|
|
|
|
with (
|
|
patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}),
|
|
patch("time.sleep"),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._open_capture_stream",
|
|
return_value=(1, 16000, None),
|
|
),
|
|
):
|
|
result = manager._attempt_audio_reconnection(mock_audio_instance)
|
|
|
|
assert result is True
|
|
assert manager._audio_stream == mock_stream
|
|
mock_audio_instance.open.assert_called_once()
|
|
|
|
def test_attempt_audio_reconnection_empty_read_closes_stream(self):
|
|
"""A reconnected stream that returns no data must be closed safely."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
|
|
mock_pyaudio_mod = MagicMock()
|
|
mock_pyaudio_mod.paInt16 = 8
|
|
mock_stream = MagicMock()
|
|
mock_stream.read.return_value = b""
|
|
mock_audio_instance = MagicMock()
|
|
mock_audio_instance.open.return_value = mock_stream
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}):
|
|
with patch("time.sleep"):
|
|
result = manager._attempt_audio_reconnection(mock_audio_instance)
|
|
|
|
assert result is False
|
|
mock_stream.stop_stream.assert_called_once()
|
|
mock_stream.close.assert_called_once()
|
|
|
|
|
|
class TestIBusEngineUtilities:
|
|
"""Test ibus_engine utility functions."""
|
|
|
|
def test_is_ibus_available(self):
|
|
"""Test is_ibus_available() function."""
|
|
from vocalinux.text_injection.ibus_engine import is_ibus_available
|
|
|
|
result = is_ibus_available()
|
|
assert isinstance(result, bool)
|
|
|
|
def test_is_ibus_daemon_running(self):
|
|
"""Test daemon detection when not running."""
|
|
from vocalinux.text_injection.ibus_engine import is_ibus_daemon_running
|
|
|
|
with patch("subprocess.run") as mock_run:
|
|
mock_run.return_value.returncode = 1
|
|
result = is_ibus_daemon_running()
|
|
assert result is False
|
|
|
|
def test_is_ibus_daemon_running_success(self):
|
|
"""Test daemon detection when running."""
|
|
from vocalinux.text_injection.ibus_engine import is_ibus_daemon_running
|
|
|
|
with patch("subprocess.run") as mock_run:
|
|
mock_run.return_value.returncode = 0
|