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 산출물은 커밋에서 제외했다.
This commit is contained in:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

@ -1,7 +1,7 @@
"""음성 캐스케이드 — OpenAI STT + OpenAI/Higgs TTS 어댑터.
"""음성 캐스케이드 — Deepgram/OpenAI STT + OpenAI/Higgs TTS 어댑터.
MASTERPLAN '음성 필수'(한신대 요구) / DESIGN_CONCEPT §5.2(음성 오브 4상태) / §4.3(립싱크 RMS):
STT : OpenAI /v1/audio/transcriptions (gpt-4o-transcribe | whisper-1). 학습자 음성 텍스트.
STT : Deepgram 실시간 WebSocket 또는 OpenAI 배치 전사. 학습자 음성 텍스트.
TTS : OpenAI /v1/audio/speech 또는 로컬 Higgs v3. 내담자 텍스트 음성.
설계 원칙( 모듈의 경계):
@ -18,14 +18,21 @@ PRESET_TO_OPENAI_VOICE 테이블이 흡수. 새 preset 추가는 이 테이블
from __future__ import annotations
import asyncio
import json
import re
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, AsyncIterator, Mapping, Optional
from urllib.parse import urlencode
import httpx
from websockets.asyncio.client import connect as websocket_connect
from ..config import settings
from .voice_runtime import voice_runtime_metrics
from ..paths import repo_root, repo_path
# ════════════════════════════════════════════════════════════════════════════
@ -35,6 +42,8 @@ OPENAI_BASE_URL = "https://api.openai.com/v1"
STT_ENDPOINT = "/audio/transcriptions"
TTS_ENDPOINT = "/audio/speech"
HIGGS_TTS_ENDPOINT = "/tts"
DEEPGRAM_STT_URL = "wss://api.deepgram.com/v1/listen"
DEEPGRAM_STT_MODEL = "nova-3"
# STT 모델: gpt-4o-transcribe(고품질) — 미가용 폴백은 whisper-1.
STT_MODEL = "gpt-4o-transcribe"
@ -159,6 +168,524 @@ class TranscriptResult:
model: str = STT_MODEL
duration: Optional[float] = None
provider_events: list[dict[str, object]] = field(default_factory=list)
words: list["TranscriptWord"] = field(default_factory=list)
@dataclass(frozen=True, slots=True)
class TranscriptWord:
"""Provider word timing kept in process until it is privacy-safe hashed."""
word: str
start: float
end: float
confidence: float | None = None
@dataclass(frozen=True, slots=True)
class StreamingTranscriptEvent:
"""Provider-neutral live transcript update emitted by streaming STT."""
text: str
final: bool
speech_final: bool
confidence: float | None = None
class DeepgramStreamingSession:
"""One Deepgram Listen WebSocket, scoped to exactly one learner utterance."""
def __init__(
self,
socket: Any,
*,
model: str,
language: str,
on_event: Callable[[StreamingTranscriptEvent], Awaitable[None]],
keepalive_seconds: float,
finalize_timeout_seconds: float,
) -> None:
self._socket = socket
self._model = model
self._language = language
self._on_event = on_event
self._keepalive_seconds = keepalive_seconds
self._finalize_timeout_seconds = finalize_timeout_seconds
self._send_lock = asyncio.Lock()
self._last_audio_sent_at = time.monotonic()
self._final_segments: list[str] = []
self._words: list[TranscriptWord] = []
self._provider_events: list[dict[str, object]] = []
self._duration: float | None = None
self._error: RuntimeError | None = None
self._finishing = False
self._closed = False
self._runtime_closed = False
voice_runtime_metrics.streaming_provider_opened()
self._receiver_task = asyncio.create_task(self._receive())
self._keepalive_task = asyncio.create_task(self._keepalive())
async def send_audio(self, audio: bytes) -> None:
if not audio:
return
if self._error is not None:
raise self._error
if self._closed or self._receiver_task.done():
if self._error is not None:
raise self._error
raise RuntimeError("Deepgram streaming STT connection closed")
try:
async with self._send_lock:
await self._socket.send(audio)
self._last_audio_sent_at = time.monotonic()
except Exception as exc:
raise RuntimeError("Deepgram streaming STT transport failed") from exc
async def finish(self) -> TranscriptResult:
"""Flush remaining audio with CloseStream and await final Results/Metadata."""
if self._closed:
if self._error is not None:
raise self._error
return self._result()
self._finishing = True
self._keepalive_task.cancel()
try:
async with self._send_lock:
await self._socket.send(json.dumps({"type": "CloseStream"}))
await asyncio.wait_for(
asyncio.shield(self._receiver_task),
timeout=self._finalize_timeout_seconds,
)
except TimeoutError as exc:
await self.abort()
raise RuntimeError("Deepgram streaming STT finalization timed out") from exc
except Exception as exc:
await self.abort()
if isinstance(exc, RuntimeError):
raise
raise RuntimeError("Deepgram streaming STT finalization failed") from exc
finally:
await self._cancel_keepalive()
self._closed = True
self._close_runtime_metrics(outcome="finalized")
if self._error is not None:
raise self._error
return self._result()
async def abort(self) -> None:
"""Close without asking the provider to process buffered audio."""
if self._closed:
return
self._closed = True
self._finishing = True
self._keepalive_task.cancel()
if not self._receiver_task.done():
self._receiver_task.cancel()
try:
await self._socket.close(code=1000, reason="utterance aborted")
except TypeError:
try:
await self._socket.close()
except Exception:
pass
except Exception:
pass
await self._cancel_keepalive()
if not self._receiver_task.done():
try:
await self._receiver_task
except (asyncio.CancelledError, Exception):
pass
self._close_runtime_metrics(outcome="aborted")
def _close_runtime_metrics(self, *, outcome: str) -> None:
if self._runtime_closed:
return
self._runtime_closed = True
voice_runtime_metrics.streaming_provider_closed(outcome=outcome)
async def _receive(self) -> None:
try:
async for raw in self._socket:
if not isinstance(raw, str):
continue
try:
payload = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(payload, dict):
continue
message_type = str(payload.get("type") or "")
if message_type == "Results":
await self._consume_results(payload)
elif message_type == "Metadata":
duration = _optional_float(payload.get("duration"))
if duration is not None and duration >= 0:
self._duration = max(self._duration or 0.0, duration)
elif message_type in {"Error", "Warning"}:
self._error = RuntimeError("Deepgram streaming STT provider failed")
return
except asyncio.CancelledError:
raise
except Exception as exc:
self._error = RuntimeError("Deepgram streaming STT receive failed")
self._error.__cause__ = exc
async def _consume_results(self, payload: dict[str, object]) -> None:
channel = payload.get("channel")
if not isinstance(channel, dict):
return
alternatives = channel.get("alternatives")
if not isinstance(alternatives, list) or not alternatives:
return
alternative = alternatives[0]
if not isinstance(alternative, dict):
return
transcript = str(alternative.get("transcript") or "").strip()
is_final = bool(payload.get("is_final"))
speech_final = bool(payload.get("speech_final"))
confidence = _optional_float(alternative.get("confidence"))
start = max(0.0, _optional_float(payload.get("start")) or 0.0)
duration = max(0.0, _optional_float(payload.get("duration")) or 0.0)
if duration:
self._duration = max(self._duration or 0.0, start + duration)
if is_final and transcript:
self._final_segments.append(transcript)
self._consume_final_words(alternative.get("words"))
display_parts = list(self._final_segments)
if transcript and not is_final:
display_parts.append(transcript)
display_text = " ".join(part for part in display_parts if part).strip()
if not display_text and not speech_final:
return
event_type = "speech_final" if speech_final else (
"speech_end" if is_final else "voice_activity"
)
provider_event: dict[str, object] = {
"type": event_type,
"provider": "deepgram",
"source": "streaming_stt",
"start_ms": round(start * 1000),
"duration_ms": round(duration * 1000),
"is_final": is_final,
}
if confidence is not None:
provider_event["confidence"] = confidence
if is_final or speech_final:
self._provider_events.append(provider_event)
await self._on_event(
StreamingTranscriptEvent(
text=display_text,
final=is_final,
speech_final=speech_final,
confidence=confidence,
)
)
def _consume_final_words(self, value: object) -> None:
if not isinstance(value, list):
return
for item in value:
if not isinstance(item, dict):
continue
word = str(item.get("punctuated_word") or item.get("word") or "").strip()
start = _optional_float(item.get("start"))
end = _optional_float(item.get("end"))
if not word or start is None or end is None or end <= start:
continue
confidence = _optional_float(item.get("confidence"))
self._words.append(
TranscriptWord(
word=word,
start=max(0.0, start),
end=max(0.0, end),
confidence=confidence,
)
)
timing_event: dict[str, object] = {
"type": "stt_word",
"provider": "deepgram",
"source": "stt_word_timestamps",
"start_ms": round(start * 1000),
"end_ms": round(end * 1000),
"is_final": True,
}
if confidence is not None:
timing_event["confidence"] = confidence
self._provider_events.append(timing_event)
self._duration = max(self._duration or 0.0, end)
async def _keepalive(self) -> None:
try:
while not self._finishing and not self._closed:
await asyncio.sleep(self._keepalive_seconds)
idle_for = time.monotonic() - self._last_audio_sent_at
if idle_for < self._keepalive_seconds:
continue
async with self._send_lock:
await self._socket.send(json.dumps({"type": "KeepAlive"}))
except asyncio.CancelledError:
return
except Exception as exc:
if not self._finishing:
self._error = RuntimeError("Deepgram streaming STT keepalive failed")
self._error.__cause__ = exc
async def _cancel_keepalive(self) -> None:
if self._keepalive_task.done():
return
self._keepalive_task.cancel()
try:
await self._keepalive_task
except asyncio.CancelledError:
pass
def _result(self) -> TranscriptResult:
return TranscriptResult(
text=" ".join(self._final_segments).strip(),
language=self._language,
model=self._model,
duration=self._duration,
provider_events=list(self._provider_events),
words=list(self._words),
)
class LocalWhisperStreamingSession:
"""One loopback faster-whisper stream, scoped to exactly one learner utterance.
Same public surface as the Deepgram session so the WebSocket route does not
branch on provider. Audio never leaves the host: the sidecar keeps only an
in-memory utterance buffer and drops it when the utterance ends.
"""
def __init__(
self,
socket: Any,
*,
model: str,
language: str,
on_event: Callable[[StreamingTranscriptEvent], Awaitable[None]],
finalize_timeout_seconds: float,
) -> None:
self._socket = socket
self._model = model
self._language = language
self._on_event = on_event
self._finalize_timeout_seconds = finalize_timeout_seconds
self._send_lock = asyncio.Lock()
self._final_segments: list[str] = []
self._words: list[TranscriptWord] = []
self._provider_events: list[dict[str, object]] = []
self._duration: float | None = None
# 확정된 발화들의 누적 길이. interim 은 같은 발화라 오프셋을 밀지 않는다.
self._utterance_offset = 0.0
self._error: RuntimeError | None = None
self._finishing = False
self._closed = False
self._runtime_closed = False
voice_runtime_metrics.streaming_provider_opened()
self._receiver_task = asyncio.create_task(self._receive())
async def send_audio(self, audio: bytes) -> None:
if not audio:
return
if self._error is not None:
raise self._error
if self._closed or self._receiver_task.done():
if self._error is not None:
raise self._error
raise RuntimeError("Local whisper streaming STT connection closed")
try:
async with self._send_lock:
await self._socket.send(audio)
except Exception as exc:
self._error = RuntimeError("Local whisper streaming STT transport failed")
self._error.__cause__ = exc
raise self._error from exc
async def finish(self) -> TranscriptResult:
if self._closed:
if self._error is not None:
raise self._error
return self._result()
self._finishing = True
try:
async with self._send_lock:
await self._socket.send(json.dumps({"type": "CloseStream"}))
await asyncio.wait_for(
asyncio.shield(self._receiver_task),
timeout=self._finalize_timeout_seconds,
)
except TimeoutError as exc:
await self.abort()
raise RuntimeError(
"Local whisper streaming STT finalization timed out"
) from exc
except Exception as exc:
await self.abort()
if isinstance(exc, RuntimeError):
raise
raise RuntimeError(
"Local whisper streaming STT finalization failed"
) from exc
self._closed = True
self._close_runtime_metrics(outcome="finalized")
if self._error is not None:
raise self._error
return self._result()
async def abort(self) -> None:
if self._closed:
return
self._closed = True
self._finishing = True
if not self._receiver_task.done():
self._receiver_task.cancel()
try:
await self._socket.close(code=1000, reason="utterance aborted")
except TypeError:
try:
await self._socket.close()
except Exception:
pass
except Exception:
pass
if not self._receiver_task.done():
try:
await self._receiver_task
except (asyncio.CancelledError, Exception):
pass
self._close_runtime_metrics(outcome="aborted")
def _close_runtime_metrics(self, *, outcome: str) -> None:
if self._runtime_closed:
return
self._runtime_closed = True
voice_runtime_metrics.streaming_provider_closed(outcome=outcome)
async def _receive(self) -> None:
try:
async for message in self._socket:
if isinstance(message, (bytes, bytearray)):
continue
try:
payload = json.loads(message)
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
kind = str(payload.get("type") or "")
if kind == "error":
self._error = RuntimeError(
"Local whisper streaming STT provider failed"
)
return
if kind == "transcript":
await self._consume_transcript(payload)
except asyncio.CancelledError:
raise
except Exception as exc:
if not self._finishing:
self._error = RuntimeError("Local whisper streaming STT receive failed")
self._error.__cause__ = exc
async def _consume_transcript(self, payload: dict[str, object]) -> None:
transcript = str(payload.get("text") or "").strip()
is_final = bool(payload.get("is_final"))
speech_final = bool(payload.get("speech_final"))
confidence = _optional_float(payload.get("confidence"))
duration = max(0.0, _optional_float(payload.get("duration")) or 0.0)
start = self._utterance_offset
if duration:
self._duration = max(self._duration or 0.0, start + duration)
if is_final and transcript:
self._final_segments.append(transcript)
self._consume_final_words(payload.get("words"), offset=start)
if is_final:
# 다음 발화는 이 발화가 끝난 지점부터 시작한다.
self._utterance_offset = start + duration
display_parts = list(self._final_segments)
if transcript and not is_final:
display_parts.append(transcript)
display_text = " ".join(part for part in display_parts if part).strip()
if not display_text and not speech_final:
return
event_type = "speech_final" if speech_final else (
"speech_end" if is_final else "voice_activity"
)
provider_event: dict[str, object] = {
"type": event_type,
"provider": "local_whisper",
"source": "streaming_stt",
"start_ms": round(start * 1000),
"duration_ms": round(duration * 1000),
"is_final": is_final,
}
if confidence is not None:
provider_event["confidence"] = confidence
if is_final or speech_final:
self._provider_events.append(provider_event)
await self._on_event(
StreamingTranscriptEvent(
text=display_text,
final=is_final,
speech_final=speech_final,
confidence=confidence,
)
)
def _consume_final_words(self, value: object, *, offset: float) -> None:
if not isinstance(value, list):
return
for item in value:
if not isinstance(item, dict):
continue
word = str(item.get("word") or "").strip()
start = _optional_float(item.get("start"))
end = _optional_float(item.get("end"))
if not word or start is None or end is None or end <= start:
continue
confidence = _optional_float(item.get("confidence"))
absolute_start = max(0.0, offset + start)
absolute_end = max(absolute_start, offset + end)
self._words.append(
TranscriptWord(
word=word,
start=absolute_start,
end=absolute_end,
confidence=confidence,
)
)
timing_event: dict[str, object] = {
"type": "stt_word",
"provider": "local_whisper",
"source": "stt_word_timestamps",
"start_ms": round(absolute_start * 1000),
"end_ms": round(absolute_end * 1000),
"is_final": True,
}
if confidence is not None:
timing_event["confidence"] = confidence
self._provider_events.append(timing_event)
self._duration = max(self._duration or 0.0, absolute_end)
def _result(self) -> TranscriptResult:
return TranscriptResult(
text=" ".join(self._final_segments).strip(),
language=self._language,
model=self._model,
duration=self._duration,
provider_events=list(self._provider_events),
words=list(self._words),
)
@dataclass(frozen=True, slots=True)
@ -361,7 +888,7 @@ def assess_end_of_turn(
# OpenAI 음성 서비스
# ════════════════════════════════════════════════════════════════════════════
class VoiceService:
"""OpenAI STT와 선택형 OpenAI/Higgs TTS 어댑터(httpx 풀 공유)."""
"""Deepgram/OpenAI STT와 선택형 OpenAI/Higgs TTS 어댑터."""
def __init__(
self,
@ -374,10 +901,130 @@ class VoiceService:
tts_provider: Optional[str] = None,
higgs_base_url: Optional[str] = None,
higgs_timeout_seconds: Optional[float] = None,
stt_provider: Optional[str] = None,
deepgram_api_key: Optional[str] = None,
deepgram_stt_url: Optional[str] = None,
deepgram_stt_model: Optional[str] = None,
deepgram_stt_language: Optional[str] = None,
deepgram_endpointing_ms: Optional[int] = None,
deepgram_utterance_end_ms: Optional[int] = None,
deepgram_keepalive_seconds: Optional[float] = None,
deepgram_finalize_timeout_seconds: Optional[float] = None,
deepgram_mip_opt_out: Optional[bool] = None,
deepgram_connect: Optional[Callable[..., Awaitable[Any]]] = None,
local_whisper_stt_url: Optional[str] = None,
local_whisper_stt_model: Optional[str] = None,
local_whisper_stt_language: Optional[str] = None,
local_whisper_endpointing_ms: Optional[int] = None,
local_whisper_utterance_end_ms: Optional[int] = None,
local_whisper_finalize_timeout_seconds: Optional[float] = None,
local_whisper_connect: Optional[Callable[..., Awaitable[Any]]] = None,
) -> None:
self._api_key = (api_key if api_key is not None else settings.openai_api_key) or ""
self._base_url = (base_url or settings.openai_base_url or OPENAI_BASE_URL).rstrip("/")
self._environment = environment if environment is not None else settings.environment
self._stt_provider = (
stt_provider if stt_provider is not None else settings.voice_stt_provider
).strip().lower()
configured_deepgram_key = settings.deepgram_api_key.get_secret_value()
self._deepgram_api_key = (
deepgram_api_key
if deepgram_api_key is not None
else configured_deepgram_key
).strip()
self._deepgram_stt_url = (
deepgram_stt_url
if deepgram_stt_url is not None
else settings.deepgram_stt_url
).rstrip("?")
self._deepgram_stt_model = (
deepgram_stt_model
if deepgram_stt_model is not None
else settings.deepgram_stt_model
).strip() or DEEPGRAM_STT_MODEL
self._deepgram_stt_language = (
deepgram_stt_language
if deepgram_stt_language is not None
else settings.deepgram_stt_language
).strip() or STT_LANGUAGE
self._deepgram_endpointing_ms = max(
10,
int(
deepgram_endpointing_ms
if deepgram_endpointing_ms is not None
else settings.deepgram_endpointing_ms
),
)
self._deepgram_utterance_end_ms = max(
1000,
int(
deepgram_utterance_end_ms
if deepgram_utterance_end_ms is not None
else settings.deepgram_utterance_end_ms
),
)
self._deepgram_keepalive_seconds = max(
1.0,
float(
deepgram_keepalive_seconds
if deepgram_keepalive_seconds is not None
else settings.deepgram_keepalive_seconds
),
)
self._deepgram_finalize_timeout_seconds = max(
1.0,
float(
deepgram_finalize_timeout_seconds
if deepgram_finalize_timeout_seconds is not None
else settings.deepgram_finalize_timeout_seconds
),
)
self._deepgram_mip_opt_out = (
bool(settings.deepgram_mip_opt_out)
if deepgram_mip_opt_out is None
else bool(deepgram_mip_opt_out)
)
self._deepgram_connect = deepgram_connect or websocket_connect
self._local_whisper_stt_url = (
local_whisper_stt_url
if local_whisper_stt_url is not None
else settings.local_whisper_stt_url
).strip().rstrip("?")
self._local_whisper_stt_model = (
local_whisper_stt_model
if local_whisper_stt_model is not None
else settings.local_whisper_stt_model
).strip() or "large-v3"
self._local_whisper_stt_language = (
local_whisper_stt_language
if local_whisper_stt_language is not None
else settings.local_whisper_stt_language
).strip() or STT_LANGUAGE
self._local_whisper_endpointing_ms = max(
10,
int(
local_whisper_endpointing_ms
if local_whisper_endpointing_ms is not None
else settings.local_whisper_endpointing_ms
),
)
self._local_whisper_utterance_end_ms = max(
1000,
int(
local_whisper_utterance_end_ms
if local_whisper_utterance_end_ms is not None
else settings.local_whisper_utterance_end_ms
),
)
self._local_whisper_finalize_timeout_seconds = max(
1.0,
float(
local_whisper_finalize_timeout_seconds
if local_whisper_finalize_timeout_seconds is not None
else settings.local_whisper_finalize_timeout_seconds
),
)
self._local_whisper_connect = local_whisper_connect or websocket_connect
self._tts_provider = (
tts_provider if tts_provider is not None else settings.voice_tts_provider
).strip().lower()
@ -436,8 +1083,162 @@ class VoiceService:
return self.stt_available() and self.tts_available()
def stt_available(self) -> bool:
return self.streaming_stt_enabled() or self.batch_stt_available()
def batch_stt_available(self) -> bool:
return bool(self._api_key)
def streaming_stt_enabled(self) -> bool:
if self._stt_provider == "deepgram":
return bool(self._deepgram_api_key)
if self._stt_provider == "local_whisper":
# 로컬 사이드카는 키가 없다. URL 설정만으로 활성화된다.
return bool(self._local_whisper_stt_url)
return False
def stt_provider(self) -> str:
if self.streaming_stt_enabled():
return self._stt_provider
if (
self._stt_provider in {"deepgram", "local_whisper"}
and self.batch_stt_available()
):
return "openai-batch-fallback"
if self.batch_stt_available():
return "openai"
return "unavailable"
def stt_model(self) -> str:
if self.streaming_stt_enabled():
if self._stt_provider == "local_whisper":
return self._local_whisper_stt_model
return self._deepgram_stt_model
return STT_MODEL
def can_stream_audio(
self,
*,
fmt: str | None,
sample_rate: int | None,
channels: int | None,
sample_width: int | None,
) -> bool:
normalized = (fmt or "").strip().lower()
return (
self.streaming_stt_enabled()
and normalized in {"pcm", "s16le", "linear16", "audio/pcm"}
and sample_width == 2
and sample_rate is not None
and 8000 <= sample_rate <= 192000
and channels in {1, 2}
)
async def open_streaming_transcription(
self,
*,
fmt: str | None,
sample_rate: int | None,
channels: int | None,
sample_width: int | None,
on_event: Callable[[StreamingTranscriptEvent], Awaitable[None]],
) -> DeepgramStreamingSession | LocalWhisperStreamingSession:
if not self.can_stream_audio(
fmt=fmt,
sample_rate=sample_rate,
channels=channels,
sample_width=sample_width,
):
raise VoiceUnavailable(
"streaming STT requires linear16 PCM metadata"
)
assert sample_rate is not None and channels is not None
if self._stt_provider == "local_whisper":
return await self._open_local_whisper_transcription(
sample_rate=sample_rate, channels=channels, on_event=on_event
)
query = urlencode(
{
"model": self._deepgram_stt_model,
"language": self._deepgram_stt_language,
"encoding": "linear16",
"sample_rate": sample_rate,
"channels": channels,
"interim_results": "true",
"punctuate": "true",
"smart_format": "true",
"vad_events": "true",
"endpointing": self._deepgram_endpointing_ms,
"utterance_end_ms": self._deepgram_utterance_end_ms,
"mip_opt_out": "true" if self._deepgram_mip_opt_out else "false",
}
)
url = f"{self._deepgram_stt_url}?{query}"
try:
socket = await self._deepgram_connect(
url,
additional_headers={
"Authorization": f"Token {self._deepgram_api_key}",
},
open_timeout=10,
close_timeout=5,
ping_interval=20,
ping_timeout=20,
max_size=2 * 1024 * 1024,
max_queue=16,
write_limit=64 * 1024,
)
except Exception as exc:
raise RuntimeError("Deepgram streaming STT connection failed") from exc
return DeepgramStreamingSession(
socket,
model=self._deepgram_stt_model,
language=self._deepgram_stt_language,
on_event=on_event,
keepalive_seconds=self._deepgram_keepalive_seconds,
finalize_timeout_seconds=self._deepgram_finalize_timeout_seconds,
)
async def _open_local_whisper_transcription(
self,
*,
sample_rate: int,
channels: int,
on_event: Callable[[StreamingTranscriptEvent], Awaitable[None]],
) -> LocalWhisperStreamingSession:
query = urlencode(
{
"model": self._local_whisper_stt_model,
"language": self._local_whisper_stt_language,
"sample_rate": sample_rate,
"channels": channels,
"endpointing": self._local_whisper_endpointing_ms,
"utterance_end_ms": self._local_whisper_utterance_end_ms,
}
)
url = f"{self._local_whisper_stt_url}?{query}"
try:
socket = await self._local_whisper_connect(
url,
open_timeout=10,
close_timeout=5,
ping_interval=20,
ping_timeout=20,
max_size=2 * 1024 * 1024,
max_queue=16,
write_limit=64 * 1024,
)
except Exception as exc:
raise RuntimeError(
"Local whisper streaming STT connection failed"
) from exc
return LocalWhisperStreamingSession(
socket,
model=self._local_whisper_stt_model,
language=self._local_whisper_stt_language,
on_event=on_event,
finalize_timeout_seconds=self._local_whisper_finalize_timeout_seconds,
)
def tts_available(self, voice: VoicePreset | None = None) -> bool:
if self._higgs_enabled() and (voice is None or self._should_use_higgs_tts(voice)):
return True
@ -702,6 +1503,13 @@ def _nonnegative_int(value: object) -> int:
return 0
def _optional_float(value: object) -> float | None:
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def _clean_optional_text(value: object) -> str | None:
if value is None:
return None
@ -718,6 +1526,10 @@ __all__ = [
"VoiceUnavailable",
"VoicePreset",
"TranscriptResult",
"TranscriptWord",
"StreamingTranscriptEvent",
"DeepgramStreamingSession",
"LocalWhisperStreamingSession",
"EndOfTurnDecision",
"TTSChunk",
"VoiceService",
@ -732,6 +1544,7 @@ __all__ = [
"PERSONA_CODE_TO_PRESET",
"DEFAULT_OPENAI_VOICE",
"STT_MODEL",
"DEEPGRAM_STT_MODEL",
"TTS_MODEL",
"HIGGS_TTS_MODEL",
]