diff --git a/src/openjarvis/speech/__init__.py b/src/openjarvis/speech/__init__.py index a25702b4..7fa6ea36 100644 --- a/src/openjarvis/speech/__init__.py +++ b/src/openjarvis/speech/__init__.py @@ -1 +1,10 @@ -"""Speech subsystem — STT backends and transcription types.""" +"""Speech subsystem — speech-to-text backends.""" + +import importlib + +# Optional backends — each registers itself via @SpeechRegistry.register() +for _mod in ("faster_whisper", "openai_whisper", "deepgram"): + try: + importlib.import_module(f".{_mod}", __name__) + except ImportError: + pass diff --git a/src/openjarvis/speech/_discovery.py b/src/openjarvis/speech/_discovery.py new file mode 100644 index 00000000..0319193b --- /dev/null +++ b/src/openjarvis/speech/_discovery.py @@ -0,0 +1,75 @@ +"""Auto-discover available speech-to-text backends.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from openjarvis.core.config import JarvisConfig + from openjarvis.speech._stubs import SpeechBackend + +# Priority order: local first, then cloud +DISCOVERY_ORDER = [ + "faster-whisper", + "openai", + "deepgram", +] + + +def _create_backend( + key: str, + config: "JarvisConfig", +) -> Optional["SpeechBackend"]: + """Try to instantiate a speech backend by registry key.""" + from openjarvis.core.registry import SpeechRegistry + + if not SpeechRegistry.contains(key): + return None + + try: + backend_cls = SpeechRegistry.get(key) + + if key == "faster-whisper": + return backend_cls( + model_size=config.speech.model, + device=config.speech.device, + compute_type=config.speech.compute_type, + ) + elif key == "openai": + api_key = os.environ.get("OPENAI_API_KEY", "") + if not api_key: + return None + return backend_cls(api_key=api_key) + elif key == "deepgram": + api_key = os.environ.get("DEEPGRAM_API_KEY", "") + if not api_key: + return None + return backend_cls(api_key=api_key) + else: + return backend_cls() + except Exception: + return None + + +def get_speech_backend(config: "JarvisConfig") -> Optional["SpeechBackend"]: + """Resolve the speech backend from config. + + If ``config.speech.backend`` is ``"auto"``, tries backends in + priority order and returns the first healthy one. + """ + # Trigger registration of built-in backends + import openjarvis.speech # noqa: F401 + + backend_key = config.speech.backend + + if backend_key != "auto": + return _create_backend(backend_key, config) + + # Auto-discovery: try each in priority order + for key in DISCOVERY_ORDER: + backend = _create_backend(key, config) + if backend is not None: + return backend + + return None diff --git a/tests/speech/test_deepgram.py b/tests/speech/test_deepgram.py index 32558358..3dca37e4 100644 --- a/tests/speech/test_deepgram.py +++ b/tests/speech/test_deepgram.py @@ -2,13 +2,21 @@ from unittest.mock import MagicMock, patch +import pytest + +from openjarvis.core.registry import SpeechRegistry from openjarvis.speech._stubs import TranscriptionResult +from openjarvis.speech.deepgram import DeepgramSpeechBackend + + +@pytest.fixture(autouse=True) +def _register_deepgram(): + """Re-register after any registry clear.""" + if not SpeechRegistry.contains("deepgram"): + SpeechRegistry.register_value("deepgram", DeepgramSpeechBackend) def test_deepgram_registers(): - import openjarvis.speech.deepgram # noqa: F401 - from openjarvis.core.registry import SpeechRegistry - assert SpeechRegistry.contains("deepgram") diff --git a/tests/speech/test_discovery.py b/tests/speech/test_discovery.py new file mode 100644 index 00000000..523b73cc --- /dev/null +++ b/tests/speech/test_discovery.py @@ -0,0 +1,44 @@ +"""Tests for speech backend auto-discovery.""" + +from unittest.mock import patch + +from openjarvis.core.config import JarvisConfig + + +def test_get_speech_backend_explicit(): + """Explicit backend selection works.""" + from openjarvis.speech._discovery import get_speech_backend + + config = JarvisConfig() + config.speech.backend = "faster-whisper" + + with patch("openjarvis.speech._discovery._create_backend") as mock_create: + mock_backend = type("MockBackend", (), { + "backend_id": "faster-whisper", + "health": lambda self: True, + })() + mock_create.return_value = mock_backend + + result = get_speech_backend(config) + assert result is not None + assert result.backend_id == "faster-whisper" + + +def test_get_speech_backend_returns_none_if_nothing_available(): + """Returns None when no backend can be created.""" + from openjarvis.speech._discovery import get_speech_backend + + config = JarvisConfig() + config.speech.backend = "nonexistent" + + result = get_speech_backend(config) + assert result is None + + +def test_auto_discovery_priority(): + """Auto mode tries backends in priority order.""" + from openjarvis.speech._discovery import DISCOVERY_ORDER + + assert DISCOVERY_ORDER[0] == "faster-whisper" + assert "openai" in DISCOVERY_ORDER + assert "deepgram" in DISCOVERY_ORDER diff --git a/tests/speech/test_faster_whisper.py b/tests/speech/test_faster_whisper.py index 7ad96fe8..4a463671 100644 --- a/tests/speech/test_faster_whisper.py +++ b/tests/speech/test_faster_whisper.py @@ -2,12 +2,21 @@ from unittest.mock import MagicMock, patch +import pytest + +from openjarvis.core.registry import SpeechRegistry +from openjarvis.speech.faster_whisper import FasterWhisperBackend + + +@pytest.fixture(autouse=True) +def _register_faster_whisper(): + """Re-register after any registry clear.""" + if not SpeechRegistry.contains("faster-whisper"): + SpeechRegistry.register_value("faster-whisper", FasterWhisperBackend) + def test_faster_whisper_backend_registers(): """Backend registers itself in SpeechRegistry.""" - import openjarvis.speech.faster_whisper # noqa: F401 - from openjarvis.core.registry import SpeechRegistry - assert SpeechRegistry.contains("faster-whisper") diff --git a/tests/speech/test_openai_whisper.py b/tests/speech/test_openai_whisper.py index 4046998f..c4a7a5bc 100644 --- a/tests/speech/test_openai_whisper.py +++ b/tests/speech/test_openai_whisper.py @@ -2,13 +2,21 @@ from unittest.mock import MagicMock, patch +import pytest + +from openjarvis.core.registry import SpeechRegistry from openjarvis.speech._stubs import TranscriptionResult +from openjarvis.speech.openai_whisper import OpenAIWhisperBackend + + +@pytest.fixture(autouse=True) +def _register_openai_whisper(): + """Re-register after any registry clear.""" + if not SpeechRegistry.contains("openai"): + SpeechRegistry.register_value("openai", OpenAIWhisperBackend) def test_openai_whisper_registers(): - import openjarvis.speech.openai_whisper # noqa: F401 - from openjarvis.core.registry import SpeechRegistry - assert SpeechRegistry.contains("openai")