feat(voice): add duplex streaming v2 pipeline

This commit is contained in:
Yera All
2026-04-11 17:38:21 +05:00
parent fc976804e4
commit b75b76fae6
10 changed files with 1219 additions and 206 deletions
@@ -1,5 +1,6 @@
from __future__ import annotations
import base64
import os
from dataclasses import dataclass
@@ -27,6 +28,22 @@ def _openai_asr_model() -> str:
return os.getenv("AI_VOICE_ASR_MODEL", "gpt-4o-mini-transcribe").strip() or "gpt-4o-mini-transcribe"
def _streaming_asr_api_base() -> str:
return (
os.getenv("AI_VOICE_V2_STREAMING_ASR_BASE_URL", "http://127.0.0.1:8021").strip()
or "http://127.0.0.1:8021"
).rstrip("/")
def _streaming_asr_timeout_seconds() -> float:
raw = os.getenv("AI_VOICE_V2_STREAMING_ASR_TIMEOUT_SECONDS", "4").strip()
try:
value = float(raw)
except ValueError:
value = 4.0
return max(value, 0.25)
@dataclass(slots=True)
class ASRTranscription:
text: str
@@ -34,6 +51,19 @@ class ASRTranscription:
confidence: float | None = None
@dataclass(slots=True)
class StreamingASRPartial:
text: str
language: str | None = None
confidence: float | None = None
is_final: bool = False
is_stable: bool = False
class StreamingASRUnavailable(RuntimeError):
pass
class ASRProvider:
name = "stub"
@@ -45,6 +75,30 @@ class ASRProvider:
return self.transcribe(audio_bytes, language_hint=language_hint)
class StreamingASRProvider:
name = "streaming-stub"
supports_streaming = False
def open_stream(self, session_id: str, *, language_hint: str | None = None) -> str:
del session_id, language_hint
raise StreamingASRUnavailable("Streaming ASR backend is not configured")
def push_pcm(self, stream_id: str, pcm_8k_chunk: bytes) -> None:
del stream_id, pcm_8k_chunk
raise StreamingASRUnavailable("Streaming ASR backend is not configured")
def poll_partial(self, stream_id: str) -> StreamingASRPartial | None:
del stream_id
return None
def finalize(self, stream_id: str) -> ASRTranscription:
del stream_id
raise StreamingASRUnavailable("Streaming ASR backend is not configured")
def close_stream(self, stream_id: str) -> None:
del stream_id
class OpenAIASRProvider(ASRProvider):
name = "openai"
@@ -84,8 +138,107 @@ class OpenAIASRProvider(ASRProvider):
return self.transcribe(audio_bytes, language_hint=language_hint)
class LocalSidecarStreamingASRProvider(StreamingASRProvider):
name = "local-sidecar"
supports_streaming = True
def __init__(
self,
*,
api_base: str | None = None,
timeout_seconds: float | None = None,
) -> None:
self._api_base = str(api_base or _streaming_asr_api_base()).strip().rstrip("/")
self._timeout_seconds = max(float(timeout_seconds or _streaming_asr_timeout_seconds()), 0.25)
def _request(
self,
method: str,
path: str,
*,
payload: dict[str, object] | None = None,
) -> dict[str, object]:
if not self._api_base:
raise StreamingASRUnavailable("Streaming ASR API base is not configured")
try:
with httpx.Client(timeout=self._timeout_seconds) as client:
response = client.request(
method,
f"{self._api_base}{path}",
json=payload,
)
response.raise_for_status()
except httpx.HTTPError as exc:
raise StreamingASRUnavailable(str(exc)[:500] or "Streaming ASR sidecar is unavailable") from exc
body = response.json() if response.content else {}
return body if isinstance(body, dict) else {}
def open_stream(self, session_id: str, *, language_hint: str | None = None) -> str:
payload = {
"session_id": str(session_id or "").strip(),
"language_hint": str(language_hint or "").strip() or None,
"sample_rate_hz": 8000,
"encoding": "pcm_s16le",
}
body = self._request("POST", "/internal/asr/streams", payload=payload)
stream_id = str(body.get("stream_id") or "").strip()
if not stream_id:
raise StreamingASRUnavailable("Streaming ASR sidecar did not return stream_id")
return stream_id
def push_pcm(self, stream_id: str, pcm_8k_chunk: bytes) -> None:
if not pcm_8k_chunk:
return
self._request(
"POST",
f"/internal/asr/streams/{stream_id}/chunks",
payload={
"pcm_b64": base64.b64encode(pcm_8k_chunk).decode("ascii"),
"sample_rate_hz": 8000,
"encoding": "pcm_s16le",
},
)
def poll_partial(self, stream_id: str) -> StreamingASRPartial | None:
body = self._request("GET", f"/internal/asr/streams/{stream_id}/partial")
text = str(body.get("text") or "").strip()
if not text:
return None
language = str(body.get("language") or "").strip() or None
confidence_raw = body.get("confidence")
confidence = float(confidence_raw) if isinstance(confidence_raw, (int, float)) else None
return StreamingASRPartial(
text=text,
language=language,
confidence=confidence,
is_final=bool(body.get("is_final")),
is_stable=bool(body.get("is_stable")),
)
def finalize(self, stream_id: str) -> ASRTranscription:
body = self._request("POST", f"/internal/asr/streams/{stream_id}/finalize")
return ASRTranscription(
text=str(body.get("text") or "").strip(),
language=str(body.get("language") or "").strip() or None,
confidence=float(body["confidence"]) if isinstance(body.get("confidence"), (int, float)) else None,
)
def close_stream(self, stream_id: str) -> None:
try:
self._request("DELETE", f"/internal/asr/streams/{stream_id}")
except StreamingASRUnavailable:
return
def build_asr_provider(name: str) -> ASRProvider:
normalized = str(name or "stub").strip().lower()
if normalized == "openai":
return OpenAIASRProvider()
return ASRProvider()
def build_streaming_asr_provider(name: str) -> StreamingASRProvider:
normalized = str(name or "disabled").strip().lower()
if normalized in {"local_sidecar", "local-sidecar", "sidecar"}:
return LocalSidecarStreamingASRProvider()
return StreamingASRProvider()