feat(voice): add streaming asr sidecar for v2 duplex
This commit is contained in:
@@ -60,6 +60,12 @@ AI_VOICE_V2_ACK_MODE=immediate_short
|
||||
AI_VOICE_V2_DUPLEX_ENABLED=1
|
||||
AI_VOICE_V2_STREAMING_ASR_BACKEND=local_sidecar
|
||||
AI_VOICE_V2_STREAMING_ASR_BASE_URL=http://127.0.0.1:8021
|
||||
AI_VOICE_V2_STREAMING_ASR_TIMEOUT_SECONDS=4
|
||||
AI_VOICE_V2_STREAMING_ASR_MODEL=base
|
||||
AI_VOICE_V2_STREAMING_ASR_COMPUTE_TYPE=int8
|
||||
AI_VOICE_V2_STREAMING_ASR_DEVICE=cpu
|
||||
AI_VOICE_V2_STREAMING_ASR_CACHE_DIR=.models/faster_whisper
|
||||
AI_VOICE_V2_STREAMING_ASR_SUPPORTED_LANGUAGES=ru
|
||||
AI_VOICE_V2_PREBAKED_ACK_ENABLED=1
|
||||
AI_VOICE_V2_PREBAKED_ACK_DIR=.data/voice_v2_ack_bank
|
||||
AI_VOICE_V2_STREAMING_TTS=1
|
||||
|
||||
@@ -54,7 +54,13 @@ AI_VOICE_V2_QUEUE_CODES=voice_lab_ai
|
||||
AI_VOICE_V2_ACK_MODE=immediate_short
|
||||
AI_VOICE_V2_DUPLEX_ENABLED=1
|
||||
AI_VOICE_V2_STREAMING_ASR_BACKEND=local_sidecar
|
||||
AI_VOICE_V2_STREAMING_ASR_BASE_URL=http://127.0.0.1:8021
|
||||
AI_VOICE_V2_STREAMING_ASR_BASE_URL=http://streaming-asr-sidecar:8021
|
||||
AI_VOICE_V2_STREAMING_ASR_TIMEOUT_SECONDS=4
|
||||
AI_VOICE_V2_STREAMING_ASR_MODEL=base
|
||||
AI_VOICE_V2_STREAMING_ASR_COMPUTE_TYPE=int8
|
||||
AI_VOICE_V2_STREAMING_ASR_DEVICE=cpu
|
||||
AI_VOICE_V2_STREAMING_ASR_CACHE_DIR=/models/faster_whisper
|
||||
AI_VOICE_V2_STREAMING_ASR_SUPPORTED_LANGUAGES=ru
|
||||
AI_VOICE_V2_PREBAKED_ACK_ENABLED=1
|
||||
AI_VOICE_V2_PREBAKED_ACK_DIR=/app/.data/voice_v2_ack_bank
|
||||
AI_VOICE_V2_STREAMING_TTS=1
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
.codex_tmp/
|
||||
.server_backups/
|
||||
.tmp/
|
||||
.models/
|
||||
test-results/
|
||||
.testdata/
|
||||
backups/
|
||||
|
||||
@@ -22,6 +22,8 @@ x-app-env: &app_env
|
||||
KB_SERVICE_URL: http://kb-service:8000
|
||||
REPORTING_SERVICE_URL: http://reporting-service:8000
|
||||
SUPERVISOR_SERVICE_URL: http://supervisor-service:8000
|
||||
AI_VOICE_V2_STREAMING_ASR_BASE_URL: http://streaming-asr-sidecar:8021
|
||||
AI_VOICE_V2_STREAMING_ASR_TIMEOUT_SECONDS: "4"
|
||||
|
||||
x-service-defaults: &service_defaults
|
||||
build:
|
||||
@@ -123,8 +125,38 @@ services:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.ai_orchestrator_service.app:app
|
||||
|
||||
streaming-asr-sidecar:
|
||||
<<: *service_defaults
|
||||
command: ["sh", "-c", "uvicorn ${APP_MODULE} --host 0.0.0.0 --port 8021"]
|
||||
environment:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.streaming_asr_sidecar_service.app:app
|
||||
AI_VOICE_V2_STREAMING_ASR_MODEL: base
|
||||
AI_VOICE_V2_STREAMING_ASR_COMPUTE_TYPE: int8
|
||||
AI_VOICE_V2_STREAMING_ASR_DEVICE: cpu
|
||||
AI_VOICE_V2_STREAMING_ASR_CACHE_DIR: /models/faster_whisper
|
||||
AI_VOICE_V2_STREAMING_ASR_SUPPORTED_LANGUAGES: ru
|
||||
volumes:
|
||||
- ../.data_pg_parallel:/app/.data_pg_parallel
|
||||
- ../.models/faster_whisper:/models/faster_whisper
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- |
|
||||
from urllib.request import urlopen
|
||||
import sys
|
||||
sys.exit(0 if urlopen("http://127.0.0.1:8021/health", timeout=2).getcode() == 200 else 1)
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
ai-voice-runtime-service:
|
||||
<<: *service_defaults
|
||||
depends_on:
|
||||
- streaming-asr-sidecar
|
||||
environment:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.ai_voice_runtime_service.app:app
|
||||
|
||||
@@ -22,6 +22,8 @@ x-app-env: &app_env
|
||||
KB_SERVICE_URL: http://kb-service:8000
|
||||
REPORTING_SERVICE_URL: http://reporting-service:8000
|
||||
SUPERVISOR_SERVICE_URL: http://supervisor-service:8000
|
||||
AI_VOICE_V2_STREAMING_ASR_BASE_URL: http://streaming-asr-sidecar:8021
|
||||
AI_VOICE_V2_STREAMING_ASR_TIMEOUT_SECONDS: "4"
|
||||
|
||||
x-service-defaults: &service_defaults
|
||||
image: ${APP_IMAGE:?Set APP_IMAGE in deployment/.env.images or shell env}
|
||||
@@ -117,8 +119,38 @@ services:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.ai_orchestrator_service.app:app
|
||||
|
||||
streaming-asr-sidecar:
|
||||
<<: *service_defaults
|
||||
command: ["sh", "-c", "uvicorn ${APP_MODULE} --host 0.0.0.0 --port 8021"]
|
||||
environment:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.streaming_asr_sidecar_service.app:app
|
||||
AI_VOICE_V2_STREAMING_ASR_MODEL: base
|
||||
AI_VOICE_V2_STREAMING_ASR_COMPUTE_TYPE: int8
|
||||
AI_VOICE_V2_STREAMING_ASR_DEVICE: cpu
|
||||
AI_VOICE_V2_STREAMING_ASR_CACHE_DIR: /models/faster_whisper
|
||||
AI_VOICE_V2_STREAMING_ASR_SUPPORTED_LANGUAGES: ru
|
||||
volumes:
|
||||
- ../.data_local:/app/.data_local
|
||||
- ../.models/faster_whisper:/models/faster_whisper
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- |
|
||||
from urllib.request import urlopen
|
||||
import sys
|
||||
sys.exit(0 if urlopen("http://127.0.0.1:8021/health", timeout=2).getcode() == 200 else 1)
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
ai-voice-runtime-service:
|
||||
<<: *service_defaults
|
||||
depends_on:
|
||||
- streaming-asr-sidecar
|
||||
environment:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.ai_voice_runtime_service.app:app
|
||||
|
||||
@@ -22,6 +22,8 @@ x-app-env: &app_env
|
||||
KB_SERVICE_URL: http://kb-service:8000
|
||||
REPORTING_SERVICE_URL: http://reporting-service:8000
|
||||
SUPERVISOR_SERVICE_URL: http://supervisor-service:8000
|
||||
AI_VOICE_V2_STREAMING_ASR_BASE_URL: http://streaming-asr-sidecar:8021
|
||||
AI_VOICE_V2_STREAMING_ASR_TIMEOUT_SECONDS: "4"
|
||||
|
||||
x-service-defaults: &service_defaults
|
||||
build:
|
||||
@@ -119,8 +121,38 @@ services:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.ai_orchestrator_service.app:app
|
||||
|
||||
streaming-asr-sidecar:
|
||||
<<: *service_defaults
|
||||
command: ["sh", "-c", "uvicorn ${APP_MODULE} --host 0.0.0.0 --port 8021"]
|
||||
environment:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.streaming_asr_sidecar_service.app:app
|
||||
AI_VOICE_V2_STREAMING_ASR_MODEL: base
|
||||
AI_VOICE_V2_STREAMING_ASR_COMPUTE_TYPE: int8
|
||||
AI_VOICE_V2_STREAMING_ASR_DEVICE: cpu
|
||||
AI_VOICE_V2_STREAMING_ASR_CACHE_DIR: /models/faster_whisper
|
||||
AI_VOICE_V2_STREAMING_ASR_SUPPORTED_LANGUAGES: ru
|
||||
volumes:
|
||||
- ../.data_local:/app/.data_local
|
||||
- ../.models/faster_whisper:/models/faster_whisper
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- |
|
||||
from urllib.request import urlopen
|
||||
import sys
|
||||
sys.exit(0 if urlopen("http://127.0.0.1:8021/health", timeout=2).getcode() == 200 else 1)
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
ai-voice-runtime-service:
|
||||
<<: *service_defaults
|
||||
depends_on:
|
||||
- streaming-asr-sidecar
|
||||
environment:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.ai_voice_runtime_service.app:app
|
||||
|
||||
@@ -36,7 +36,8 @@ x-app-env: &app_env
|
||||
AI_VOICE_V2_ACK_MODE: immediate_short
|
||||
AI_VOICE_V2_DUPLEX_ENABLED: "1"
|
||||
AI_VOICE_V2_STREAMING_ASR_BACKEND: local_sidecar
|
||||
AI_VOICE_V2_STREAMING_ASR_BASE_URL: http://127.0.0.1:8021
|
||||
AI_VOICE_V2_STREAMING_ASR_BASE_URL: http://streaming-asr-sidecar:8021
|
||||
AI_VOICE_V2_STREAMING_ASR_TIMEOUT_SECONDS: "4"
|
||||
AI_VOICE_V2_PREBAKED_ACK_ENABLED: "1"
|
||||
AI_VOICE_V2_PREBAKED_ACK_DIR: /app/.data/voice_v2_ack_bank
|
||||
AI_VOICE_V2_STREAMING_TTS: "1"
|
||||
@@ -193,9 +194,40 @@ services:
|
||||
ports:
|
||||
- "8017:8000"
|
||||
|
||||
ai-voice-runtime-service:
|
||||
streaming-asr-sidecar:
|
||||
build: ..
|
||||
depends_on: [postgres, rabbitmq]
|
||||
command: ["sh", "-c", "uvicorn ${APP_MODULE} --host 0.0.0.0 --port 8021"]
|
||||
environment:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.streaming_asr_sidecar_service.app:app
|
||||
AI_VOICE_V2_STREAMING_ASR_MODEL: base
|
||||
AI_VOICE_V2_STREAMING_ASR_COMPUTE_TYPE: int8
|
||||
AI_VOICE_V2_STREAMING_ASR_DEVICE: cpu
|
||||
AI_VOICE_V2_STREAMING_ASR_CACHE_DIR: /models/faster_whisper
|
||||
AI_VOICE_V2_STREAMING_ASR_SUPPORTED_LANGUAGES: ru
|
||||
volumes:
|
||||
- ../.data:/app/.data
|
||||
- ../.models/faster_whisper:/models/faster_whisper
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- |
|
||||
from urllib.request import urlopen
|
||||
import sys
|
||||
sys.exit(0 if urlopen("http://127.0.0.1:8021/health", timeout=2).getcode() == 200 else 1)
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
ports:
|
||||
- "8021:8021"
|
||||
|
||||
ai-voice-runtime-service:
|
||||
build: ..
|
||||
depends_on: [postgres, rabbitmq, streaming-asr-sidecar]
|
||||
environment:
|
||||
<<: *app_env
|
||||
APP_MODULE: services.ai_voice_runtime_service.app:app
|
||||
|
||||
@@ -8,3 +8,4 @@ psycopg[binary]==3.2.6
|
||||
pika==1.3.2
|
||||
paramiko==3.5.0
|
||||
python-multipart==0.0.20
|
||||
faster-whisper==1.1.1
|
||||
|
||||
@@ -88,6 +88,7 @@ def build_service_specs(env: dict[str, str] | None = None) -> list[dict[str, Any
|
||||
{"name": "telegram", "port": 8007, "module": "services.telegram_adapter_service.app:app"},
|
||||
{"name": "whatsapp", "port": 8019, "module": "services.whatsapp_adapter_service.app:app"},
|
||||
{"name": "ai", "port": 8017, "module": "services.ai_orchestrator_service.app:app"},
|
||||
{"name": "streaming-asr-sidecar", "port": 8021, "module": "services.streaming_asr_sidecar_service.app:app"},
|
||||
{"name": "ai-voice-runtime", "port": 8018, "module": "services.ai_voice_runtime_service.app:app"},
|
||||
{"name": "webchat", "port": 8011, "module": "services.webchat_adapter_service.app:app"},
|
||||
{"name": "email", "port": 8012, "module": "services.email_adapter_service.app:app"},
|
||||
@@ -317,6 +318,12 @@ def spawn_service(spec: dict[str, Any], runtime_dir: Path, data_dir: Path, base_
|
||||
env["AI_VOICE_V2_DUPLEX_ENABLED"] = env.get("AI_VOICE_V2_DUPLEX_ENABLED", "1")
|
||||
env["AI_VOICE_V2_STREAMING_ASR_BACKEND"] = env.get("AI_VOICE_V2_STREAMING_ASR_BACKEND", "local_sidecar")
|
||||
env["AI_VOICE_V2_STREAMING_ASR_BASE_URL"] = env.get("AI_VOICE_V2_STREAMING_ASR_BASE_URL", "http://127.0.0.1:8021")
|
||||
env["AI_VOICE_V2_STREAMING_ASR_TIMEOUT_SECONDS"] = env.get("AI_VOICE_V2_STREAMING_ASR_TIMEOUT_SECONDS", "4")
|
||||
env["AI_VOICE_V2_STREAMING_ASR_MODEL"] = env.get("AI_VOICE_V2_STREAMING_ASR_MODEL", "base")
|
||||
env["AI_VOICE_V2_STREAMING_ASR_COMPUTE_TYPE"] = env.get("AI_VOICE_V2_STREAMING_ASR_COMPUTE_TYPE", "int8")
|
||||
env["AI_VOICE_V2_STREAMING_ASR_DEVICE"] = env.get("AI_VOICE_V2_STREAMING_ASR_DEVICE", "cpu")
|
||||
env["AI_VOICE_V2_STREAMING_ASR_CACHE_DIR"] = env.get("AI_VOICE_V2_STREAMING_ASR_CACHE_DIR", str(ROOT / ".models" / "faster_whisper"))
|
||||
env["AI_VOICE_V2_STREAMING_ASR_SUPPORTED_LANGUAGES"] = env.get("AI_VOICE_V2_STREAMING_ASR_SUPPORTED_LANGUAGES", "ru")
|
||||
env["AI_VOICE_V2_PREBAKED_ACK_ENABLED"] = env.get("AI_VOICE_V2_PREBAKED_ACK_ENABLED", "1")
|
||||
env["AI_VOICE_V2_PREBAKED_ACK_DIR"] = env.get("AI_VOICE_V2_PREBAKED_ACK_DIR", str(DATA_DIR / "voice_v2_ack_bank"))
|
||||
env["AI_VOICE_V2_STREAMING_TTS"] = env.get("AI_VOICE_V2_STREAMING_TTS", "1")
|
||||
@@ -338,6 +345,8 @@ def spawn_service(spec: dict[str, Any], runtime_dir: Path, data_dir: Path, base_
|
||||
if spec["name"] == "recording":
|
||||
env["CC_RECORDINGS_DIR"] = str((data_dir / "recordings").resolve())
|
||||
env["RECORDING_MAX_BYTES"] = env.get("RECORDING_MAX_BYTES", "26214400")
|
||||
if spec["name"] == "streaming-asr-sidecar":
|
||||
Path(env["AI_VOICE_V2_STREAMING_ASR_CACHE_DIR"]).mkdir(parents=True, exist_ok=True)
|
||||
if spec["name"] == "gateway":
|
||||
env.update(build_gateway_env(env))
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from services.shared.security import issue_app_token
|
||||
|
||||
|
||||
def _api_base() -> str:
|
||||
return (os.getenv("AI_API_BASE", "https://api.openai.com/v1").strip() or "https://api.openai.com/v1").rstrip("/")
|
||||
@@ -44,6 +46,18 @@ def _streaming_asr_timeout_seconds() -> float:
|
||||
return max(value, 0.25)
|
||||
|
||||
|
||||
def _service_headers() -> dict[str, str]:
|
||||
token = issue_app_token(
|
||||
subject="svc:ai-voice-runtime",
|
||||
username="ai-voice-runtime",
|
||||
role="admin",
|
||||
auth_source="service",
|
||||
provider="ai-voice-runtime",
|
||||
ttl_seconds=300,
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ASRTranscription:
|
||||
text: str
|
||||
@@ -166,6 +180,7 @@ class LocalSidecarStreamingASRProvider(StreamingASRProvider):
|
||||
method,
|
||||
f"{self._api_base}{path}",
|
||||
json=payload,
|
||||
headers=_service_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Streaming ASR sidecar service package."""
|
||||
@@ -0,0 +1,576 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import audioop
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.shared.core import Role, new_id
|
||||
from services.shared.models import HealthResponse
|
||||
from services.shared.security import require_roles
|
||||
|
||||
|
||||
LOGGER = logging.getLogger("uvicorn.error")
|
||||
|
||||
app = FastAPI(title="streaming-asr-sidecar-service", version="1.0.0")
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
return int(str(raw).strip())
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _float_env(name: str, default: float) -> float:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
return float(str(raw).strip())
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _stream_idle_ttl_seconds() -> float:
|
||||
return max(_float_env("AI_VOICE_V2_STREAMING_ASR_IDLE_TTL_SECONDS", 45.0), 5.0)
|
||||
|
||||
|
||||
def _partial_min_audio_ms() -> int:
|
||||
return max(_int_env("AI_VOICE_V2_STREAMING_ASR_MIN_AUDIO_MS", 320), 120)
|
||||
|
||||
|
||||
def _partial_recompute_interval_ms() -> int:
|
||||
return max(_int_env("AI_VOICE_V2_STREAMING_ASR_PARTIAL_RECOMPUTE_INTERVAL_MS", 200), 80)
|
||||
|
||||
|
||||
def _partial_stability_hold_ms() -> int:
|
||||
return max(_int_env("AI_VOICE_V2_STREAMING_ASR_PARTIAL_STABILITY_HOLD_MS", 400), 120)
|
||||
|
||||
|
||||
def _model_name() -> str:
|
||||
return str(os.getenv("AI_VOICE_V2_STREAMING_ASR_MODEL", "base") or "base").strip() or "base"
|
||||
|
||||
|
||||
def _compute_type() -> str:
|
||||
return str(os.getenv("AI_VOICE_V2_STREAMING_ASR_COMPUTE_TYPE", "int8") or "int8").strip() or "int8"
|
||||
|
||||
|
||||
def _device() -> str:
|
||||
return str(os.getenv("AI_VOICE_V2_STREAMING_ASR_DEVICE", "cpu") or "cpu").strip() or "cpu"
|
||||
|
||||
|
||||
def _cache_dir() -> str:
|
||||
return str(os.getenv("AI_VOICE_V2_STREAMING_ASR_CACHE_DIR", "/models/faster_whisper") or "/models/faster_whisper").strip() or "/models/faster_whisper"
|
||||
|
||||
|
||||
def _supported_languages() -> set[str]:
|
||||
raw = str(os.getenv("AI_VOICE_V2_STREAMING_ASR_SUPPORTED_LANGUAGES", "ru") or "ru").strip()
|
||||
values = {item.strip().lower() for item in raw.split(",") if item.strip()}
|
||||
return values or {"ru"}
|
||||
|
||||
|
||||
def _target_sample_rate_hz() -> int:
|
||||
return max(_int_env("AI_VOICE_V2_STREAMING_ASR_TARGET_SAMPLE_RATE_HZ", 16000), 8000)
|
||||
|
||||
|
||||
def _normalize_language_hint(language_hint: str | None) -> str:
|
||||
raw = str(language_hint or "ru").strip().lower() or "ru"
|
||||
if raw in {"ru", "ru-ru"}:
|
||||
return "ru"
|
||||
if raw in {"kk", "kz", "kk-kk"}:
|
||||
return "kk"
|
||||
return raw
|
||||
|
||||
|
||||
def _pcm_duration_ms(pcm_bytes: bytes | bytearray, sample_rate_hz: int) -> int:
|
||||
if sample_rate_hz <= 0:
|
||||
return 0
|
||||
sample_count = len(pcm_bytes) // 2
|
||||
return int((sample_count / float(sample_rate_hz)) * 1000.0)
|
||||
|
||||
|
||||
def _resample_pcm16le(pcm_bytes: bytes, *, input_rate_hz: int, output_rate_hz: int) -> bytes:
|
||||
if not pcm_bytes or input_rate_hz == output_rate_hz:
|
||||
return pcm_bytes
|
||||
converted, _ = audioop.ratecv(
|
||||
pcm_bytes,
|
||||
2,
|
||||
1,
|
||||
input_rate_hz,
|
||||
output_rate_hz,
|
||||
None,
|
||||
)
|
||||
return converted
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SidecarTranscript:
|
||||
text: str
|
||||
language: str | None = None
|
||||
confidence: float | None = None
|
||||
|
||||
|
||||
class TranscriptionEngine(Protocol):
|
||||
def transcribe_pcm(
|
||||
self,
|
||||
pcm_bytes: bytes,
|
||||
*,
|
||||
sample_rate_hz: int,
|
||||
language_hint: str | None = None,
|
||||
) -> SidecarTranscript: ...
|
||||
|
||||
|
||||
class FasterWhisperEngine:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
compute_type: str,
|
||||
device: str,
|
||||
cache_dir: str,
|
||||
target_sample_rate_hz: int,
|
||||
) -> None:
|
||||
self._model_name = model_name
|
||||
self._compute_type = compute_type
|
||||
self._device = device
|
||||
self._cache_dir = cache_dir
|
||||
self._target_sample_rate_hz = target_sample_rate_hz
|
||||
self._model = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _load_model(self):
|
||||
with self._lock:
|
||||
if self._model is not None:
|
||||
return self._model
|
||||
try:
|
||||
from faster_whisper import WhisperModel
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise RuntimeError(
|
||||
"faster-whisper is not installed; install requirements before starting the streaming ASR sidecar"
|
||||
) from exc
|
||||
self._model = WhisperModel(
|
||||
self._model_name,
|
||||
device=self._device,
|
||||
compute_type=self._compute_type,
|
||||
download_root=self._cache_dir,
|
||||
)
|
||||
return self._model
|
||||
|
||||
def transcribe_pcm(
|
||||
self,
|
||||
pcm_bytes: bytes,
|
||||
*,
|
||||
sample_rate_hz: int,
|
||||
language_hint: str | None = None,
|
||||
) -> SidecarTranscript:
|
||||
if not pcm_bytes:
|
||||
return SidecarTranscript(text="", language=language_hint, confidence=None)
|
||||
try:
|
||||
import numpy as np
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise RuntimeError("numpy is required for streaming ASR sidecar") from exc
|
||||
|
||||
model = self._load_model()
|
||||
pcm_model_rate = _resample_pcm16le(
|
||||
pcm_bytes,
|
||||
input_rate_hz=sample_rate_hz,
|
||||
output_rate_hz=self._target_sample_rate_hz,
|
||||
)
|
||||
waveform = np.frombuffer(pcm_model_rate, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
segments, info = model.transcribe(
|
||||
waveform,
|
||||
language=_normalize_language_hint(language_hint),
|
||||
beam_size=1,
|
||||
best_of=1,
|
||||
temperature=0.0,
|
||||
vad_filter=False,
|
||||
word_timestamps=False,
|
||||
condition_on_previous_text=False,
|
||||
without_timestamps=True,
|
||||
)
|
||||
parts: list[str] = []
|
||||
for segment in segments:
|
||||
text = str(getattr(segment, "text", "") or "").strip()
|
||||
if text:
|
||||
parts.append(text)
|
||||
text = " ".join(parts).strip()
|
||||
language = str(getattr(info, "language", "") or language_hint or "").strip() or language_hint
|
||||
confidence_raw = getattr(info, "language_probability", None)
|
||||
confidence = float(confidence_raw) if isinstance(confidence_raw, (int, float)) else None
|
||||
if confidence is not None:
|
||||
confidence = max(0.0, min(confidence, 1.0))
|
||||
return SidecarTranscript(text=text, language=language, confidence=confidence)
|
||||
|
||||
|
||||
def _default_engine_factory() -> TranscriptionEngine:
|
||||
return FasterWhisperEngine(
|
||||
model_name=_model_name(),
|
||||
compute_type=_compute_type(),
|
||||
device=_device(),
|
||||
cache_dir=_cache_dir(),
|
||||
target_sample_rate_hz=_target_sample_rate_hz(),
|
||||
)
|
||||
|
||||
|
||||
_ENGINE_FACTORY = _default_engine_factory
|
||||
_ENGINE_INSTANCE: TranscriptionEngine | None = None
|
||||
_ENGINE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _get_engine() -> TranscriptionEngine:
|
||||
global _ENGINE_INSTANCE
|
||||
with _ENGINE_LOCK:
|
||||
if _ENGINE_INSTANCE is None:
|
||||
_ENGINE_INSTANCE = _ENGINE_FACTORY()
|
||||
return _ENGINE_INSTANCE
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StreamState:
|
||||
stream_id: str
|
||||
session_id: str
|
||||
language_hint: str
|
||||
sample_rate_hz: int
|
||||
encoding: str
|
||||
pcm_buffer: bytearray = field(default_factory=bytearray)
|
||||
created_monotonic: float = field(default_factory=time.monotonic)
|
||||
last_activity_monotonic: float = field(default_factory=time.monotonic)
|
||||
buffer_version: int = 0
|
||||
partial_text: str = ""
|
||||
partial_language: str | None = None
|
||||
partial_confidence: float | None = None
|
||||
partial_buffer_version: int = -1
|
||||
partial_updated_monotonic: float = 0.0
|
||||
partial_first_seen_monotonic: float = 0.0
|
||||
partial_repeat_count: int = 0
|
||||
final_transcript: SidecarTranscript | None = None
|
||||
|
||||
def touch(self) -> None:
|
||||
self.last_activity_monotonic = time.monotonic()
|
||||
|
||||
|
||||
class StreamStore:
|
||||
def __init__(self, *, idle_ttl_seconds: float) -> None:
|
||||
self._idle_ttl_seconds = idle_ttl_seconds
|
||||
self._streams: dict[str, StreamState] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def cleanup_expired(self) -> list[str]:
|
||||
now = time.monotonic()
|
||||
removed: list[str] = []
|
||||
with self._lock:
|
||||
for stream_id, stream in list(self._streams.items()):
|
||||
if now - stream.last_activity_monotonic < self._idle_ttl_seconds:
|
||||
continue
|
||||
removed.append(stream_id)
|
||||
self._streams.pop(stream_id, None)
|
||||
return removed
|
||||
|
||||
def create(self, *, session_id: str, language_hint: str, sample_rate_hz: int, encoding: str) -> StreamState:
|
||||
stream = StreamState(
|
||||
stream_id=new_id("sasr"),
|
||||
session_id=session_id,
|
||||
language_hint=language_hint,
|
||||
sample_rate_hz=sample_rate_hz,
|
||||
encoding=encoding,
|
||||
)
|
||||
with self._lock:
|
||||
self._streams[stream.stream_id] = stream
|
||||
return stream
|
||||
|
||||
def get(self, stream_id: str) -> StreamState | None:
|
||||
with self._lock:
|
||||
stream = self._streams.get(stream_id)
|
||||
if stream is not None:
|
||||
stream.touch()
|
||||
return stream
|
||||
|
||||
def append_pcm(self, stream_id: str, pcm_chunk: bytes) -> StreamState:
|
||||
with self._lock:
|
||||
stream = self._streams.get(stream_id)
|
||||
if stream is None:
|
||||
raise KeyError(stream_id)
|
||||
stream.pcm_buffer.extend(pcm_chunk)
|
||||
stream.buffer_version += 1
|
||||
stream.touch()
|
||||
return stream
|
||||
|
||||
def update_partial(
|
||||
self,
|
||||
stream_id: str,
|
||||
*,
|
||||
transcript: SidecarTranscript,
|
||||
buffer_version: int,
|
||||
) -> StreamState | None:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
stream = self._streams.get(stream_id)
|
||||
if stream is None:
|
||||
return None
|
||||
previous_text = stream.partial_text
|
||||
stream.touch()
|
||||
if transcript.text:
|
||||
if transcript.text == previous_text:
|
||||
stream.partial_repeat_count += 1
|
||||
else:
|
||||
stream.partial_first_seen_monotonic = now
|
||||
stream.partial_repeat_count = 1
|
||||
stream.partial_text = transcript.text
|
||||
stream.partial_language = transcript.language
|
||||
stream.partial_confidence = transcript.confidence
|
||||
stream.partial_updated_monotonic = now
|
||||
stream.partial_buffer_version = buffer_version
|
||||
return stream
|
||||
|
||||
def set_final(self, stream_id: str, transcript: SidecarTranscript) -> StreamState | None:
|
||||
with self._lock:
|
||||
stream = self._streams.get(stream_id)
|
||||
if stream is None:
|
||||
return None
|
||||
stream.final_transcript = transcript
|
||||
stream.touch()
|
||||
return stream
|
||||
|
||||
def delete(self, stream_id: str) -> bool:
|
||||
with self._lock:
|
||||
return self._streams.pop(stream_id, None) is not None
|
||||
|
||||
|
||||
_STREAMS = StreamStore(idle_ttl_seconds=_stream_idle_ttl_seconds())
|
||||
|
||||
|
||||
class OpenStreamIn(BaseModel):
|
||||
session_id: str
|
||||
language_hint: str | None = None
|
||||
sample_rate_hz: int = 8000
|
||||
encoding: str = "pcm_s16le"
|
||||
|
||||
|
||||
class OpenStreamOut(BaseModel):
|
||||
ok: bool = True
|
||||
stream_id: str
|
||||
language: str
|
||||
|
||||
|
||||
class PushChunkIn(BaseModel):
|
||||
pcm_b64: str
|
||||
sample_rate_hz: int = 8000
|
||||
encoding: str = "pcm_s16le"
|
||||
|
||||
|
||||
class PushChunkOut(BaseModel):
|
||||
ok: bool = True
|
||||
stream_id: str
|
||||
received_bytes: int
|
||||
duration_ms: int
|
||||
|
||||
|
||||
class PartialOut(BaseModel):
|
||||
text: str
|
||||
language: str | None = None
|
||||
confidence: float | None = None
|
||||
is_final: bool = False
|
||||
is_stable: bool = False
|
||||
|
||||
|
||||
class FinalizeOut(BaseModel):
|
||||
text: str
|
||||
language: str | None = None
|
||||
confidence: float | None = None
|
||||
|
||||
|
||||
def _require_internal_actor(_: dict = Depends(require_roles(Role.ADMIN))) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _cleanup_expired_streams() -> None:
|
||||
removed = _STREAMS.cleanup_expired()
|
||||
if removed:
|
||||
LOGGER.info("streaming_asr.cleanup_expired removed=%s", len(removed))
|
||||
|
||||
|
||||
def _current_partial_is_stable(stream: StreamState) -> bool:
|
||||
if not stream.partial_text:
|
||||
return False
|
||||
if stream.partial_repeat_count >= 2:
|
||||
return True
|
||||
return (time.monotonic() - stream.partial_first_seen_monotonic) >= (_partial_stability_hold_ms() / 1000.0)
|
||||
|
||||
|
||||
def _copy_stream_snapshot(stream: StreamState) -> tuple[bytes, int, int, str]:
|
||||
return (
|
||||
bytes(stream.pcm_buffer),
|
||||
stream.buffer_version,
|
||||
stream.sample_rate_hz,
|
||||
stream.language_hint,
|
||||
)
|
||||
|
||||
|
||||
def _transcribe_stream_snapshot(pcm_bytes: bytes, *, sample_rate_hz: int, language_hint: str) -> SidecarTranscript:
|
||||
return _get_engine().transcribe_pcm(
|
||||
pcm_bytes,
|
||||
sample_rate_hz=sample_rate_hz,
|
||||
language_hint=language_hint,
|
||||
)
|
||||
|
||||
|
||||
def _current_partial_payload(stream: StreamState) -> PartialOut | None:
|
||||
if not stream.partial_text:
|
||||
return None
|
||||
return PartialOut(
|
||||
text=stream.partial_text,
|
||||
language=stream.partial_language,
|
||||
confidence=stream.partial_confidence,
|
||||
is_final=False,
|
||||
is_stable=_current_partial_is_stable(stream),
|
||||
)
|
||||
|
||||
|
||||
def _language_or_error(language_hint: str | None) -> str:
|
||||
normalized = _normalize_language_hint(language_hint)
|
||||
if normalized not in _supported_languages():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported streaming ASR language: {normalized}",
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_audio_params(sample_rate_hz: int, encoding: str) -> None:
|
||||
if int(sample_rate_hz) != 8000:
|
||||
raise HTTPException(status_code=400, detail="Only 8000 Hz streaming ASR input is supported")
|
||||
if str(encoding or "").strip().lower() != "pcm_s16le":
|
||||
raise HTTPException(status_code=400, detail="Only pcm_s16le encoding is supported")
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthResponse)
|
||||
def health() -> HealthResponse:
|
||||
_cleanup_expired_streams()
|
||||
with _ENGINE_LOCK:
|
||||
loaded = _ENGINE_INSTANCE is not None
|
||||
suffix = "loaded" if loaded else "cold"
|
||||
return HealthResponse(status="ok", service=f"streaming-asr-sidecar ({suffix})")
|
||||
|
||||
|
||||
@app.post("/internal/asr/streams", response_model=OpenStreamOut)
|
||||
def open_stream(payload: OpenStreamIn, _: None = Depends(_require_internal_actor)) -> OpenStreamOut:
|
||||
_cleanup_expired_streams()
|
||||
session_id = str(payload.session_id or "").strip()
|
||||
if not session_id:
|
||||
raise HTTPException(status_code=400, detail="session_id is required")
|
||||
language = _language_or_error(payload.language_hint)
|
||||
_validate_audio_params(payload.sample_rate_hz, payload.encoding)
|
||||
stream = _STREAMS.create(
|
||||
session_id=session_id,
|
||||
language_hint=language,
|
||||
sample_rate_hz=payload.sample_rate_hz,
|
||||
encoding=payload.encoding,
|
||||
)
|
||||
return OpenStreamOut(stream_id=stream.stream_id, language=language)
|
||||
|
||||
|
||||
@app.post("/internal/asr/streams/{stream_id}/chunks", response_model=PushChunkOut)
|
||||
def push_chunk(stream_id: str, payload: PushChunkIn, _: None = Depends(_require_internal_actor)) -> PushChunkOut:
|
||||
_cleanup_expired_streams()
|
||||
_validate_audio_params(payload.sample_rate_hz, payload.encoding)
|
||||
try:
|
||||
pcm_bytes = base64.b64decode(str(payload.pcm_b64 or "").encode("ascii"), validate=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=400, detail="pcm_b64 must be valid base64") from exc
|
||||
try:
|
||||
stream = _STREAMS.append_pcm(stream_id, pcm_bytes)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail="ASR stream not found") from exc
|
||||
return PushChunkOut(
|
||||
stream_id=stream_id,
|
||||
received_bytes=len(pcm_bytes),
|
||||
duration_ms=_pcm_duration_ms(stream.pcm_buffer, stream.sample_rate_hz),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/internal/asr/streams/{stream_id}/partial", response_model=PartialOut | dict[str, Any])
|
||||
def poll_partial(stream_id: str, _: None = Depends(_require_internal_actor)) -> PartialOut | dict[str, Any]:
|
||||
_cleanup_expired_streams()
|
||||
stream = _STREAMS.get(stream_id)
|
||||
if stream is None:
|
||||
raise HTTPException(status_code=404, detail="ASR stream not found")
|
||||
if stream.final_transcript is not None:
|
||||
return PartialOut(
|
||||
text=stream.final_transcript.text,
|
||||
language=stream.final_transcript.language,
|
||||
confidence=stream.final_transcript.confidence,
|
||||
is_final=True,
|
||||
is_stable=True,
|
||||
)
|
||||
if _pcm_duration_ms(stream.pcm_buffer, stream.sample_rate_hz) < _partial_min_audio_ms():
|
||||
payload = _current_partial_payload(stream)
|
||||
return payload.model_dump() if payload else {}
|
||||
now = time.monotonic()
|
||||
if (
|
||||
stream.partial_text
|
||||
and stream.partial_buffer_version == stream.buffer_version
|
||||
and (now - stream.partial_updated_monotonic) < (_partial_recompute_interval_ms() / 1000.0)
|
||||
):
|
||||
payload = _current_partial_payload(stream)
|
||||
return payload.model_dump() if payload else {}
|
||||
|
||||
pcm_bytes, buffer_version, sample_rate_hz, language_hint = _copy_stream_snapshot(stream)
|
||||
transcript = _transcribe_stream_snapshot(
|
||||
pcm_bytes,
|
||||
sample_rate_hz=sample_rate_hz,
|
||||
language_hint=language_hint,
|
||||
)
|
||||
stream = _STREAMS.update_partial(
|
||||
stream_id,
|
||||
transcript=transcript,
|
||||
buffer_version=buffer_version,
|
||||
)
|
||||
if stream is None:
|
||||
raise HTTPException(status_code=404, detail="ASR stream not found")
|
||||
payload = _current_partial_payload(stream)
|
||||
return payload.model_dump() if payload else {}
|
||||
|
||||
|
||||
@app.post("/internal/asr/streams/{stream_id}/finalize", response_model=FinalizeOut)
|
||||
def finalize_stream(stream_id: str, _: None = Depends(_require_internal_actor)) -> FinalizeOut:
|
||||
_cleanup_expired_streams()
|
||||
stream = _STREAMS.get(stream_id)
|
||||
if stream is None:
|
||||
raise HTTPException(status_code=404, detail="ASR stream not found")
|
||||
if stream.final_transcript is not None:
|
||||
return FinalizeOut(
|
||||
text=stream.final_transcript.text,
|
||||
language=stream.final_transcript.language,
|
||||
confidence=stream.final_transcript.confidence,
|
||||
)
|
||||
pcm_bytes, _buffer_version, sample_rate_hz, language_hint = _copy_stream_snapshot(stream)
|
||||
transcript = _transcribe_stream_snapshot(
|
||||
pcm_bytes,
|
||||
sample_rate_hz=sample_rate_hz,
|
||||
language_hint=language_hint,
|
||||
)
|
||||
_STREAMS.set_final(stream_id, transcript)
|
||||
return FinalizeOut(
|
||||
text=transcript.text,
|
||||
language=transcript.language,
|
||||
confidence=transcript.confidence,
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/internal/asr/streams/{stream_id}")
|
||||
def close_stream(stream_id: str, _: None = Depends(_require_internal_actor)) -> dict[str, Any]:
|
||||
_cleanup_expired_streams()
|
||||
if not _STREAMS.delete(stream_id):
|
||||
raise HTTPException(status_code=404, detail="ASR stream not found")
|
||||
return {"ok": True, "stream_id": stream_id}
|
||||
@@ -14,7 +14,13 @@ from services.ai_voice_runtime_service.audiosocket import (
|
||||
resample_pcm16le,
|
||||
)
|
||||
from services.ai_voice_runtime_service.media_runtime import AudioSocketMediaRuntime, MediaActor, MediaRegistration
|
||||
from services.ai_voice_runtime_service.providers.asr import ASRProvider, ASRTranscription
|
||||
from services.ai_voice_runtime_service.providers.asr import (
|
||||
ASRProvider,
|
||||
ASRTranscription,
|
||||
StreamingASRPartial,
|
||||
StreamingASRProvider,
|
||||
StreamingASRUnavailable,
|
||||
)
|
||||
from services.ai_voice_runtime_service.providers.tts import TTSProvider, TTSSynthesis
|
||||
from services.shared.models import VoiceAITurnDecisionOut
|
||||
|
||||
@@ -1221,3 +1227,272 @@ def test_media_runtime_voice_v2_emotive_ack_avoids_same_variant_back_to_back():
|
||||
assert first_text in runtime._base_ack_variants("ru", "understanding")
|
||||
assert second_text in runtime._base_ack_variants("ru", "understanding")
|
||||
assert first_text != second_text
|
||||
|
||||
|
||||
def test_media_runtime_voice_v2_uses_streaming_sidecar_for_partial_and_final_asr():
|
||||
registrations: dict[str, MediaRegistration] = {}
|
||||
reply_starts: list[tuple[str, str | None, float]] = []
|
||||
turns: list[str] = []
|
||||
delivered: list[tuple[str, bool]] = []
|
||||
|
||||
class _ExplodingBatchASRProvider(ASRProvider):
|
||||
name = "exploding-batch-asr"
|
||||
|
||||
def transcribe(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription:
|
||||
raise AssertionError("batch ASR should not be used when streaming sidecar is active")
|
||||
|
||||
class _FakeStreamingASRProvider(StreamingASRProvider):
|
||||
name = "fake-streaming-sidecar"
|
||||
supports_streaming = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.chunk_count = 0
|
||||
self.events: list[tuple[str, float]] = []
|
||||
|
||||
def open_stream(self, session_id: str, *, language_hint: str | None = None) -> str:
|
||||
del session_id, language_hint
|
||||
self.events.append(("open", time.monotonic()))
|
||||
return "stream-1"
|
||||
|
||||
def push_pcm(self, stream_id: str, pcm_8k_chunk: bytes) -> None:
|
||||
del stream_id
|
||||
assert pcm_8k_chunk
|
||||
self.chunk_count += 1
|
||||
self.events.append(("push", time.monotonic()))
|
||||
|
||||
def poll_partial(self, stream_id: str) -> StreamingASRPartial | None:
|
||||
del stream_id
|
||||
self.events.append(("poll", time.monotonic()))
|
||||
if self.chunk_count >= 2:
|
||||
return StreamingASRPartial(
|
||||
text="мне нужен график работы",
|
||||
language="ru",
|
||||
confidence=0.84,
|
||||
is_stable=True,
|
||||
)
|
||||
return None
|
||||
|
||||
def finalize(self, stream_id: str) -> ASRTranscription:
|
||||
del stream_id
|
||||
self.events.append(("finalize", time.monotonic()))
|
||||
time.sleep(0.2)
|
||||
return ASRTranscription(text="мне нужен график работы", language="ru", confidence=0.84)
|
||||
|
||||
def close_stream(self, stream_id: str) -> None:
|
||||
del stream_id
|
||||
self.events.append(("close", time.monotonic()))
|
||||
|
||||
media_uuid = str(uuid.uuid4())
|
||||
registrations[media_uuid] = MediaRegistration(
|
||||
voice_session_id="avs_media_runtime_v2_streaming",
|
||||
call_id="call_media_runtime_v2_streaming",
|
||||
interaction_id="int_media_runtime_v2_streaming",
|
||||
ai_session_id="ais_media_runtime_v2_streaming",
|
||||
language="ru",
|
||||
media_uuid=media_uuid,
|
||||
queue_code="voice_lab_ai",
|
||||
queue_id="que_voice_lab_ai",
|
||||
agent_profile="voice_support",
|
||||
voice_v2_enabled=True,
|
||||
voice_v2_ack_mode="immediate_short",
|
||||
voice_v2_streaming_tts=True,
|
||||
voice_v2_partial_asr=True,
|
||||
voice_v2_duplex=True,
|
||||
voice_v2_streaming_asr_backend="local_sidecar",
|
||||
)
|
||||
streaming_provider = _FakeStreamingASRProvider()
|
||||
|
||||
runtime = AudioSocketMediaRuntime(
|
||||
enabled=True,
|
||||
host="127.0.0.1",
|
||||
port=0,
|
||||
frame_ms=20,
|
||||
idle_timeout_seconds=2.0,
|
||||
registration_wait_timeout_seconds=0.5,
|
||||
min_speech_ms=40,
|
||||
trailing_silence_ms=40,
|
||||
max_turn_ms=2000,
|
||||
asr_provider=_ExplodingBatchASRProvider(),
|
||||
streaming_asr_provider=streaming_provider,
|
||||
tts_provider=_StubTTSProvider(),
|
||||
load_registration_by_media_uuid=lambda value: registrations.get(value),
|
||||
mark_media_connected=lambda session_id, value: None,
|
||||
mark_media_ended=lambda session_id, reason: None,
|
||||
touch_media_frame=lambda session_id: None,
|
||||
set_state=lambda session_id, state, handoff_reason, metadata: None,
|
||||
get_pending_greeting=lambda session_id: None,
|
||||
mark_reply_started=lambda session_id, text, is_greeting, phase: reply_starts.append((text, phase, time.monotonic())),
|
||||
mark_reply_delivered=lambda session_id, text, is_greeting: delivered.append((text, is_greeting)),
|
||||
plan_reply=lambda session_id, text, metadata, kind: None,
|
||||
process_turn=lambda session_id, transcript_text, language, barge_in, metadata: (
|
||||
turns.append(transcript_text)
|
||||
or VoiceAITurnDecisionOut(
|
||||
language=language or "ru",
|
||||
intent="clarification",
|
||||
reply_text="Назовите, пожалуйста, город.",
|
||||
confidence=0.9,
|
||||
needs_handoff=False,
|
||||
handoff_reason=None,
|
||||
case_action="keep_open",
|
||||
kb_refs=[],
|
||||
summary_text="reply ready",
|
||||
model="stub-voice",
|
||||
latency_ms=1,
|
||||
status="active",
|
||||
)
|
||||
),
|
||||
request_handoff=lambda session_id, customer_request_text, decision: None,
|
||||
handle_media_error=lambda session_id, message, metadata: None,
|
||||
)
|
||||
|
||||
async def _scenario() -> None:
|
||||
await runtime.start()
|
||||
port = runtime._server.sockets[0].getsockname()[1]
|
||||
|
||||
reader, writer = await asyncio.open_connection("127.0.0.1", port)
|
||||
writer.write(encode_packet(AUDIO_SOCKET_PACKET_UUID, uuid.UUID(media_uuid).bytes))
|
||||
await writer.drain()
|
||||
|
||||
speech_frame = (1000).to_bytes(2, "little", signed=True) * 160
|
||||
silence_frame = b"\x00\x00" * 160
|
||||
for _ in range(14):
|
||||
writer.write(encode_audio_packet(speech_frame))
|
||||
for _ in range(2):
|
||||
writer.write(encode_audio_packet(silence_frame))
|
||||
await writer.drain()
|
||||
|
||||
deadline = time.time() + 2.5
|
||||
while time.time() < deadline:
|
||||
if len(reply_starts) >= 2 and turns:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
await asyncio.sleep(0.2)
|
||||
await runtime.stop()
|
||||
|
||||
asyncio.run(_scenario())
|
||||
|
||||
phases = [phase for _, phase, _ in reply_starts]
|
||||
finalize_started_at = next(ts for name, ts in streaming_provider.events if name == "finalize")
|
||||
assert turns == ["мне нужен график работы"]
|
||||
assert "open" in [name for name, _ in streaming_provider.events]
|
||||
assert "close" in [name for name, _ in streaming_provider.events]
|
||||
assert phases[:2] == ["ack", "main"]
|
||||
assert reply_starts[0][2] < finalize_started_at
|
||||
assert any(text == "Назовите, пожалуйста, город." and phase == "main" for text, phase, _ in reply_starts)
|
||||
|
||||
|
||||
def test_media_runtime_voice_v2_falls_back_when_streaming_sidecar_is_unavailable():
|
||||
registrations: dict[str, MediaRegistration] = {}
|
||||
turns: list[str] = []
|
||||
|
||||
class _BatchASRProvider(ASRProvider):
|
||||
name = "batch-asr"
|
||||
|
||||
def transcribe(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription:
|
||||
assert audio_bytes
|
||||
return ASRTranscription(text="нужен оператор", language=language_hint or "ru", confidence=0.88)
|
||||
|
||||
class _UnavailableStreamingProvider(StreamingASRProvider):
|
||||
name = "missing-sidecar"
|
||||
supports_streaming = True
|
||||
|
||||
def open_stream(self, session_id: str, *, language_hint: str | None = None) -> str:
|
||||
del session_id, language_hint
|
||||
raise StreamingASRUnavailable("sidecar down")
|
||||
|
||||
media_uuid = str(uuid.uuid4())
|
||||
registrations[media_uuid] = MediaRegistration(
|
||||
voice_session_id="avs_media_runtime_v2_fallback",
|
||||
call_id="call_media_runtime_v2_fallback",
|
||||
interaction_id="int_media_runtime_v2_fallback",
|
||||
ai_session_id="ais_media_runtime_v2_fallback",
|
||||
language="ru",
|
||||
media_uuid=media_uuid,
|
||||
queue_code="voice_lab_ai",
|
||||
queue_id="que_voice_lab_ai",
|
||||
agent_profile="voice_support",
|
||||
voice_v2_enabled=True,
|
||||
voice_v2_ack_mode="immediate_short",
|
||||
voice_v2_streaming_tts=True,
|
||||
voice_v2_partial_asr=True,
|
||||
voice_v2_duplex=True,
|
||||
voice_v2_streaming_asr_backend="local_sidecar",
|
||||
)
|
||||
|
||||
runtime = AudioSocketMediaRuntime(
|
||||
enabled=True,
|
||||
host="127.0.0.1",
|
||||
port=0,
|
||||
frame_ms=20,
|
||||
idle_timeout_seconds=2.0,
|
||||
registration_wait_timeout_seconds=0.5,
|
||||
min_speech_ms=40,
|
||||
trailing_silence_ms=40,
|
||||
max_turn_ms=2000,
|
||||
asr_provider=_BatchASRProvider(),
|
||||
streaming_asr_provider=_UnavailableStreamingProvider(),
|
||||
tts_provider=_StubTTSProvider(),
|
||||
load_registration_by_media_uuid=lambda value: registrations.get(value),
|
||||
mark_media_connected=lambda session_id, value: None,
|
||||
mark_media_ended=lambda session_id, reason: None,
|
||||
touch_media_frame=lambda session_id: None,
|
||||
set_state=lambda session_id, state, handoff_reason, metadata: None,
|
||||
get_pending_greeting=lambda session_id: None,
|
||||
mark_reply_delivered=lambda session_id, text, is_greeting: None,
|
||||
plan_reply=lambda session_id, text, metadata, kind: None,
|
||||
process_turn=lambda session_id, transcript_text, language, barge_in, metadata: (
|
||||
turns.append(transcript_text)
|
||||
or VoiceAITurnDecisionOut(
|
||||
language=language or "ru",
|
||||
intent="handoff",
|
||||
reply_text="Соединяю с оператором.",
|
||||
confidence=0.9,
|
||||
needs_handoff=False,
|
||||
handoff_reason=None,
|
||||
case_action="keep_open",
|
||||
kb_refs=[],
|
||||
summary_text="reply ready",
|
||||
model="stub-voice",
|
||||
latency_ms=1,
|
||||
status="active",
|
||||
)
|
||||
),
|
||||
request_handoff=lambda session_id, customer_request_text, decision: None,
|
||||
handle_media_error=lambda session_id, message, metadata: None,
|
||||
)
|
||||
|
||||
async def _scenario() -> None:
|
||||
await runtime.start()
|
||||
port = runtime._server.sockets[0].getsockname()[1]
|
||||
|
||||
reader, writer = await asyncio.open_connection("127.0.0.1", port)
|
||||
writer.write(encode_packet(AUDIO_SOCKET_PACKET_UUID, uuid.UUID(media_uuid).bytes))
|
||||
await writer.drain()
|
||||
|
||||
speech_frame = (1000).to_bytes(2, "little", signed=True) * 160
|
||||
silence_frame = b"\x00\x00" * 160
|
||||
for _ in range(2):
|
||||
writer.write(encode_audio_packet(speech_frame))
|
||||
for _ in range(2):
|
||||
writer.write(encode_audio_packet(silence_frame))
|
||||
await writer.drain()
|
||||
|
||||
deadline = time.time() + 2.0
|
||||
while time.time() < deadline:
|
||||
if turns:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
await asyncio.sleep(0.2)
|
||||
await runtime.stop()
|
||||
|
||||
asyncio.run(_scenario())
|
||||
|
||||
assert turns == ["нужен оператор"]
|
||||
assert registrations[media_uuid].voice_v2_duplex is False
|
||||
assert registrations[media_uuid].voice_v2_partial_asr is False
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import base64
|
||||
import time
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import services.streaming_asr_sidecar_service.app as sidecar_module
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
return {"X-User": "admin", "X-Role": "admin"}
|
||||
|
||||
|
||||
def _pcm_chunk(duration_ms: int = 400, *, amplitude: int = 1000) -> bytes:
|
||||
sample_count = int(8000 * (duration_ms / 1000.0))
|
||||
return int(amplitude).to_bytes(2, "little", signed=True) * sample_count
|
||||
|
||||
|
||||
class _FakeEngine:
|
||||
def __init__(self, *, text: str = "привет") -> None:
|
||||
self.text = text
|
||||
self.calls: list[tuple[int, int, str | None]] = []
|
||||
|
||||
def transcribe_pcm(
|
||||
self,
|
||||
pcm_bytes: bytes,
|
||||
*,
|
||||
sample_rate_hz: int,
|
||||
language_hint: str | None = None,
|
||||
) -> sidecar_module.SidecarTranscript:
|
||||
self.calls.append((len(pcm_bytes), sample_rate_hz, language_hint))
|
||||
return sidecar_module.SidecarTranscript(
|
||||
text=self.text,
|
||||
language=language_hint or "ru",
|
||||
confidence=0.91,
|
||||
)
|
||||
|
||||
|
||||
def _reset_sidecar(
|
||||
monkeypatch,
|
||||
*,
|
||||
engine: _FakeEngine | None = None,
|
||||
idle_ttl_seconds: float = 45.0,
|
||||
supported_languages: set[str] | None = None,
|
||||
) -> _FakeEngine:
|
||||
fake_engine = engine or _FakeEngine()
|
||||
monkeypatch.setattr(sidecar_module, "_ENGINE_INSTANCE", None)
|
||||
monkeypatch.setattr(sidecar_module, "_ENGINE_FACTORY", lambda: fake_engine)
|
||||
monkeypatch.setattr(sidecar_module, "_STREAMS", sidecar_module.StreamStore(idle_ttl_seconds=idle_ttl_seconds))
|
||||
monkeypatch.setattr(
|
||||
sidecar_module,
|
||||
"_supported_languages",
|
||||
lambda: set(supported_languages or {"ru"}),
|
||||
)
|
||||
return fake_engine
|
||||
|
||||
|
||||
def test_streaming_asr_sidecar_stream_lifecycle(monkeypatch):
|
||||
engine = _reset_sidecar(monkeypatch)
|
||||
client = TestClient(sidecar_module.app)
|
||||
|
||||
open_response = client.post(
|
||||
"/internal/asr/streams",
|
||||
headers=_admin_headers(),
|
||||
json={
|
||||
"session_id": "avs_sidecar_1",
|
||||
"language_hint": "ru",
|
||||
"sample_rate_hz": 8000,
|
||||
"encoding": "pcm_s16le",
|
||||
},
|
||||
)
|
||||
assert open_response.status_code == 200
|
||||
stream_id = open_response.json()["stream_id"]
|
||||
|
||||
chunk = _pcm_chunk(400)
|
||||
push_response = client.post(
|
||||
f"/internal/asr/streams/{stream_id}/chunks",
|
||||
headers=_admin_headers(),
|
||||
json={
|
||||
"pcm_b64": base64.b64encode(chunk).decode("ascii"),
|
||||
"sample_rate_hz": 8000,
|
||||
"encoding": "pcm_s16le",
|
||||
},
|
||||
)
|
||||
assert push_response.status_code == 200
|
||||
assert push_response.json()["received_bytes"] == len(chunk)
|
||||
|
||||
partial_one = client.get(
|
||||
f"/internal/asr/streams/{stream_id}/partial",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert partial_one.status_code == 200
|
||||
first_payload = partial_one.json()
|
||||
assert first_payload["text"] == "привет"
|
||||
assert first_payload["is_stable"] is False
|
||||
|
||||
second_chunk = _pcm_chunk(80)
|
||||
client.post(
|
||||
f"/internal/asr/streams/{stream_id}/chunks",
|
||||
headers=_admin_headers(),
|
||||
json={
|
||||
"pcm_b64": base64.b64encode(second_chunk).decode("ascii"),
|
||||
"sample_rate_hz": 8000,
|
||||
"encoding": "pcm_s16le",
|
||||
},
|
||||
)
|
||||
partial_two = client.get(
|
||||
f"/internal/asr/streams/{stream_id}/partial",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert partial_two.status_code == 200
|
||||
second_payload = partial_two.json()
|
||||
assert second_payload["text"] == "привет"
|
||||
assert second_payload["is_stable"] is True
|
||||
|
||||
finalize_response = client.post(
|
||||
f"/internal/asr/streams/{stream_id}/finalize",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert finalize_response.status_code == 200
|
||||
assert finalize_response.json() == {
|
||||
"text": "привет",
|
||||
"language": "ru",
|
||||
"confidence": 0.91,
|
||||
}
|
||||
|
||||
close_response = client.delete(
|
||||
f"/internal/asr/streams/{stream_id}",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert close_response.status_code == 200
|
||||
assert close_response.json() == {"ok": True, "stream_id": stream_id}
|
||||
assert len(engine.calls) >= 3
|
||||
|
||||
|
||||
def test_streaming_asr_sidecar_rejects_unsupported_language(monkeypatch):
|
||||
_reset_sidecar(monkeypatch, supported_languages={"ru"})
|
||||
client = TestClient(sidecar_module.app)
|
||||
|
||||
response = client.post(
|
||||
"/internal/asr/streams",
|
||||
headers=_admin_headers(),
|
||||
json={
|
||||
"session_id": "avs_sidecar_kk",
|
||||
"language_hint": "kk",
|
||||
"sample_rate_hz": 8000,
|
||||
"encoding": "pcm_s16le",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Unsupported streaming ASR language" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_streaming_asr_sidecar_cleans_up_idle_streams(monkeypatch):
|
||||
_reset_sidecar(monkeypatch, idle_ttl_seconds=0.01)
|
||||
client = TestClient(sidecar_module.app)
|
||||
|
||||
open_response = client.post(
|
||||
"/internal/asr/streams",
|
||||
headers=_admin_headers(),
|
||||
json={
|
||||
"session_id": "avs_sidecar_idle",
|
||||
"language_hint": "ru",
|
||||
"sample_rate_hz": 8000,
|
||||
"encoding": "pcm_s16le",
|
||||
},
|
||||
)
|
||||
assert open_response.status_code == 200
|
||||
stream_id = open_response.json()["stream_id"]
|
||||
|
||||
with sidecar_module._STREAMS._lock:
|
||||
sidecar_module._STREAMS._streams[stream_id].last_activity_monotonic = time.monotonic() - 1.0
|
||||
|
||||
health_response = client.get("/health")
|
||||
assert health_response.status_code == 200
|
||||
|
||||
missing_response = client.get(
|
||||
f"/internal/asr/streams/{stream_id}/partial",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert missing_response.status_code == 404
|
||||
Reference in New Issue
Block a user