diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index b749745..eb2b041 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -21,13 +21,31 @@ from typing import Callable, Optional from ..common_types import RecognitionState from ..ui.audio_feedback import play_error_sound, play_start_sound, play_stop_sound from ..utils.paths import models_dir -from ..utils.vosk_model_info import VOSK_MODEL_INFO +from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO from ..utils.whispercpp_model_info import WHISPERCPP_MODEL_INFO, get_model_path, is_model_downloaded from ..version import __version__ from .command_processor import CommandProcessor from .silero_vad import SILERO_CHUNK_SIZE, load_silero_vad +def resolve_whisper_language(language: str) -> Optional[str]: + """Map a catalog language id to a Whisper / whisper.cpp language code. + + Catalog keys like ``en-us`` / ``en-in`` are not valid Whisper codes; the + ``whisper`` field on ``SUPPORTED_LANGUAGES`` holds the ISO-style code + (``en``). ``auto`` resolves to ``None`` for model auto-detect. + """ + if language == "auto": + return None + info = SUPPORTED_LANGUAGES.get(language) + if info is not None and "whisper" in info: + return info["whisper"] + # Legacy fallback for codes not present in the catalog. + if language == "en-us": + return "en" + return language + + # ALSA error handler to suppress warnings during PyAudio initialization def _setup_alsa_error_handler(): """Set up an error handler to suppress ALSA warnings.""" @@ -1137,11 +1155,7 @@ class SpeechRecognitionManager: import torch use_fp16 = self.model.device != torch.device("cpu") - lang = self.language - if self.language == "en-us": - lang = "en" - elif self.language == "auto": - lang = None # Auto-detect + lang = resolve_whisper_language(self.language) # Transcribe with Whisper (handles variable length audio automatically) result = self.model.transcribe( @@ -1488,11 +1502,7 @@ class SpeechRecognitionManager: ) # Prepare language parameter - lang = self.language - if self.language == "en-us": - lang = "en" - elif self.language == "auto": - lang = None # Auto-detect + lang = resolve_whisper_language(self.language) logger.debug(f"whisper.cpp using language: {lang or 'auto-detect'}") @@ -1625,11 +1635,7 @@ class SpeechRecognitionManager: ) # Prepare language parameters - lang = self.language - if lang == "en-us": - lang = "en" - elif lang == "auto": - lang = None + lang = resolve_whisper_language(self.language) # Prepare HTTP request headers headers = {} diff --git a/tests/test_recognition_manager_remote_api.py b/tests/test_recognition_manager_remote_api.py index 754e523..fd2048f 100644 --- a/tests/test_recognition_manager_remote_api.py +++ b/tests/test_recognition_manager_remote_api.py @@ -366,6 +366,18 @@ class TestRemoteAPITranscription(unittest.TestCase): # Should have been called with "en" (converted from "en-us") mock_server.assert_called_once_with(ANY, "en", ANY, ANY) + def test_transcribe_with_language_en_in_conversion(self): + """English (India) catalog id must map to Whisper code '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-in" + self.manager._transcribe_with_remote_api([b"audio"], self.manager._http_session) + + mock_server.assert_called_once_with(ANY, "en", ANY, ANY) + def test_transcribe_with_language_auto(self): """Test with auto language detection.""" from unittest.mock import patch diff --git a/tests/test_speech_recognition.py b/tests/test_speech_recognition.py index 36e44b9..7d4a683 100644 --- a/tests/test_speech_recognition.py +++ b/tests/test_speech_recognition.py @@ -305,6 +305,35 @@ class TestSpeechRecognition(unittest.TestCase): self.assertIsNone(hu["vosk"]) self.assertNotIn("hu", VOSK_MODEL_INFO["small"]["languages"]) + def test_resolve_whisper_language_maps_english_variants(self): + """Catalog ids like en-us/en-in must become Whisper's 'en' code.""" + from unittest.mock import patch + + from vocalinux.speech_recognition.recognition_manager import resolve_whisper_language + + self.assertEqual(resolve_whisper_language("en-us"), "en") + self.assertEqual(resolve_whisper_language("en-in"), "en") + self.assertEqual(resolve_whisper_language("hu"), "hu") + self.assertEqual(resolve_whisper_language("ja"), "ja") + self.assertIsNone(resolve_whisper_language("auto")) + # Unknown catalog ids pass through unchanged. + self.assertEqual(resolve_whisper_language("zz-unknown"), "zz-unknown") + + # Legacy fallback when a code is missing from the catalog entirely. + with patch( + "vocalinux.speech_recognition.recognition_manager.SUPPORTED_LANGUAGES", + {}, + ): + self.assertEqual(resolve_whisper_language("en-us"), "en") + self.assertEqual(resolve_whisper_language("fr"), "fr") + + # Entry present but without a whisper field still falls through. + with patch( + "vocalinux.speech_recognition.recognition_manager.SUPPORTED_LANGUAGES", + {"legacy": {"name": "Legacy", "vosk": None}}, + ): + self.assertEqual(resolve_whisper_language("legacy"), "legacy") + def test_vosk_init_italian_medium_model_resolves_correctly(self): """Selecting Italian + the 'medium' VOSK model must not crash (issue #550).""" manager = SpeechRecognitionManager(engine="vosk", language="it", model_size="medium")