from __future__ import annotations import json import queue import time import httpx import pytest from services.ai_voice_runtime_service.audiosocket import pcm16le_to_wav_bytes from services.ai_voice_runtime_service.providers import asr as asr_module class _DummyResponse: def __init__(self, json_payload: dict | None = None) -> None: self.content = b"{}" self._json_payload = json_payload or {} def raise_for_status(self) -> None: return None def json(self) -> dict: return self._json_payload def test_yandex_asr_provider_posts_lpcm_with_tts_credential_fallback(monkeypatch): calls: list[dict] = [] pcm = b"\x01\x00" * 160 wav_bytes = pcm16le_to_wav_bytes(pcm, sample_rate_hz=8000) class _DummyClient: def __init__(self, *, timeout: float) -> None: self.timeout = timeout def __enter__(self) -> "_DummyClient": return self def __exit__(self, exc_type, exc, tb) -> None: return None def post( self, url: str, *, headers: dict[str, str], params: dict[str, str], content: bytes, ) -> _DummyResponse: calls.append( { "url": url, "headers": headers, "params": params, "content": content, "timeout": self.timeout, } ) return _DummyResponse({"result": "almaty schedule"}) monkeypatch.delenv("AI_VOICE_ASR_YANDEX_API_KEY", raising=False) monkeypatch.delenv("AI_VOICE_ASR_YANDEX_IAM_TOKEN", raising=False) monkeypatch.setenv("AI_VOICE_TTS_YANDEX_API_KEY", "tts-yandex-key") monkeypatch.setenv("AI_VOICE_ASR_YANDEX_API_BASE", "https://stt.example.test") monkeypatch.setenv("AI_VOICE_ASR_YANDEX_FOLDER_ID", "folder-test") monkeypatch.setenv("AI_VOICE_ASR_YANDEX_TIMEOUT_SECONDS", "5") monkeypatch.setattr(asr_module.httpx, "Client", _DummyClient) provider = asr_module.YandexSpeechKitASRProvider() result = provider.transcribe(wav_bytes, language_hint="ru") assert result.text == "almaty schedule" assert result.language == "ru-RU" assert len(calls) == 1 assert calls[0]["url"] == "https://stt.example.test/speech/v1/stt:recognize" assert calls[0]["headers"]["Authorization"] == "Api-Key tts-yandex-key" assert calls[0]["headers"]["Content-Type"] == "application/octet-stream" assert calls[0]["params"]["lang"] == "ru-RU" assert calls[0]["params"]["format"] == "lpcm" assert calls[0]["params"]["sampleRateHertz"] == "8000" assert calls[0]["params"]["topic"] == "general" assert calls[0]["params"]["folderId"] == "folder-test" assert calls[0]["content"] == pcm assert calls[0]["timeout"] == 5.0 def test_elevenlabs_asr_provider_posts_wav_with_tts_credential_fallback(monkeypatch): calls: list[dict] = [] pcm = b"\x01\x00" * 160 wav_bytes = pcm16le_to_wav_bytes(pcm, sample_rate_hz=8000) class _DummyClient: def __init__(self, *, timeout: float) -> None: self.timeout = timeout def __enter__(self) -> "_DummyClient": return self def __exit__(self, exc_type, exc, tb) -> None: return None def post( self, url: str, *, headers: dict[str, str], data: dict[str, str], files: dict[str, tuple[str, bytes, str]], ) -> _DummyResponse: calls.append( { "url": url, "headers": headers, "data": data, "files": files, "timeout": self.timeout, } ) return _DummyResponse({"text": "schedule", "language_code": "rus"}) monkeypatch.delenv("AI_VOICE_ASR_ELEVENLABS_API_KEY", raising=False) monkeypatch.setenv("AI_VOICE_TTS_ELEVENLABS_API_KEY", "tts-elevenlabs-key") monkeypatch.setenv("AI_VOICE_ASR_ELEVENLABS_API_BASE", "https://api.elevenlabs.example") monkeypatch.setenv("AI_VOICE_ASR_ELEVENLABS_MODEL_ID", "scribe_v1") monkeypatch.setattr(asr_module.httpx, "Client", _DummyClient) provider = asr_module.ElevenLabsASRProvider() result = provider.transcribe(wav_bytes, language_hint="ru") assert result.text == "schedule" assert result.language == "ru" assert len(calls) == 1 assert calls[0]["url"] == "https://api.elevenlabs.example/v1/speech-to-text" assert calls[0]["headers"]["xi-api-key"] == "tts-elevenlabs-key" assert calls[0]["data"]["model_id"] == "scribe_v1" assert calls[0]["data"]["language_code"] == "rus" assert calls[0]["files"]["file"][0] == "turn.wav" assert calls[0]["files"]["file"][2] == "audio/wav" assert calls[0]["timeout"] == 20.0 def test_elevenlabs_asr_provider_surfaces_http_auth_errors(monkeypatch): request = httpx.Request("POST", "https://api.elevenlabs.example/v1/speech-to-text") response = httpx.Response(401, request=request) class _DummyClient: def __init__(self, *, timeout: float) -> None: self.timeout = timeout def __enter__(self) -> "_DummyClient": return self def __exit__(self, exc_type, exc, tb) -> None: return None def post( self, url: str, *, headers: dict[str, str], data: dict[str, str], files: dict[str, tuple[str, bytes, str]], ) -> _DummyResponse: del url, headers, data, files class _FailingResponse(_DummyResponse): def raise_for_status(self_nonlocal) -> None: raise httpx.HTTPStatusError("401 Unauthorized", request=request, response=response) return _FailingResponse() monkeypatch.setenv("AI_VOICE_ASR_ELEVENLABS_API_KEY", "asr-elevenlabs-key") monkeypatch.setattr(asr_module.httpx, "Client", _DummyClient) provider = asr_module.ElevenLabsASRProvider() with pytest.raises(httpx.HTTPStatusError): provider.transcribe(b"\x01\x00" * 160, language_hint="ru") def test_yandex_asr_provider_uses_iam_token(monkeypatch): calls: list[dict] = [] class _DummyClient: def __init__(self, *, timeout: float) -> None: self.timeout = timeout def __enter__(self) -> "_DummyClient": return self def __exit__(self, exc_type, exc, tb) -> None: return None def post( self, url: str, *, headers: dict[str, str], params: dict[str, str], content: bytes, ) -> _DummyResponse: calls.append({"url": url, "headers": headers, "params": params, "content": content}) return _DummyResponse({"result": "operator"}) monkeypatch.delenv("AI_VOICE_ASR_YANDEX_API_KEY", raising=False) monkeypatch.delenv("AI_VOICE_TTS_YANDEX_API_KEY", raising=False) monkeypatch.setenv("AI_VOICE_ASR_YANDEX_IAM_TOKEN", "iam-token") monkeypatch.setenv("AI_VOICE_ASR_YANDEX_API_BASE", "https://stt.example.test") monkeypatch.setattr(asr_module.httpx, "Client", _DummyClient) provider = asr_module.YandexSpeechKitASRProvider() result = provider.transcribe(b"\x02\x00" * 80, language_hint="kk") assert result.text == "operator" assert result.language == "kk-KZ" assert calls[0]["headers"]["Authorization"] == "Bearer iam-token" assert calls[0]["params"]["lang"] == "kk-KZ" def test_yandex_asr_provider_supports_kz_v3_async_rest(monkeypatch): calls: list[dict] = [] class _DummyClient: def __init__(self, *, timeout: float) -> None: self.timeout = timeout def __enter__(self) -> "_DummyClient": return self def __exit__(self, exc_type, exc, tb) -> None: return None def post(self, url: str, *, headers: dict[str, str], json: dict) -> _DummyResponse: calls.append({"method": "POST", "url": url, "headers": headers, "json": json}) return _DummyResponse({"id": "operation-1", "done": False}) def get( self, url: str, *, headers: dict[str, str], params: dict[str, str] | None = None, ) -> _DummyResponse: calls.append({"method": "GET", "url": url, "headers": headers, "params": params or {}}) if "operation.api.cloud.yandex.net/operations/" in url: return _DummyResponse({"id": "operation-1", "done": True}) return _DummyResponse( { "finalRefinement": { "normalizedText": { "alternatives": [ { "text": "almaty schedule", } ] } } } ) monkeypatch.setenv("AI_VOICE_ASR_YANDEX_API_KEY", "asr-key") monkeypatch.setenv("AI_VOICE_ASR_YANDEX_API_BASE", "https://stt.api.ml.yandexcloud.kz") monkeypatch.delenv("AI_VOICE_ASR_YANDEX_OPERATIONS_BASE", raising=False) monkeypatch.setenv("AI_VOICE_ASR_YANDEX_FOLDER_ID", "folder-test") monkeypatch.setenv("AI_VOICE_ASR_YANDEX_POLL_INTERVAL_SECONDS", "0.05") monkeypatch.setattr(asr_module.httpx, "Client", _DummyClient) provider = asr_module.YandexSpeechKitASRProvider() result = provider.transcribe(b"\x03\x00" * 80, language_hint="ru") assert result.text == "almaty schedule" assert result.language == "ru-RU" assert calls[0]["url"] == "https://stt.api.cloud.yandex.net/stt/v3/recognizeFileAsync" assert calls[0]["headers"]["Authorization"] == "Api-Key asr-key" assert calls[0]["headers"]["Content-Type"] == "application/json" assert calls[0]["headers"]["x-folder-id"] == "folder-test" assert calls[0]["json"]["recognitionModel"]["audioFormat"]["rawAudio"]["sampleRateHertz"] == "8000" assert calls[0]["json"]["recognitionModel"]["languageRestriction"]["languageCode"] == ["ru-RU"] assert calls[1]["url"] == "https://operation.api.cloud.yandex.net/operations/operation-1" assert calls[1]["params"] == {} assert calls[2]["url"] == "https://stt.api.cloud.yandex.net/stt/v3/getRecognition" assert calls[2]["params"] == {"operationId": "operation-1"} def test_yandex_buffered_streaming_provider_buffers_pcm_until_finalize(): calls: list[dict] = [] class _FakeYandexASR: def transcribe(self, audio_bytes: bytes, *, language_hint: str | None = None): calls.append({"audio_bytes": audio_bytes, "language_hint": language_hint}) return asr_module.ASRTranscription(text="need schedule", language=language_hint, confidence=None) provider = asr_module.YandexSpeechKitBufferedStreamingASRProvider(asr_provider=_FakeYandexASR()) # type: ignore[arg-type] stream_id = provider.open_stream("session-1", language_hint="ru") provider.push_pcm(stream_id, b"\x10\x00") provider.push_pcm(stream_id, b"\x20\x00") assert provider.poll_partial(stream_id) is None result = provider.finalize(stream_id) provider.close_stream(stream_id) assert result.text == "need schedule" assert calls == [{"audio_bytes": b"\x10\x00\x20\x00", "language_hint": "ru"}] def test_elevenlabs_realtime_streaming_provider_returns_partial_and_final(): calls: list[dict] = [] class _FakeRealtimeWebSocket: def __init__(self) -> None: self.sent_payloads: list[dict] = [] self.incoming: queue.Queue[str | None] = queue.Queue() self.incoming.put( json.dumps( { "message_type": "session_started", "session_id": "session-test", "config": {"sample_rate": 16000, "audio_format": "pcm_16000"}, } ) ) def send(self, raw_payload: str) -> None: payload = json.loads(raw_payload) self.sent_payloads.append(payload) if payload.get("commit"): self.incoming.put( json.dumps( { "message_type": "committed_transcript", "text": "нужно узнать график работы", "language_code": "ru", } ) ) else: self.incoming.put( json.dumps( { "message_type": "partial_transcript", "text": "нужно узнать график", "language_code": "ru", } ) ) def recv(self) -> str: item = self.incoming.get(timeout=1) if item is None: raise RuntimeError("closed") return item def close(self) -> None: self.incoming.put(None) websocket = _FakeRealtimeWebSocket() def _factory(url: str, *, header: list[str], timeout: float): calls.append({"url": url, "header": header, "timeout": timeout}) return websocket provider = asr_module.ElevenLabsRealtimeStreamingASRProvider( api_base="https://api.elevenlabs.example", api_key="asr-key", timeout_seconds=3, finalize_timeout_seconds=1, websocket_factory=_factory, ) stream_id = provider.open_stream("session-1", language_hint="ru") provider.push_pcm(stream_id, b"\x01\x00" * 160) partial = None for _ in range(20): partial = provider.poll_partial(stream_id) if partial is not None: break time.sleep(0.02) assert partial is not None assert partial.text == "нужно узнать график" assert partial.is_final is False final = provider.finalize(stream_id) provider.close_stream(stream_id) assert final.text == "нужно узнать график работы" assert final.language == "ru" assert calls[0]["url"].startswith("wss://api.elevenlabs.example/v1/speech-to-text/realtime?") assert "model_id=scribe_v2_realtime" in calls[0]["url"] assert "audio_format=pcm_16000" in calls[0]["url"] assert "language_code=ru" in calls[0]["url"] assert calls[0]["header"] == ["xi-api-key: asr-key"] assert calls[0]["timeout"] == 3.0 assert len(websocket.sent_payloads) == 2 assert websocket.sent_payloads[0]["message_type"] == "input_audio_chunk" assert websocket.sent_payloads[0]["commit"] is False assert websocket.sent_payloads[0]["sample_rate"] == 16000 assert websocket.sent_payloads[0]["audio_base_64"] assert websocket.sent_payloads[1]["commit"] is True def test_yandex_grpc_streaming_provider_returns_partial_and_final(): calls: list[dict] = [] class _Message: def __init__(self, **kwargs) -> None: self.__dict__.update(kwargs) def HasField(self, name: str) -> bool: return getattr(self, name, None) is not None class _RawAudio(_Message): LINEAR16_PCM = 1 class _TextNormalizationOptions(_Message): TEXT_NORMALIZATION_ENABLED = 1 PHONE_FORMATTING_MODE_DISABLED = 1 class _LanguageRestrictionOptions(_Message): WHITELIST = 1 class _RecognitionModelOptions(_Message): REAL_TIME = 1 class _DefaultEouClassifier(_Message): HIGH = 2 class _FakeSttPb2: RawAudio = _RawAudio AudioFormatOptions = _Message TextNormalizationOptions = _TextNormalizationOptions LanguageRestrictionOptions = _LanguageRestrictionOptions RecognitionModelOptions = _RecognitionModelOptions DefaultEouClassifier = _DefaultEouClassifier EouClassifierOptions = _Message StreamingOptions = _Message StreamingRequest = _Message AudioChunk = _Message Eou = _Message class _FakeChannel: def close(self) -> None: calls.append({"method": "close"}) class _FakeGrpc: @staticmethod def ssl_channel_credentials(): return "ssl" @staticmethod def secure_channel(target: str, credentials): calls.append({"method": "secure_channel", "target": target, "credentials": credentials}) return _FakeChannel() class _RecognizerStub: def __init__(self, channel) -> None: calls.append({"method": "stub_init", "channel": channel}) def RecognizeStreaming(self, request_iterator, *, metadata, timeout): calls.append({"method": "recognize", "metadata": metadata, "timeout": timeout}) for request in request_iterator: calls.append({"method": "request", "request": request}) if getattr(request, "chunk", None) is not None: yield _Message( partial=_Message( alternatives=[ _Message(text="need schedule", confidence=0.7, languages=[]), ] ) ) continue if getattr(request, "eou", None) is not None: yield _Message( final_refinement=_Message( normalized_text=_Message( alternatives=[ _Message(text="need schedule in almaty", confidence=0.91, languages=[]), ] ) ) ) return class _FakeGrpcPb2: RecognizerStub = _RecognizerStub provider = asr_module.YandexSpeechKitGrpcStreamingASRProvider( api_key="asr-key", grpc_target="stt.test:443", timeout_seconds=3, grpc_module=_FakeGrpc, stt_pb2_module=_FakeSttPb2, stt_service_pb2_grpc_module=_FakeGrpcPb2, ) stream_id = provider.open_stream("session-1", language_hint="ru") provider.push_pcm(stream_id, b"\x10\x00\x20\x00") partial = None for _ in range(20): partial = provider.poll_partial(stream_id) if partial is not None: break time.sleep(0.02) assert partial is not None assert partial.text == "need schedule" assert partial.is_final is False final = provider.finalize(stream_id) provider.close_stream(stream_id) assert final.text == "need schedule in almaty" recognize_call = next(call for call in calls if call.get("method") == "recognize") assert ("authorization", "Api-Key asr-key") in recognize_call["metadata"] requests = [call["request"] for call in calls if call.get("method") == "request"] assert getattr(requests[0], "session_options", None) is not None assert getattr(requests[1], "chunk", None).data == b"\x10\x00\x20\x00" assert getattr(requests[-1], "eou", None) is not None def test_yandex_asr_builders(): assert isinstance(asr_module.build_asr_provider("elevenlabs"), asr_module.ElevenLabsASRProvider) assert isinstance(asr_module.build_asr_provider("yandex"), asr_module.YandexSpeechKitASRProvider) assert isinstance(asr_module.build_asr_provider("speechkit"), asr_module.YandexSpeechKitASRProvider) assert isinstance( asr_module.build_streaming_asr_provider("elevenlabs_realtime"), asr_module.ElevenLabsRealtimeStreamingASRProvider, ) assert isinstance( asr_module.build_streaming_asr_provider("yandex_speechkit"), asr_module.YandexSpeechKitGrpcStreamingASRProvider, ) assert isinstance( asr_module.build_streaming_asr_provider("yandex_speechkit_buffered"), asr_module.YandexSpeechKitBufferedStreamingASRProvider, )