vignette/apps/api/app/test_voice_service.py
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

903 lines
32 KiB
Python

"""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()