* 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>
203 lines
7.7 KiB
Python
203 lines
7.7 KiB
Python
"""The recommendation must be applicable, and must not ignore the disk (#778)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import sys
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, Mock, call, patch
|
|
|
|
import pytest
|
|
|
|
import vocalinux.ui
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def settings_dialog() -> Iterator[Any]:
|
|
"""Import settings_dialog with real base classes for its GTK subclasses.
|
|
|
|
Same reasoning as tests/test_settings_model_state.py; both sys.modules and the
|
|
package attribute are restored so later tests patching the module by name do
|
|
not reach a second module object (#686).
|
|
"""
|
|
repository = sys.modules["gi.repository"]
|
|
bases = {name: type(name, (), {}) for name in ("Box", "ListBoxRow", "Dialog")}
|
|
saved_module = sys.modules.pop("vocalinux.ui.settings_dialog", None)
|
|
saved_attribute = getattr(vocalinux.ui, "settings_dialog", None)
|
|
try:
|
|
with patch.object(repository, "Gtk", MagicMock(**bases)):
|
|
module = importlib.import_module("vocalinux.ui.settings_dialog")
|
|
yield module
|
|
finally:
|
|
sys.modules.pop("vocalinux.ui.settings_dialog", None)
|
|
if saved_module is not None:
|
|
sys.modules["vocalinux.ui.settings_dialog"] = saved_module
|
|
if saved_attribute is not None:
|
|
vocalinux.ui.settings_dialog = saved_attribute
|
|
elif hasattr(vocalinux.ui, "settings_dialog"):
|
|
del vocalinux.ui.settings_dialog
|
|
|
|
|
|
@pytest.fixture
|
|
def dialog_class(settings_dialog: Any) -> Any:
|
|
return settings_dialog.SettingsDialog
|
|
|
|
|
|
def _dialog_stub(language: str = "en-us") -> Mock:
|
|
dialog = Mock()
|
|
dialog.language = language
|
|
dialog.language_combo.get_active_id.return_value = language
|
|
dialog._populating_models = False
|
|
dialog._processing_language_change = False
|
|
# Plain Mock attrs are truthy; these guards must be real False or the
|
|
# language-changed handler returns before updating dialog.language.
|
|
dialog._initializing = False
|
|
dialog._applying_settings = False
|
|
return dialog
|
|
|
|
|
|
def _with_disk(settings_dialog: Any, downloaded: list[str]) -> tuple[Any, Any]:
|
|
"""Patch the disk so only ``downloaded`` counts as present."""
|
|
return (
|
|
patch.object(settings_dialog, "list_downloaded_whispercpp_models", return_value=downloaded),
|
|
patch.object(
|
|
settings_dialog,
|
|
"is_whispercpp_model_downloaded",
|
|
side_effect=lambda name: name in downloaded,
|
|
),
|
|
)
|
|
|
|
|
|
def test_a_bigger_model_on_disk_is_offered_instead_of_a_download(
|
|
settings_dialog: Any, dialog_class: Any
|
|
) -> None:
|
|
"""The reported case: recommended small.en, medium.en already paid for."""
|
|
dialog = _dialog_stub()
|
|
listed, downloaded = _with_disk(settings_dialog, ["medium.en", "small", "tiny"])
|
|
|
|
with listed, downloaded:
|
|
alternative = dialog_class._downloaded_alternative_for(dialog, "small.en")
|
|
|
|
assert alternative == "medium.en"
|
|
|
|
|
|
def test_nothing_is_offered_when_the_recommendation_is_already_on_disk(
|
|
settings_dialog: Any, dialog_class: Any
|
|
) -> None:
|
|
dialog = _dialog_stub()
|
|
listed, downloaded = _with_disk(settings_dialog, ["small.en", "medium.en"])
|
|
|
|
with listed, downloaded:
|
|
assert dialog_class._downloaded_alternative_for(dialog, "small.en") is None
|
|
|
|
|
|
def test_a_smaller_model_is_never_offered(settings_dialog: Any, dialog_class: Any) -> None:
|
|
"""Reusing a download must not quietly cost accuracy."""
|
|
dialog = _dialog_stub()
|
|
listed, downloaded = _with_disk(settings_dialog, ["tiny", "tiny.en"])
|
|
|
|
with listed, downloaded:
|
|
assert dialog_class._downloaded_alternative_for(dialog, "medium.en") is None
|
|
|
|
|
|
def test_english_only_weights_are_not_offered_for_another_language(
|
|
settings_dialog: Any, dialog_class: Any
|
|
) -> None:
|
|
dialog = _dialog_stub(language="pl")
|
|
listed, downloaded = _with_disk(settings_dialog, ["medium.en"])
|
|
|
|
with listed, downloaded:
|
|
assert dialog_class._downloaded_alternative_for(dialog, "small") is None
|
|
|
|
|
|
def test_the_smallest_qualifying_model_wins(settings_dialog: Any, dialog_class: Any) -> None:
|
|
"""Between two usable downloads, take the cheaper one to run."""
|
|
dialog = _dialog_stub()
|
|
listed, downloaded = _with_disk(settings_dialog, ["large", "medium.en"])
|
|
|
|
with listed, downloaded:
|
|
assert dialog_class._downloaded_alternative_for(dialog, "small.en") == "medium.en"
|
|
|
|
|
|
def test_applying_the_recommendation_sets_both_pickers(
|
|
settings_dialog: Any, dialog_class: Any
|
|
) -> None:
|
|
"""Clicking must move size and specialization together, not just one."""
|
|
dialog = _dialog_stub()
|
|
dialog._recommended_target_model = "small.en"
|
|
|
|
dialog_class._on_apply_recommendation(dialog, None)
|
|
|
|
dialog.model_combo.set_active_id.assert_called_once_with("small")
|
|
dialog._populate_whispercpp_variant_options.assert_called_once_with("small", "small.en")
|
|
dialog.model_variant_combo.set_active_id.assert_called_once_with("small.en")
|
|
dialog._sync_language_options_for_selected_model.assert_called_once_with()
|
|
dialog._update_model_info.assert_called_once_with()
|
|
dialog._refresh_unused_downloads.assert_called_once_with()
|
|
dialog._auto_apply_settings.assert_called_once_with()
|
|
assert dialog._populating_models is False
|
|
|
|
|
|
def test_applying_does_nothing_without_a_target(settings_dialog: Any, dialog_class: Any) -> None:
|
|
dialog = _dialog_stub()
|
|
dialog._recommended_target_model = None
|
|
|
|
dialog_class._on_apply_recommendation(dialog, None)
|
|
|
|
dialog.model_combo.set_active_id.assert_not_called()
|
|
dialog._auto_apply_settings.assert_not_called()
|
|
|
|
|
|
def test_applying_recommendation_suppresses_model_changed_auto_apply(
|
|
settings_dialog: Any, dialog_class: Any
|
|
) -> None:
|
|
"""Size change must not briefly auto-apply the default size before the target.
|
|
|
|
Without ``_populating_models``, ``set_active_id(large)`` would fire
|
|
``_on_model_changed`` and download full large before turbo-q5_0 lands.
|
|
"""
|
|
dialog = _dialog_stub()
|
|
dialog._recommended_target_model = "large-v3-turbo-q5_0"
|
|
seen_while_setting_size: list[bool] = []
|
|
|
|
def capture_flag(_model_size: str) -> bool:
|
|
seen_while_setting_size.append(dialog._populating_models)
|
|
return True
|
|
|
|
dialog.model_combo.set_active_id.side_effect = capture_flag
|
|
|
|
dialog_class._on_apply_recommendation(dialog, None)
|
|
|
|
assert seen_while_setting_size == [True]
|
|
dialog._populate_whispercpp_variant_options.assert_called_once_with(
|
|
"large", "large-v3-turbo-q5_0"
|
|
)
|
|
dialog.model_variant_combo.set_active_id.assert_called_once_with("large-v3-turbo-q5_0")
|
|
# One apply after both pickers settle — not during the size change.
|
|
dialog._auto_apply_settings.assert_called_once_with()
|
|
assert dialog._populating_models is False
|
|
|
|
|
|
def test_language_change_refreshes_recommendation_ui(
|
|
settings_dialog: Any, dialog_class: Any
|
|
) -> None:
|
|
"""EN→PL must refresh recommendation target / label so Use it is not stale."""
|
|
dialog = _dialog_stub(language="en-us")
|
|
dialog.language_combo.get_active_id.return_value = "pl"
|
|
dialog.engine_combo.get_active_text.return_value = "whisper.cpp"
|
|
dialog._recommended_target_model = "small.en"
|
|
|
|
dialog_class._on_language_changed(dialog, None)
|
|
|
|
assert dialog.language == "pl"
|
|
dialog._populate_model_options.assert_called_once_with()
|
|
dialog._update_language_warning.assert_called_once_with()
|
|
dialog._update_model_info.assert_called_once_with()
|
|
dialog._auto_apply_settings.assert_called_once_with()
|
|
# Refresh happens before auto-apply so the card is current when settings land.
|
|
assert dialog.mock_calls.index(call._update_model_info()) < dialog.mock_calls.index(
|
|
call._auto_apply_settings()
|
|
)
|
|
assert dialog._processing_language_change is False
|