"""Deterministic tests for voice preset, TTS payload, and EOT helpers.""" from __future__ import annotations import asyncio import json import unittest from pathlib import Path from tempfile import TemporaryDirectory from urllib.parse import parse_qs, urlsplit from .services.voice import ( DEFAULT_OPENAI_VOICE, EOT_SILENCE_THRESHOLD_MS, TTS_ENDPOINT, TTS_MODEL, TTS_MODEL_FALLBACK, HIGGS_TTS_ENDPOINT, HIGGS_TTS_MODEL, DEEPGRAM_STT_MODEL, DeepgramStreamingSession, LocalWhisperStreamingSession, VoicePreset, VoiceService, VoiceUnavailable, assess_end_of_turn, build_higgs_prompt, build_tts_payload, resolve_voice, resolve_voice_from_map, ) class VoicePresetResolutionTest(unittest.TestCase): def test_persona_codes_resolve_distinct_voice_presets(self) -> None: expected = { "P1": ("soft-young-fem", "coral", 0.96), "P2": ("calm-adult-male", "ash", 1.0), "P3": ("warm-adult-fem", "shimmer", 0.98), } resolved = {code: resolve_voice(persona_code=code) for code in expected} self.assertEqual( {voice.openai_voice for voice in resolved.values()}, {"coral", "ash", "shimmer"}, ) for code, (preset, openai_voice, rate) in expected.items(): with self.subTest(code=code): voice = resolved[code] self.assertEqual(voice.preset, preset) self.assertEqual(voice.openai_voice, openai_voice) self.assertAlmostEqual(voice.rate, rate) self.assertEqual(resolve_voice(persona_code="p2").preset, "calm-adult-male") def test_invalid_explicit_preset_falls_back_to_default_openai_voice(self) -> None: voice = resolve_voice(persona_code="P1", preset="not-a-real-preset") self.assertEqual(voice.preset, "not-a-real-preset") self.assertEqual(voice.openai_voice, DEFAULT_OPENAI_VOICE) self.assertEqual(voice.rate, 1.0) def test_openai_persona_voice_map_overrides_live_tts_fields(self) -> None: voice = resolve_voice_from_map( provider="openai", voice_id="voice-p1-custom", persona_code="P1", base_params={ "preset": "soft-young-fem", "openai_voice": "nova", "rate": 1.14, "instructions": "Keep the voice quiet and hesitant.", }, ) self.assertIsNotNone(voice) assert voice is not None self.assertEqual(voice.preset, "soft-young-fem") self.assertEqual(voice.openai_voice, "nova") self.assertAlmostEqual(voice.rate, 1.14) self.assertEqual(voice.instructions, "Keep the voice quiet and hesitant.") def test_openai_persona_voice_map_can_use_voice_id_as_openai_voice(self) -> None: voice = resolve_voice_from_map( provider="openai", voice_id="verse", persona_code="P2", base_params={"preset": "calm-adult-male"}, ) self.assertIsNotNone(voice) assert voice is not None self.assertEqual(voice.preset, "calm-adult-male") self.assertEqual(voice.openai_voice, "verse") def test_non_openai_persona_voice_map_returns_none_for_safe_fallback(self) -> None: self.assertIsNone( resolve_voice_from_map( provider="higgs", voice_id="p1-synthetic", persona_code="P1", base_params={"openai_voice": "coral"}, ) ) class TTSPayloadTest(unittest.TestCase): def test_higgs_prompt_keeps_first_word_before_emotion_tags(self) -> None: voice = VoicePreset(preset="soft-young-fem", openai_voice="coral", rate=0.96) prompt = build_higgs_prompt("그냥 학교 가도 아무 의미 없는 것 같아요.", voice) self.assertTrue(prompt.startswith("그냥 ")) self.assertIn("<|emotion:helplessness|>", prompt) self.assertIn("<|prosody:speed_slow|>", prompt) self.assertNotIn("<|emotion:helplessness|> ", prompt) def test_payload_contains_openai_tts_fields_and_clamps_high_speed(self) -> None: voice = VoicePreset( preset="soft-young-fem", openai_voice="coral", rate=9.5, instructions="Speak gently with low intensity.", ) payload = build_tts_payload("Client reply", voice) self.assertEqual( payload, { "model": TTS_MODEL, "voice": "coral", "input": "Client reply", "response_format": "mp3", "speed": 4.0, "instructions": "Speak gently with low intensity.", }, ) def test_payload_omits_instructions_for_fallback_model_and_clamps_low_speed(self) -> None: voice = VoicePreset( preset="neutral", openai_voice="sage", rate=0.1, instructions="This should not be sent to tts-1.", ) payload = build_tts_payload( "Fallback reply", voice, model=TTS_MODEL_FALLBACK, response_format="opus", ) self.assertEqual(payload["model"], TTS_MODEL_FALLBACK) self.assertEqual(payload["voice"], "sage") self.assertEqual(payload["input"], "Fallback reply") self.assertEqual(payload["response_format"], "opus") self.assertEqual(payload["speed"], 0.25) self.assertNotIn("instructions", payload) def test_payload_uses_default_speed_for_invalid_rate(self) -> None: voice = VoicePreset( preset="neutral", openai_voice="sage", rate="fast", # type: ignore[arg-type] ) payload = build_tts_payload("Client reply", voice) self.assertEqual(payload["speed"], 1.0) class _FakeTTSStream: def __init__(self, chunks: list[bytes]) -> None: self.status_code = 200 self._chunks = chunks self.closed = False async def __aenter__(self) -> "_FakeTTSStream": return self async def __aexit__(self, exc_type, exc, tb) -> bool: # noqa: ANN001 return False async def aclose(self) -> None: self.closed = True def raise_for_status(self) -> None: return None async def aiter_bytes(self, chunk_size: int = 4096): self.chunk_size = chunk_size for chunk in self._chunks: yield chunk class _CaptureTTSClient: def __init__(self, chunks: list[bytes]) -> None: self.chunks = chunks self.calls: list[tuple[str, str, dict[str, object]]] = [] def stream(self, method: str, endpoint: str, *, json: dict[str, object]) -> _FakeTTSStream: self.calls.append((method, endpoint, dict(json))) return _FakeTTSStream(self.chunks) class VoiceServiceStreamTest(unittest.IsolatedAsyncioTestCase): async def test_synthesize_stream_uses_payload_with_fake_client(self) -> None: client = _CaptureTTSClient([b"\x80\x80", b"\xff\x00"]) service = VoiceService(api_key="test-key") service._client = client # type: ignore[assignment] voice = VoicePreset( preset="neutral", openai_voice="sage", rate=0.1, instructions="Keep the tone grounded.", ) chunks = [ chunk async for chunk in service.synthesize_stream( "Spoken client reply", voice, response_format="opus", ) ] self.assertEqual(len(client.calls), 1) method, endpoint, payload = client.calls[0] self.assertEqual(method, "POST") self.assertEqual(endpoint, TTS_ENDPOINT) self.assertEqual(payload["model"], TTS_MODEL) self.assertEqual(payload["voice"], "sage") self.assertEqual(payload["input"], "Spoken client reply") self.assertEqual(payload["response_format"], "opus") self.assertEqual(payload["speed"], 0.25) self.assertEqual(payload["instructions"], "Keep the tone grounded.") self.assertEqual([chunk.audio for chunk in chunks], [b"\x80\x80", b"\xff\x00"]) async def test_higgs_tts_uses_local_synthetic_voice_server_in_dev(self) -> None: client = _CaptureTTSClient([b"RIFF", b"synthetic-wav"]) service = VoiceService( api_key="", environment="dev", tts_provider="higgs", higgs_base_url="http://127.0.0.1:9881", ) service._higgs_client = client # type: ignore[assignment] voice = VoicePreset(preset="soft-young-fem", openai_voice="coral") chunks = [ chunk async for chunk in service.synthesize_stream( "엄마한테 말하지 않는 거죠?", voice, ) ] self.assertFalse(service.stt_available()) self.assertTrue(service.tts_available(voice)) self.assertEqual(service.tts_provider_for_voice(voice), "higgs") self.assertEqual(service.tts_model_for_voice(voice), HIGGS_TTS_MODEL) self.assertEqual(service.tts_media_type_for_voice(voice), "audio/wav") self.assertEqual(len(client.calls), 1) method, endpoint, payload = client.calls[0] self.assertEqual((method, endpoint), ("POST", HIGGS_TTS_ENDPOINT)) self.assertIn("<|emotion:fear|>", str(payload["text"])) self.assertEqual(payload["preset"], "soft-young-fem") self.assertEqual(b"".join(chunk.audio for chunk in chunks), b"RIFFsynthetic-wav") async def test_higgs_tts_is_fail_closed_outside_dev(self) -> None: service = VoiceService( api_key="", environment="prod", tts_provider="higgs", ) voice = VoicePreset(preset="soft-young-fem", openai_voice="coral") self.assertFalse(service.tts_available(voice)) self.assertEqual(service.tts_provider(), "disabled-non-dev") async def test_dev_p1_sample_tts_streams_local_mp3_without_openai_key(self) -> None: with TemporaryDirectory() as tmp: sample_dir = Path(tmp) default_audio = b"default-mp3" anxious_audio = (b"anxious-mp3-" * 500) (sample_dir / "p1_seoyeon_01_depressed_slow.mp3").write_bytes(default_audio) (sample_dir / "p1_seoyeon_03_anxious_guarded.mp3").write_bytes(anxious_audio) service = VoiceService( api_key="", poc_sample_tts_enabled=True, environment="dev", poc_sample_tts_dir=sample_dir, ) voice = VoicePreset(preset="soft-young-fem", openai_voice="coral") chunks = [ chunk async for chunk in service.synthesize_stream( "엄마한테 말하지 않는 거죠? 진짜 불안해요.", voice, ) ] self.assertFalse(service.is_available()) self.assertTrue(service.poc_sample_tts_available()) self.assertEqual(service.tts_provider(), "p1-sample-poc") self.assertEqual(b"".join(chunk.audio for chunk in chunks), anxious_audio) async def test_p1_sample_tts_is_disabled_outside_dev(self) -> None: with TemporaryDirectory() as tmp: sample_dir = Path(tmp) (sample_dir / "p1_seoyeon_01_depressed_slow.mp3").write_bytes(b"default-mp3") service = VoiceService( api_key="", poc_sample_tts_enabled=True, environment="prod", poc_sample_tts_dir=sample_dir, ) self.assertFalse(service.is_available()) self.assertFalse(service.poc_sample_tts_available()) self.assertEqual(service.tts_provider(), "disabled-non-dev") class _FakeDeepgramSocket: def __init__(self) -> None: self.sent: list[bytes | str] = [] self.closed = False self._frames: asyncio.Queue[str | None] = asyncio.Queue() self._interim_sent = False def __aiter__(self): return self async def __anext__(self) -> str: frame = await self._frames.get() if frame is None: raise StopAsyncIteration return frame async def send(self, value: bytes | str) -> None: self.sent.append(value) if isinstance(value, bytes) and not self._interim_sent: self._interim_sent = True await self._frames.put( json.dumps( { "type": "Results", "is_final": False, "speech_final": False, "start": 0.0, "duration": 0.3, "channel": { "alternatives": [ {"transcript": "안녕", "confidence": 0.72} ] }, } ) ) if isinstance(value, str) and json.loads(value).get("type") == "CloseStream": final_frames = [ { "type": "Results", "is_final": True, "speech_final": False, "start": 0.0, "duration": 0.45, "channel": { "alternatives": [ { "transcript": "안녕하세요", "confidence": 0.94, "words": [ { "word": "안녕하세요", "start": 0.0, "end": 0.45, "confidence": 0.94, } ], } ] }, }, { "type": "Results", "is_final": True, "speech_final": True, "start": 0.5, "duration": 0.4, "channel": { "alternatives": [ { "transcript": "반가워요", "confidence": 0.91, "words": [ { "word": "반가워요", "start": 0.5, "end": 0.9, "confidence": 0.91, } ], } ] }, }, {"type": "Metadata", "duration": 0.9}, ] for frame in final_frames: await self._frames.put(json.dumps(frame, ensure_ascii=False)) await self._frames.put(None) async def close(self, **_kwargs) -> None: self.closed = True await self._frames.put(None) class DeepgramStreamingSTTTest(unittest.IsolatedAsyncioTestCase): async def test_idle_stream_sends_text_keepalive_without_audio(self) -> None: socket = _FakeDeepgramSocket() async def on_event(_event) -> None: return None stream = DeepgramStreamingSession( socket, model="nova-3", language="ko", on_event=on_event, keepalive_seconds=0.01, finalize_timeout_seconds=1, ) for _ in range(20): if any( isinstance(item, str) and json.loads(item).get("type") == "KeepAlive" for item in socket.sent ): break await asyncio.sleep(0.01) await stream.abort() self.assertTrue( any( isinstance(item, str) and json.loads(item).get("type") == "KeepAlive" for item in socket.sent ) ) async def test_streaming_listen_uses_token_header_and_accumulates_final_segments( self, ) -> None: socket = _FakeDeepgramSocket() connect_calls: list[tuple[str, dict[str, object]]] = [] updates = [] async def connect(url: str, **kwargs): connect_calls.append((url, kwargs)) return socket async def on_event(event) -> None: updates.append(event) service = VoiceService( api_key="openai-fallback-key", stt_provider="deepgram", deepgram_api_key="deepgram-secret", deepgram_connect=connect, deepgram_finalize_timeout_seconds=2, ) stream = await service.open_streaming_transcription( fmt="pcm", sample_rate=16000, channels=1, sample_width=2, on_event=on_event, ) await stream.send_audio(b"\x00\x00\xff\x7f") await asyncio.sleep(0) result = await stream.finish() self.assertEqual(len(connect_calls), 1) url, kwargs = connect_calls[0] query = parse_qs(urlsplit(url).query) self.assertEqual(urlsplit(url).scheme, "wss") self.assertEqual(query["model"], [DEEPGRAM_STT_MODEL]) self.assertEqual(query["language"], ["ko"]) self.assertEqual(query["encoding"], ["linear16"]) self.assertEqual(query["sample_rate"], ["16000"]) self.assertEqual(query["channels"], ["1"]) self.assertEqual(query["interim_results"], ["true"]) self.assertEqual(query["endpointing"], ["300"]) self.assertEqual(query["utterance_end_ms"], ["1200"]) self.assertEqual(query["mip_opt_out"], ["true"]) self.assertNotIn("deepgram-secret", url) self.assertEqual( kwargs["additional_headers"], {"Authorization": "Token deepgram-secret"}, ) self.assertEqual(kwargs["max_queue"], 16) self.assertEqual(kwargs["write_limit"], 64 * 1024) self.assertEqual(result.text, "안녕하세요 반가워요") self.assertEqual(result.language, "ko") self.assertEqual(result.model, DEEPGRAM_STT_MODEL) self.assertAlmostEqual(result.duration or 0.0, 0.9) self.assertEqual([word.word for word in result.words], ["안녕하세요", "반가워요"]) self.assertTrue(any(not event.final for event in updates)) self.assertTrue(any(event.final and event.speech_final for event in updates)) self.assertEqual(socket.sent[0], b"\x00\x00\xff\x7f") self.assertEqual(json.loads(str(socket.sent[-1])), {"type": "CloseStream"}) self.assertFalse( any("word" in event or "text" in event for event in result.provider_events) ) async def test_selected_deepgram_without_key_keeps_openai_batch_fallback(self) -> None: service = VoiceService( api_key="openai-fallback-key", stt_provider="deepgram", deepgram_api_key="", ) self.assertFalse(service.streaming_stt_enabled()) self.assertTrue(service.batch_stt_available()) self.assertTrue(service.stt_available()) self.assertEqual(service.stt_provider(), "openai-batch-fallback") self.assertEqual(service.stt_model(), "gpt-4o-transcribe") class EndOfTurnDecisionTest(unittest.TestCase): def test_end_of_turn_requires_silence_threshold(self) -> None: pending = assess_end_of_turn( transcript_text="I am still talking", transcript_final=True, silence_ms=EOT_SILENCE_THRESHOLD_MS - 1, ) ready = assess_end_of_turn( transcript_text="I am done", transcript_final=True, silence_ms=EOT_SILENCE_THRESHOLD_MS, ) self.assertFalse(pending.ready) self.assertTrue(pending.transcript_ready) self.assertFalse(pending.silence_ready) self.assertEqual(pending.reason, "silence_threshold_pending") self.assertTrue(ready.ready) self.assertEqual(ready.reason, "ready") def test_end_of_turn_waits_for_final_transcript_even_after_silence(self) -> None: decision = assess_end_of_turn( transcript_text="Interim transcript", transcript_final=False, silence_ms=2500, ) self.assertFalse(decision.ready) self.assertFalse(decision.transcript_ready) self.assertTrue(decision.silence_ready) self.assertEqual(decision.reason, "final_transcript_pending") def test_end_of_turn_rejects_empty_final_transcript(self) -> None: decision = assess_end_of_turn( transcript_text=" ", transcript_final=True, silence_ms=2500, ) self.assertFalse(decision.ready) self.assertFalse(decision.transcript_ready) self.assertTrue(decision.silence_ready) self.assertEqual(decision.reason, "empty_transcript") class _FakeLocalWhisperSocket: """Loopback faster-whisper sidecar stand-in speaking the local protocol.""" def __init__(self) -> None: self.sent: list[bytes | str] = [] self.closed = False self._frames: asyncio.Queue[str | None] = asyncio.Queue() self._interim_sent = False def __aiter__(self): return self async def __anext__(self) -> str: frame = await self._frames.get() if frame is None: raise StopAsyncIteration return frame async def send(self, value: bytes | str) -> None: self.sent.append(value) if isinstance(value, bytes) and not self._interim_sent: self._interim_sent = True await self._frames.put( json.dumps( { "type": "transcript", "text": "안녕", "is_final": False, "speech_final": False, "confidence": 0.72, "duration": 0.3, "words": [], }, ensure_ascii=False, ) ) if isinstance(value, str) and json.loads(value).get("type") == "CloseStream": await self._frames.put( json.dumps( { "type": "transcript", "text": "안녕하세요", "is_final": True, "speech_final": True, "confidence": 0.91, "duration": 0.45, "words": [ { "word": "안녕하세요", "start": 0.05, "end": 0.42, "confidence": 0.93, } ], }, ensure_ascii=False, ) ) await self._frames.put(None) async def close(self, code: int | None = None, reason: str | None = None) -> None: self.closed = True await self._frames.put(None) class _FakeLocalWhisperErrorSocket(_FakeLocalWhisperSocket): async def send(self, value: bytes | str) -> None: self.sent.append(value) await self._frames.put( json.dumps({"type": "error", "detail": "faster_whisper_unavailable"}) ) await self._frames.put(None) class LocalWhisperStreamingTest(unittest.IsolatedAsyncioTestCase): def _service(self, connect, **overrides) -> VoiceService: options: dict[str, object] = { "api_key": "openai-fallback-key", "stt_provider": "local_whisper", "local_whisper_connect": connect, "local_whisper_finalize_timeout_seconds": 2, } options.update(overrides) return VoiceService(**options) async def test_local_whisper_streams_interim_then_final_with_words(self) -> None: socket = _FakeLocalWhisperSocket() connect_calls: list[tuple[str, dict[str, object]]] = [] updates = [] async def connect(url: str, **kwargs): connect_calls.append((url, kwargs)) return socket async def on_event(event) -> None: updates.append(event) service = self._service(connect) stream = await service.open_streaming_transcription( fmt="pcm", sample_rate=16000, channels=1, sample_width=2, on_event=on_event, ) self.assertIsInstance(stream, LocalWhisperStreamingSession) await stream.send_audio(b"\x00\x00\xff\x7f") await asyncio.sleep(0) result = await stream.finish() self.assertEqual(len(connect_calls), 1) url, _kwargs = connect_calls[0] query = parse_qs(urlsplit(url).query) self.assertEqual(urlsplit(url).scheme, "ws") self.assertEqual(urlsplit(url).hostname, "127.0.0.1") self.assertEqual(query["model"], ["large-v3"]) self.assertEqual(query["language"], ["ko"]) self.assertEqual(query["sample_rate"], ["16000"]) self.assertEqual(query["channels"], ["1"]) self.assertEqual(query["endpointing"], ["300"]) self.assertEqual(query["utterance_end_ms"], ["1200"]) self.assertEqual(result.text, "안녕하세요") self.assertEqual(result.model, "large-v3") self.assertEqual(result.language, "ko") self.assertEqual([word.word for word in result.words], ["안녕하세요"]) self.assertAlmostEqual(result.words[0].start, 0.05, places=3) providers = {event["provider"] for event in result.provider_events} self.assertEqual(providers, {"local_whisper"}) self.assertIn("stt_word", {event["type"] for event in result.provider_events}) self.assertTrue(updates) self.assertFalse(updates[0].final) self.assertTrue(updates[-1].final) self.assertTrue(updates[-1].speech_final) async def test_second_utterance_word_timestamps_advance_by_the_first(self) -> None: """Interim frames share the utterance clock; only finals advance it.""" frames = [ { "type": "transcript", "text": "첫째", "is_final": False, "speech_final": False, "duration": 0.3, "words": [], }, { "type": "transcript", "text": "첫째 발화", "is_final": True, "speech_final": True, "duration": 1.0, "words": [{"word": "첫째", "start": 0.1, "end": 0.5}], }, { "type": "transcript", "text": "둘째 발화", "is_final": True, "speech_final": True, "duration": 0.8, "words": [{"word": "둘째", "start": 0.2, "end": 0.6}], }, ] class _ScriptedSocket(_FakeLocalWhisperSocket): async def send(self, value: bytes | str) -> None: self.sent.append(value) if isinstance(value, str): for frame in frames: await self._frames.put( json.dumps(frame, ensure_ascii=False) ) await self._frames.put(None) socket = _ScriptedSocket() async def connect(url: str, **kwargs): return socket async def on_event(event) -> None: return None service = self._service(connect) stream = await service.open_streaming_transcription( fmt="pcm", sample_rate=16000, channels=1, sample_width=2, on_event=on_event, ) result = await stream.finish() self.assertEqual(result.text, "첫째 발화 둘째 발화") self.assertEqual([word.word for word in result.words], ["첫째", "둘째"]) # 첫 발화 word 는 0 기준, 둘째 발화 word 는 첫 발화 길이(1.0)만큼 밀린다. self.assertAlmostEqual(result.words[0].start, 0.1, places=3) self.assertAlmostEqual(result.words[1].start, 1.2, places=3) self.assertAlmostEqual(result.duration or 0.0, 1.8, places=3) async def test_local_whisper_never_sends_an_authorization_header(self) -> None: socket = _FakeLocalWhisperSocket() captured: list[dict[str, object]] = [] async def connect(url: str, **kwargs): captured.append(kwargs) return socket async def on_event(event) -> None: return None service = self._service(connect) stream = await service.open_streaming_transcription( fmt="pcm", sample_rate=16000, channels=1, sample_width=2, on_event=on_event, ) await stream.abort() self.assertEqual(len(captured), 1) self.assertNotIn("additional_headers", captured[0]) async def test_sidecar_error_frame_fails_closed(self) -> None: socket = _FakeLocalWhisperErrorSocket() async def connect(url: str, **kwargs): return socket async def on_event(event) -> None: return None service = self._service(connect) stream = await service.open_streaming_transcription( fmt="pcm", sample_rate=16000, channels=1, sample_width=2, on_event=on_event, ) await stream.send_audio(b"\x00\x00") await asyncio.sleep(0) with self.assertRaises(RuntimeError): await stream.finish() async def test_connection_failure_is_wrapped(self) -> None: async def connect(url: str, **kwargs): raise OSError("refused") async def on_event(event) -> None: return None service = self._service(connect) with self.assertRaises(RuntimeError) as ctx: await service.open_streaming_transcription( fmt="pcm", sample_rate=16000, channels=1, sample_width=2, on_event=on_event, ) self.assertIn("Local whisper", str(ctx.exception)) async def test_non_pcm_metadata_is_rejected_before_connecting(self) -> None: connected = False async def connect(url: str, **kwargs): nonlocal connected connected = True return _FakeLocalWhisperSocket() async def on_event(event) -> None: return None service = self._service(connect) with self.assertRaises(VoiceUnavailable): await service.open_streaming_transcription( fmt="webm", sample_rate=None, channels=None, sample_width=None, on_event=on_event, ) self.assertFalse(connected) class LocalWhisperProviderSelectionTest(unittest.TestCase): def test_local_whisper_needs_no_api_key(self) -> None: service = VoiceService(api_key="", stt_provider="local_whisper") self.assertTrue(service.streaming_stt_enabled()) self.assertEqual(service.stt_provider(), "local_whisper") self.assertEqual(service.stt_model(), "large-v3") def test_missing_sidecar_url_falls_back_to_batch(self) -> None: service = VoiceService( api_key="openai-key", stt_provider="local_whisper", local_whisper_stt_url="", ) self.assertFalse(service.streaming_stt_enabled()) self.assertEqual(service.stt_provider(), "openai-batch-fallback") def test_missing_sidecar_and_no_openai_key_is_unavailable(self) -> None: service = VoiceService( api_key="", stt_provider="local_whisper", local_whisper_stt_url="", ) self.assertEqual(service.stt_provider(), "unavailable") self.assertFalse(service.stt_available()) def test_deepgram_selection_is_untouched(self) -> None: service = VoiceService( api_key="openai-key", stt_provider="deepgram", deepgram_api_key="secret-value", ) self.assertTrue(service.streaming_stt_enabled()) self.assertEqual(service.stt_provider(), "deepgram") if __name__ == "__main__": unittest.main()