상담자 발화 판정(A)·감정(B)·표현(C) 20문항 질문 세트, 감쇠 없는 이번 턴 반응과 비대칭 기분 전이, 개방도 게이트로 생성 지시를 만들고 ccd.coping_strategy 전달 누락을 고친다. trace v2와 고정 문구 속마음 요약(migration 24, AI 경로 차단 RLS)을 같은 트랜잭션에 저장하고 피드백 정책이 켜진 경우에만 done·TurnResponse·음성 reply·리뷰로 노출한다. 회기 화면 속마음 보기 토글, 리뷰 접힘 블록, 관리자 감정 관측 v2 표시를 추가한다.
1937 lines
69 KiB
Python
1937 lines
69 KiB
Python
"""Voice routes for the OpenAI STT/TTS cascade over WebSocket.
|
|
|
|
Client sends JSON controls plus binary audio chunks:
|
|
audio_start -> binary audio chunks -> audio_end
|
|
|
|
Server emits:
|
|
ready -> state(listening) -> state(thinking) -> transcript -> reply
|
|
-> state(speaking) -> binary audio chunks -> tts_end -> state(idle)
|
|
|
|
When voice is not configured, the route reports degraded state and closes
|
|
cleanly instead of crashing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import hashlib
|
|
import hmac
|
|
import time
|
|
from dataclasses import dataclass, field as dataclass_field
|
|
from typing import Optional
|
|
from uuid import NAMESPACE_URL, uuid5
|
|
|
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException, status
|
|
from fastapi.responses import JSONResponse, Response
|
|
from pydantic import BaseModel, Field
|
|
from starlette.websockets import WebSocketState
|
|
|
|
from .. import session_persistence, turn_runtime
|
|
from ..auth_sessions import get_session, user_has_consent, user_onboarding_complete
|
|
from ..config import settings
|
|
from ..deps import CurrentPrincipal, Principal, Role
|
|
from ..engine_client import EngineError, engine_client
|
|
from ..persona_repository import (
|
|
PersonaVoiceMap,
|
|
get_catalog_persona,
|
|
get_persona_voice_map,
|
|
get_session_voice_map,
|
|
)
|
|
from ..runtime_policy import require_runtime_fallback_allowed
|
|
from ..session_turn_memory import build_turn_memory
|
|
from ..services import (
|
|
evaluator,
|
|
feedback_policy,
|
|
guardrail,
|
|
inner_reaction_exposure,
|
|
multimodal_alliance,
|
|
multimodal_alliance_store,
|
|
orchestrator,
|
|
rupture_scenario_director,
|
|
state_machine,
|
|
)
|
|
from ..services import voice as voice_svc
|
|
from ..services.voice import (
|
|
StreamingTranscriptEvent,
|
|
TranscriptResult,
|
|
VoicePreset,
|
|
VoiceUnavailable,
|
|
resolve_voice,
|
|
voice_service,
|
|
)
|
|
from ..services.voice_runtime import (
|
|
VOICE_AUDIO_BUFFER_MAX_BYTES,
|
|
VOICE_STREAMING_EVENT_QUEUE_MAX_ITEMS,
|
|
VOICE_UVICORN_WS_MAX_QUEUE,
|
|
voice_runtime_metrics,
|
|
)
|
|
from ..store import InProcSession, TurnRecord, store
|
|
|
|
router = APIRouter(prefix="/voice", tags=["voice"])
|
|
|
|
|
|
class VoiceSpeechRequest(BaseModel):
|
|
"""Request OpenAI TTS for an already-persisted client reply."""
|
|
|
|
session_id: str = Field(min_length=1, max_length=80)
|
|
turn_seq: int = Field(ge=1)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class VoiceSessionContext:
|
|
session_id: str
|
|
principal: Principal
|
|
voice_preset: VoicePreset
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class VoiceProsody:
|
|
audio_ref: str | None = None
|
|
duration_s: float | None = None
|
|
silence_ms: int | None = None
|
|
speech_rate: float | None = None
|
|
barge_in: bool | None = None
|
|
provider_events: list[dict[str, object]] = dataclass_field(default_factory=list)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class VoiceTurnInput:
|
|
learner_text: str
|
|
prosody: VoiceProsody = dataclass_field(default_factory=VoiceProsody)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class VoiceAudioInput:
|
|
audio: bytes
|
|
fmt: str | None
|
|
sample_rate: int | None = None
|
|
channels: int | None = None
|
|
sample_width: int | None = None
|
|
audio_started_at: float | None = None
|
|
audio_ended_at: float | None = None
|
|
prosody: VoiceProsody = dataclass_field(default_factory=VoiceProsody)
|
|
|
|
|
|
# WebSocket close codes.
|
|
WS_CLOSE_DEGRADED = 1011
|
|
WS_CLOSE_BAD_REQUEST = 1008
|
|
WS_CLOSE_UNAUTHORIZED = 1008
|
|
|
|
# Per-utterance audio cap to avoid unbounded memory growth.
|
|
_MAX_AUDIO_BYTES = VOICE_AUDIO_BUFFER_MAX_BYTES
|
|
_STREAMING_EVENT_QUEUE_MAX_ITEMS = VOICE_STREAMING_EVENT_QUEUE_MAX_ITEMS
|
|
_STREAMING_CONSENT_RECHECK_SECONDS = 1.0
|
|
_PROVIDER_EVENT_MAX_ITEMS = 12
|
|
_PROVIDER_EVENT_MAX_STRING = 80
|
|
_PROVIDER_EVENT_ALLOWED_KEYS = {
|
|
"category",
|
|
"event_type",
|
|
"type",
|
|
"kind",
|
|
"label",
|
|
"source",
|
|
"provider",
|
|
"model",
|
|
"start_ms",
|
|
"end_ms",
|
|
"duration_ms",
|
|
"confidence",
|
|
"score",
|
|
"is_final",
|
|
}
|
|
_PROVIDER_EVENT_TAXONOMY = {
|
|
"barge_in": ("barge_in", "turn_taking"),
|
|
"interrupt": ("barge_in", "turn_taking"),
|
|
"interruption": ("barge_in", "turn_taking"),
|
|
"overlap": ("barge_in", "turn_taking"),
|
|
"sigh": ("sigh", "paralinguistic"),
|
|
"sighing": ("sigh", "paralinguistic"),
|
|
"sob": ("cry", "paralinguistic"),
|
|
"cry": ("cry", "paralinguistic"),
|
|
"crying": ("cry", "paralinguistic"),
|
|
"weep": ("cry", "paralinguistic"),
|
|
"laugh": ("laugh", "paralinguistic"),
|
|
"laughter": ("laugh", "paralinguistic"),
|
|
"breath": ("breath", "paralinguistic"),
|
|
"breathing": ("breath", "paralinguistic"),
|
|
"voice_activity": ("voice_activity", "speech_activity"),
|
|
"vad": ("voice_activity", "speech_activity"),
|
|
"speech_start": ("speech_start", "speech_activity"),
|
|
"speech_end": ("speech_end", "speech_activity"),
|
|
"speech_final": ("speech_final", "speech_activity"),
|
|
"stt_word": ("stt_word", "timing"),
|
|
"silence": ("silence", "timing"),
|
|
"pause": ("silence", "timing"),
|
|
"long_pause": ("silence", "timing"),
|
|
"speech_rate": ("speech_rate", "prosody"),
|
|
"fast_speech": ("speech_rate", "prosody"),
|
|
"slow_speech": ("speech_rate", "prosody"),
|
|
"pitch": ("pitch", "prosody"),
|
|
"intonation": ("intonation", "prosody"),
|
|
"prosody": ("prosody", "prosody"),
|
|
"noise": ("background_noise", "audio_quality"),
|
|
"background_noise": ("background_noise", "audio_quality"),
|
|
}
|
|
|
|
|
|
def _is_turn_persistence_unavailable(exc: Exception) -> bool:
|
|
if not isinstance(exc, HTTPException) or exc.status_code != 503:
|
|
return False
|
|
detail = str(exc.detail or "")
|
|
return "turn append" in detail and "persistence unavailable" in detail
|
|
|
|
|
|
_PROVIDER_EVENT_TYPE_FIELDS = ("event_type", "type", "kind", "label")
|
|
|
|
|
|
def _append_audio_chunk_with_cap(buffer: bytearray, chunk: bytes) -> bool:
|
|
"""Append only when the route-owned buffer remains within its hard cap."""
|
|
|
|
if len(chunk) > _MAX_AUDIO_BYTES - len(buffer):
|
|
return False
|
|
buffer.extend(chunk)
|
|
return True
|
|
|
|
|
|
@router.get("/health")
|
|
async def voice_health() -> JSONResponse:
|
|
"""Return voice service readiness."""
|
|
available = voice_service.is_available()
|
|
stt_available = voice_service.stt_available()
|
|
tts_available = voice_service.tts_available()
|
|
tts_provider = voice_service.tts_provider()
|
|
body = {
|
|
"status": "ok" if available else "degraded",
|
|
"available": available,
|
|
"stt_available": stt_available,
|
|
"tts_available": tts_available,
|
|
"stt_provider": voice_service.stt_provider(),
|
|
"stt_model": voice_service.stt_model(),
|
|
"stt_batch_fallback_available": voice_service.batch_stt_available(),
|
|
"tts_model": (
|
|
voice_svc.MELOTTS_TTS_MODEL
|
|
if tts_provider == "melotts"
|
|
else (
|
|
voice_svc.HIGGS_TTS_MODEL
|
|
if tts_provider == "higgs"
|
|
else voice_svc.TTS_MODEL
|
|
)
|
|
),
|
|
"tts_provider": tts_provider,
|
|
"limits": {
|
|
"max_utterance_audio_bytes": _MAX_AUDIO_BYTES,
|
|
"streaming_event_queue_max_items": _STREAMING_EVENT_QUEUE_MAX_ITEMS,
|
|
"uvicorn_ws_max_queue": VOICE_UVICORN_WS_MAX_QUEUE,
|
|
},
|
|
"reason": (
|
|
None
|
|
if available
|
|
else "STT 또는 TTS provider가 준비되지 않았습니다"
|
|
),
|
|
}
|
|
return JSONResponse(body, status_code=200 if available else 503)
|
|
|
|
|
|
@router.post("/speech")
|
|
async def voice_speech(
|
|
body: VoiceSpeechRequest, principal: CurrentPrincipal
|
|
) -> Response:
|
|
"""Synthesize the persisted client reply for a completed text turn.
|
|
|
|
The browser sends only session/turn identifiers. The server reloads the
|
|
owned session and speaks the stored client-visible reply, so this endpoint
|
|
cannot be used as an arbitrary paid text-to-speech proxy.
|
|
"""
|
|
learner = principal
|
|
if learner.role != Role.LEARNER and learner.can_access_role(Role.LEARNER):
|
|
learner = learner.with_role(Role.LEARNER)
|
|
if learner.role != Role.LEARNER:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="only learners can use voice",
|
|
)
|
|
|
|
access_error = await _practice_access_error(learner)
|
|
if access_error is not None:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=access_error)
|
|
sess, err = await turn_runtime.load_owned_session(
|
|
body.session_id,
|
|
learner,
|
|
allow_ended=False,
|
|
)
|
|
if sess is None:
|
|
status_code = {
|
|
turn_runtime.SessionAccessError.FORBIDDEN: status.HTTP_403_FORBIDDEN,
|
|
turn_runtime.SessionAccessError.ENDED: status.HTTP_409_CONFLICT,
|
|
}.get(err, status.HTTP_404_NOT_FOUND)
|
|
raise HTTPException(
|
|
status_code=status_code, detail=f"voice session {err or 'not_found'}"
|
|
)
|
|
|
|
text = _client_turn_text_for_speech(sess, body.turn_seq)
|
|
if text is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="client reply not found for turn",
|
|
)
|
|
|
|
voice_preset = await _resolve_session_voice(
|
|
session_id=body.session_id,
|
|
persona_code=sess.persona.code,
|
|
explicit_preset=None,
|
|
)
|
|
if not voice_service.tts_available(voice_preset):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="TTS provider is not configured",
|
|
)
|
|
try:
|
|
chunks = [
|
|
chunk.audio
|
|
async for chunk in voice_service.synthesize_stream(text, voice_preset)
|
|
]
|
|
except VoiceUnavailable as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail=str(exc),
|
|
) from exc
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"TTS failed: {exc}",
|
|
) from exc
|
|
|
|
audio = b"".join(chunks)
|
|
if not audio:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="client reply has no speakable text",
|
|
)
|
|
return Response(
|
|
content=audio,
|
|
media_type=voice_service.tts_media_type_for_voice(voice_preset),
|
|
headers={
|
|
"Cache-Control": "no-store",
|
|
"X-Vignette-TTS-Model": voice_service.tts_model_for_voice(voice_preset),
|
|
"X-Vignette-TTS-Provider": voice_service.tts_provider_for_voice(voice_preset),
|
|
},
|
|
)
|
|
|
|
|
|
@router.websocket("/ws")
|
|
async def voice_ws(websocket: WebSocket) -> None:
|
|
"""Run one authenticated learner voice cascade."""
|
|
await websocket.accept()
|
|
|
|
# Authenticate the same server-side browser session used by REST routes.
|
|
principal = await _principal_from_websocket(websocket)
|
|
if principal is None:
|
|
await _safe_send_json(
|
|
websocket, {"type": "error", "detail": "not authenticated"}
|
|
)
|
|
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
|
|
return
|
|
if principal.role != Role.LEARNER and principal.can_access_role(Role.LEARNER):
|
|
principal = principal.with_role(Role.LEARNER)
|
|
if principal.role != Role.LEARNER:
|
|
await _safe_send_json(
|
|
websocket, {"type": "error", "detail": "only learners can use voice"}
|
|
)
|
|
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
|
|
return
|
|
|
|
# Bind to an existing session first. persona_code creation is dev-only.
|
|
session_id, voice_preset, err, bind_meta = await _bind_session(websocket, principal)
|
|
if err is not None:
|
|
await _safe_send_json(websocket, {"type": "error", "detail": err})
|
|
await _safe_close(websocket, WS_CLOSE_BAD_REQUEST)
|
|
return
|
|
assert session_id is not None and voice_preset is not None
|
|
if bind_meta.get("degraded"):
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "degraded",
|
|
"reason": bind_meta.get(
|
|
"degraded_reason", "voice session binding degraded"
|
|
),
|
|
**bind_meta,
|
|
},
|
|
)
|
|
|
|
# Voice misconfiguration is reported explicitly and then closed cleanly.
|
|
if not voice_service.is_available():
|
|
await _safe_send_json(
|
|
websocket,
|
|
{"type": "degraded", "reason": "voice STT/TTS is not configured"},
|
|
)
|
|
await _safe_close(websocket, WS_CLOSE_DEGRADED)
|
|
return
|
|
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "ready",
|
|
"session_id": session_id,
|
|
"voice": voice_preset.openai_voice,
|
|
"preset": voice_preset.preset,
|
|
"stt_provider": voice_service.stt_provider(),
|
|
"stt_model": voice_service.stt_model(),
|
|
"stt_batch_fallback_available": voice_service.batch_stt_available(),
|
|
"tts_provider": voice_service.tts_provider_for_voice(voice_preset),
|
|
"tts_model": voice_service.tts_model_for_voice(voice_preset),
|
|
"limits": {
|
|
"max_utterance_audio_bytes": _MAX_AUDIO_BYTES,
|
|
"streaming_event_queue_max_items": _STREAMING_EVENT_QUEUE_MAX_ITEMS,
|
|
"uvicorn_ws_max_queue": VOICE_UVICORN_WS_MAX_QUEUE,
|
|
},
|
|
"state": "idle",
|
|
**bind_meta,
|
|
},
|
|
)
|
|
|
|
runtime_connection_id = voice_runtime_metrics.websocket_opened()
|
|
|
|
audio_buf = bytearray()
|
|
receiving = False
|
|
audio_started_at: float | None = None
|
|
last_audio_end_at: float | None = None
|
|
audio_format: str | None = None
|
|
audio_sample_rate: int | None = None
|
|
audio_channels: int | None = None
|
|
audio_sample_width: int | None = None
|
|
streaming_session: voice_svc.DeepgramStreamingSession | None = None
|
|
streaming_consent_checked_at: float | None = None
|
|
streaming_events: asyncio.Queue[StreamingTranscriptEvent] = asyncio.Queue(
|
|
maxsize=_STREAMING_EVENT_QUEUE_MAX_ITEMS
|
|
)
|
|
discard_audio_until_end = False
|
|
last_stream_transcript: tuple[str, bool] | None = None
|
|
|
|
try:
|
|
while True:
|
|
msg = await websocket.receive()
|
|
mtype = msg.get("type")
|
|
if mtype == "websocket.disconnect":
|
|
break
|
|
|
|
# Binary frames are audio chunks.
|
|
if msg.get("bytes") is not None:
|
|
if discard_audio_until_end:
|
|
continue
|
|
if not receiving:
|
|
# Be tolerant when audio arrives before audio_start.
|
|
receiving = True
|
|
audio_started_at = time.monotonic()
|
|
audio_buf.clear()
|
|
voice_runtime_metrics.audio_buffer_cleared(
|
|
runtime_connection_id
|
|
)
|
|
await _safe_send_json(
|
|
websocket, {"type": "state", "state": "listening"}
|
|
)
|
|
chunk = msg["bytes"]
|
|
if not _append_audio_chunk_with_cap(audio_buf, chunk):
|
|
voice_runtime_metrics.audio_overflow_rejected(
|
|
runtime_connection_id
|
|
)
|
|
if streaming_session is not None:
|
|
await streaming_session.abort()
|
|
streaming_session = None
|
|
streaming_consent_checked_at = None
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "error",
|
|
"detail": "audio too large; please send a shorter utterance",
|
|
},
|
|
)
|
|
await _safe_send_json(
|
|
websocket, {"type": "state", "state": "idle"}
|
|
)
|
|
audio_buf.clear()
|
|
voice_runtime_metrics.audio_buffer_cleared(
|
|
runtime_connection_id
|
|
)
|
|
receiving = False
|
|
discard_audio_until_end = True
|
|
continue
|
|
voice_runtime_metrics.audio_chunk_received(
|
|
runtime_connection_id,
|
|
current_buffer_bytes=len(audio_buf),
|
|
chunk_bytes=len(chunk),
|
|
)
|
|
if streaming_session is not None:
|
|
now = time.monotonic()
|
|
if (
|
|
streaming_consent_checked_at is None
|
|
or now - streaming_consent_checked_at
|
|
>= _STREAMING_CONSENT_RECHECK_SECONDS
|
|
):
|
|
if not await _multimodal_voice_processing_allowed(
|
|
websocket,
|
|
session_id=session_id,
|
|
principal=principal,
|
|
):
|
|
await streaming_session.abort()
|
|
streaming_session = None
|
|
streaming_consent_checked_at = None
|
|
discard_audio_until_end = True
|
|
receiving = False
|
|
continue
|
|
streaming_consent_checked_at = now
|
|
try:
|
|
await streaming_session.send_audio(chunk)
|
|
await asyncio.sleep(0)
|
|
drained = await _drain_streaming_transcripts(
|
|
websocket, streaming_events
|
|
)
|
|
voice_runtime_metrics.streaming_queue_observed(
|
|
runtime_connection_id,
|
|
queue_items=streaming_events.qsize(),
|
|
)
|
|
if drained is not None:
|
|
last_stream_transcript = drained
|
|
except Exception:
|
|
await streaming_session.abort()
|
|
streaming_session = None
|
|
streaming_consent_checked_at = None
|
|
if voice_service.batch_stt_available():
|
|
voice_runtime_metrics.provider_fallback()
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "degraded",
|
|
"reason": "streaming STT unavailable; using batch fallback",
|
|
},
|
|
)
|
|
else:
|
|
discard_audio_until_end = True
|
|
receiving = False
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "error",
|
|
"code": "streaming_stt_unavailable",
|
|
"detail": "streaming STT failed and no batch fallback is configured",
|
|
},
|
|
)
|
|
await _safe_send_json(
|
|
websocket, {"type": "state", "state": "idle"}
|
|
)
|
|
continue
|
|
|
|
# Text frames are JSON controls.
|
|
text = msg.get("text")
|
|
if text is None:
|
|
continue
|
|
try:
|
|
ctrl = json.loads(text)
|
|
except (json.JSONDecodeError, TypeError):
|
|
await _safe_send_json(
|
|
websocket, {"type": "error", "detail": "invalid control json"}
|
|
)
|
|
continue
|
|
|
|
ctype = ctrl.get("type")
|
|
if ctype == "audio_start":
|
|
if streaming_session is not None:
|
|
await streaming_session.abort()
|
|
streaming_session = None
|
|
streaming_consent_checked_at = None
|
|
receiving = True
|
|
discard_audio_until_end = False
|
|
last_stream_transcript = None
|
|
while not streaming_events.empty():
|
|
streaming_events.get_nowait()
|
|
voice_runtime_metrics.streaming_queue_observed(
|
|
runtime_connection_id,
|
|
queue_items=0,
|
|
)
|
|
audio_started_at = time.monotonic()
|
|
audio_format = _safe_str(ctrl.get("format"))
|
|
audio_sample_rate = _safe_int(ctrl.get("sample_rate"))
|
|
audio_channels = _safe_int(ctrl.get("channels"))
|
|
audio_sample_width = _safe_int(ctrl.get("sample_width"))
|
|
audio_buf.clear()
|
|
voice_runtime_metrics.audio_buffer_cleared(runtime_connection_id)
|
|
if voice_service.can_stream_audio(
|
|
fmt=audio_format,
|
|
sample_rate=audio_sample_rate,
|
|
channels=audio_channels,
|
|
sample_width=audio_sample_width,
|
|
):
|
|
if not await _multimodal_voice_processing_allowed(
|
|
websocket,
|
|
session_id=session_id,
|
|
principal=principal,
|
|
):
|
|
receiving = False
|
|
discard_audio_until_end = True
|
|
else:
|
|
async def queue_streaming_event(
|
|
event: StreamingTranscriptEvent,
|
|
) -> None:
|
|
queue_was_full = streaming_events.full()
|
|
queue_wait_started = time.perf_counter()
|
|
await streaming_events.put(event)
|
|
voice_runtime_metrics.streaming_queue_observed(
|
|
runtime_connection_id,
|
|
queue_items=streaming_events.qsize(),
|
|
saturated=queue_was_full,
|
|
wait_seconds=(
|
|
time.perf_counter() - queue_wait_started
|
|
),
|
|
)
|
|
|
|
try:
|
|
streaming_session = (
|
|
await voice_service.open_streaming_transcription(
|
|
fmt=audio_format,
|
|
sample_rate=audio_sample_rate,
|
|
channels=audio_channels,
|
|
sample_width=audio_sample_width,
|
|
on_event=queue_streaming_event,
|
|
)
|
|
)
|
|
streaming_consent_checked_at = time.monotonic()
|
|
except Exception:
|
|
if voice_service.batch_stt_available():
|
|
voice_runtime_metrics.provider_fallback()
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "degraded",
|
|
"reason": "streaming STT unavailable; using batch fallback",
|
|
},
|
|
)
|
|
else:
|
|
receiving = False
|
|
discard_audio_until_end = True
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "error",
|
|
"code": "streaming_stt_unavailable",
|
|
"detail": "streaming STT is unavailable and no batch fallback is configured",
|
|
},
|
|
)
|
|
await _safe_send_json(
|
|
websocket, {"type": "state", "state": "idle"}
|
|
)
|
|
if not discard_audio_until_end:
|
|
await _safe_send_json(
|
|
websocket, {"type": "state", "state": "listening"}
|
|
)
|
|
|
|
elif ctype == "audio_end":
|
|
receiving = False
|
|
audio_ended_at = time.monotonic()
|
|
silence_ms = _safe_int(ctrl.get("silence_ms"))
|
|
if (
|
|
silence_ms is None
|
|
and last_audio_end_at is not None
|
|
and audio_started_at is not None
|
|
):
|
|
silence_ms = max(
|
|
0, int((audio_started_at - last_audio_end_at) * 1000)
|
|
)
|
|
end_format = _safe_str(ctrl.get("format")) or audio_format
|
|
context = VoiceSessionContext(session_id, principal, voice_preset)
|
|
utterance = VoiceAudioInput(
|
|
audio=bytes(audio_buf),
|
|
fmt=end_format,
|
|
sample_rate=_safe_int(ctrl.get("sample_rate"))
|
|
or audio_sample_rate,
|
|
channels=_safe_int(ctrl.get("channels")) or audio_channels,
|
|
sample_width=_safe_int(ctrl.get("sample_width"))
|
|
or audio_sample_width,
|
|
audio_started_at=audio_started_at,
|
|
audio_ended_at=audio_ended_at,
|
|
prosody=VoiceProsody(
|
|
silence_ms=silence_ms,
|
|
barge_in=_safe_bool(ctrl.get("barge_in")),
|
|
provider_events=_safe_provider_events(
|
|
ctrl.get("provider_events")
|
|
),
|
|
),
|
|
)
|
|
if discard_audio_until_end:
|
|
if streaming_session is not None:
|
|
await streaming_session.abort()
|
|
streaming_session = None
|
|
streaming_consent_checked_at = None
|
|
elif streaming_session is not None:
|
|
if not await _multimodal_voice_processing_allowed(
|
|
websocket,
|
|
session_id=session_id,
|
|
principal=principal,
|
|
):
|
|
await streaming_session.abort()
|
|
streaming_session = None
|
|
streaming_consent_checked_at = None
|
|
else:
|
|
try:
|
|
streaming_result, drained = (
|
|
await _finish_streaming_transcription(
|
|
websocket,
|
|
streaming_session,
|
|
streaming_events,
|
|
)
|
|
)
|
|
voice_runtime_metrics.streaming_queue_observed(
|
|
runtime_connection_id,
|
|
queue_items=streaming_events.qsize(),
|
|
)
|
|
streaming_session = None
|
|
streaming_consent_checked_at = None
|
|
except Exception:
|
|
if streaming_session is not None:
|
|
await streaming_session.abort()
|
|
streaming_session = None
|
|
streaming_consent_checked_at = None
|
|
if voice_service.batch_stt_available():
|
|
voice_runtime_metrics.provider_fallback()
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "degraded",
|
|
"reason": "streaming STT unavailable; using batch fallback",
|
|
},
|
|
)
|
|
await _handle_utterance(websocket, context, utterance)
|
|
else:
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "error",
|
|
"code": "streaming_stt_unavailable",
|
|
"detail": "streaming STT finalization failed and no batch fallback is configured",
|
|
},
|
|
)
|
|
await _safe_send_json(
|
|
websocket, {"type": "state", "state": "idle"}
|
|
)
|
|
else:
|
|
if drained is not None:
|
|
last_stream_transcript = drained
|
|
if await _multimodal_voice_processing_allowed(
|
|
websocket,
|
|
session_id=session_id,
|
|
principal=principal,
|
|
):
|
|
await _handle_streaming_utterance(
|
|
websocket,
|
|
context,
|
|
utterance,
|
|
streaming_result,
|
|
transcript_already_sent=(
|
|
last_stream_transcript
|
|
== (streaming_result.text, True)
|
|
),
|
|
)
|
|
else:
|
|
await _handle_utterance(websocket, context, utterance)
|
|
last_audio_end_at = audio_ended_at
|
|
audio_started_at = None
|
|
audio_format = None
|
|
audio_sample_rate = None
|
|
audio_channels = None
|
|
audio_sample_width = None
|
|
audio_buf.clear()
|
|
voice_runtime_metrics.audio_buffer_cleared(runtime_connection_id)
|
|
discard_audio_until_end = False
|
|
last_stream_transcript = None
|
|
|
|
elif ctype == "text_turn":
|
|
# Text-only path for accessibility and deterministic tests.
|
|
if streaming_session is not None:
|
|
await streaming_session.abort()
|
|
streaming_session = None
|
|
streaming_consent_checked_at = None
|
|
receiving = False
|
|
audio_buf.clear()
|
|
voice_runtime_metrics.audio_buffer_cleared(runtime_connection_id)
|
|
learner_text = (ctrl.get("text") or "").strip()
|
|
if learner_text:
|
|
await _run_turn_and_speak(
|
|
websocket,
|
|
VoiceSessionContext(session_id, principal, voice_preset),
|
|
VoiceTurnInput(learner_text=learner_text),
|
|
)
|
|
|
|
elif ctype == "stt_result":
|
|
if streaming_session is not None:
|
|
await streaming_session.abort()
|
|
streaming_session = None
|
|
streaming_consent_checked_at = None
|
|
receiving = False
|
|
audio_buf.clear()
|
|
voice_runtime_metrics.audio_buffer_cleared(runtime_connection_id)
|
|
stt_received_at = time.monotonic()
|
|
await _handle_stt_result_control(
|
|
websocket,
|
|
session_id=session_id,
|
|
principal=principal,
|
|
voice_preset=voice_preset,
|
|
ctrl=ctrl,
|
|
audio_started_at=audio_started_at,
|
|
audio_ended_at=stt_received_at,
|
|
last_audio_end_at=last_audio_end_at,
|
|
)
|
|
last_audio_end_at = stt_received_at
|
|
audio_started_at = None
|
|
audio_format = None
|
|
audio_sample_rate = None
|
|
audio_channels = None
|
|
audio_sample_width = None
|
|
|
|
elif ctype == "ping":
|
|
await _safe_send_json(websocket, {"type": "pong"})
|
|
|
|
elif ctype == "close":
|
|
if streaming_session is not None:
|
|
await streaming_session.abort()
|
|
streaming_session = None
|
|
streaming_consent_checked_at = None
|
|
break
|
|
|
|
except WebSocketDisconnect:
|
|
pass
|
|
except Exception as e:
|
|
voice_runtime_metrics.websocket_error()
|
|
if _is_turn_persistence_unavailable(e):
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "error",
|
|
"code": "turn_persistence_unavailable",
|
|
"detail": "voice turn persistence unavailable; retry the utterance",
|
|
},
|
|
)
|
|
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
|
else:
|
|
await _safe_send_json(
|
|
websocket, {"type": "error", "detail": f"voice ws error: {e}"}
|
|
)
|
|
finally:
|
|
if streaming_session is not None:
|
|
await streaming_session.abort()
|
|
voice_runtime_metrics.websocket_closed(runtime_connection_id)
|
|
await _safe_close(websocket)
|
|
|
|
|
|
async def _multimodal_voice_processing_allowed(
|
|
websocket: WebSocket,
|
|
*,
|
|
session_id: str,
|
|
principal: Principal,
|
|
) -> bool:
|
|
"""Stop before STT/derived processing when G7 consent is not active."""
|
|
|
|
try:
|
|
await multimodal_alliance_store.assert_voice_processing_allowed(
|
|
principal=principal,
|
|
session_id=session_id,
|
|
)
|
|
except multimodal_alliance_store.MultimodalConsentWithdrawnError as exc:
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "error",
|
|
"code": "multimodal_consent_withdrawn",
|
|
"detail": str(exc),
|
|
},
|
|
)
|
|
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
|
return False
|
|
except multimodal_alliance_store.MultimodalConsentRequiredError as exc:
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "error",
|
|
"code": "multimodal_consent_required",
|
|
"detail": str(exc),
|
|
},
|
|
)
|
|
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
|
return False
|
|
except (multimodal_alliance_store.MultimodalAllianceError, RuntimeError):
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "error",
|
|
"code": "multimodal_consent_unavailable",
|
|
"detail": "multimodal consent state is unavailable; voice processing is blocked",
|
|
},
|
|
)
|
|
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
|
return False
|
|
return True
|
|
|
|
|
|
async def _handle_stt_result_control(
|
|
websocket: WebSocket,
|
|
*,
|
|
session_id: str,
|
|
principal: Principal,
|
|
voice_preset: VoicePreset,
|
|
ctrl: dict[str, object],
|
|
audio_started_at: float | None = None,
|
|
audio_ended_at: float | None = None,
|
|
last_audio_end_at: float | None = None,
|
|
) -> None:
|
|
if not await _multimodal_voice_processing_allowed(
|
|
websocket,
|
|
session_id=session_id,
|
|
principal=principal,
|
|
):
|
|
return
|
|
learner_text = str(ctrl.get("text") or "").strip()
|
|
transcript_final = _safe_bool(ctrl.get("final"))
|
|
silence_ms = _safe_int(ctrl.get("silence_ms"))
|
|
if (
|
|
silence_ms is None
|
|
and last_audio_end_at is not None
|
|
and audio_started_at is not None
|
|
):
|
|
silence_ms = max(0, int((audio_started_at - last_audio_end_at) * 1000))
|
|
provider_events = _safe_provider_events(ctrl.get("provider_events"))
|
|
decision = voice_svc.assess_end_of_turn(
|
|
transcript_text=learner_text,
|
|
transcript_final=bool(transcript_final),
|
|
silence_ms=silence_ms,
|
|
)
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "eot",
|
|
"ready": decision.ready,
|
|
"reason": decision.reason,
|
|
"silence_ms": decision.silence_ms,
|
|
"threshold_ms": decision.threshold_ms,
|
|
},
|
|
)
|
|
if not decision.ready:
|
|
await _safe_send_json(websocket, {"type": "state", "state": "listening"})
|
|
return
|
|
|
|
await _safe_send_json(websocket, {"type": "state", "state": "thinking"})
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "transcript",
|
|
"text": learner_text,
|
|
"final": True,
|
|
"speaker": "counselor",
|
|
},
|
|
)
|
|
await _run_turn_and_speak(
|
|
websocket,
|
|
VoiceSessionContext(session_id, principal, voice_preset),
|
|
VoiceTurnInput(
|
|
learner_text=learner_text,
|
|
prosody=VoiceProsody(
|
|
duration_s=_elapsed_seconds(audio_started_at, audio_ended_at),
|
|
silence_ms=decision.silence_ms,
|
|
barge_in=_safe_bool(ctrl.get("barge_in")),
|
|
provider_events=provider_events,
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
async def _handle_utterance(
|
|
websocket: WebSocket,
|
|
context: VoiceSessionContext,
|
|
utterance: VoiceAudioInput,
|
|
) -> None:
|
|
"""Transcribe one utterance, generate the client reply, then synthesize TTS."""
|
|
if not utterance.audio:
|
|
await _safe_send_json(
|
|
websocket, {"type": "transcript", "text": "", "final": True}
|
|
)
|
|
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
|
return
|
|
|
|
if not await _multimodal_voice_processing_allowed(
|
|
websocket,
|
|
session_id=context.session_id,
|
|
principal=context.principal,
|
|
):
|
|
return
|
|
|
|
# STT begins after the learner stops speaking.
|
|
await _safe_send_json(websocket, {"type": "state", "state": "thinking"})
|
|
upload_audio, upload_fmt = _normalize_audio_upload(
|
|
utterance.audio,
|
|
fmt=utterance.fmt,
|
|
sample_rate=utterance.sample_rate,
|
|
channels=utterance.channels,
|
|
sample_width=utterance.sample_width,
|
|
)
|
|
filename, content_type = _audio_meta(upload_fmt)
|
|
try:
|
|
stt = await voice_service.transcribe(
|
|
upload_audio, filename=filename, content_type=content_type
|
|
)
|
|
except VoiceUnavailable as e:
|
|
await _safe_send_json(websocket, {"type": "degraded", "reason": str(e)})
|
|
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
|
return
|
|
except Exception as e:
|
|
await _safe_send_json(
|
|
websocket, {"type": "error", "detail": f"STT failed: {e}"}
|
|
)
|
|
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
|
return
|
|
|
|
learner_text = stt.text
|
|
audio_ref = _voice_audio_ref(upload_audio, upload_fmt)
|
|
duration_s = stt.duration or _elapsed_seconds(
|
|
utterance.audio_started_at, utterance.audio_ended_at
|
|
)
|
|
speech_rate = _estimate_speech_rate(learner_text, duration_s)
|
|
provider_events = _merge_provider_events(
|
|
utterance.prosody.provider_events,
|
|
getattr(stt, "provider_events", []),
|
|
)
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "transcript",
|
|
"text": learner_text,
|
|
"final": True,
|
|
"speaker": "counselor",
|
|
},
|
|
)
|
|
if not learner_text:
|
|
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
|
return
|
|
|
|
await _run_turn_and_speak(
|
|
websocket,
|
|
context,
|
|
VoiceTurnInput(
|
|
learner_text=learner_text,
|
|
prosody=VoiceProsody(
|
|
audio_ref=audio_ref,
|
|
duration_s=duration_s,
|
|
silence_ms=utterance.prosody.silence_ms,
|
|
speech_rate=speech_rate,
|
|
barge_in=utterance.prosody.barge_in,
|
|
provider_events=provider_events,
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
async def _drain_streaming_transcripts(
|
|
websocket: WebSocket,
|
|
events: asyncio.Queue[StreamingTranscriptEvent],
|
|
) -> tuple[str, bool] | None:
|
|
"""Relay provider-neutral streaming updates without concurrent ASGI sends."""
|
|
|
|
last: tuple[str, bool] | None = None
|
|
while True:
|
|
try:
|
|
event = events.get_nowait()
|
|
except asyncio.QueueEmpty:
|
|
return last
|
|
await _relay_streaming_transcript(websocket, event)
|
|
last = (event.text, event.speech_final)
|
|
|
|
|
|
async def _finish_streaming_transcription(
|
|
websocket: WebSocket,
|
|
session: voice_svc.DeepgramStreamingSession,
|
|
events: asyncio.Queue[StreamingTranscriptEvent],
|
|
) -> tuple[TranscriptResult, tuple[str, bool] | None]:
|
|
"""Finalize while draining the bounded event queue to avoid producer deadlock."""
|
|
|
|
finish_task = asyncio.create_task(session.finish())
|
|
last: tuple[str, bool] | None = None
|
|
try:
|
|
while not finish_task.done():
|
|
event_task = asyncio.create_task(events.get())
|
|
done, _ = await asyncio.wait(
|
|
{finish_task, event_task},
|
|
return_when=asyncio.FIRST_COMPLETED,
|
|
)
|
|
if event_task in done:
|
|
event = event_task.result()
|
|
await _relay_streaming_transcript(websocket, event)
|
|
last = (event.text, event.speech_final)
|
|
else:
|
|
event_task.cancel()
|
|
try:
|
|
await event_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
result = await finish_task
|
|
drained = await _drain_streaming_transcripts(websocket, events)
|
|
return result, drained or last
|
|
except Exception:
|
|
if not finish_task.done():
|
|
finish_task.cancel()
|
|
raise
|
|
|
|
|
|
async def _relay_streaming_transcript(
|
|
websocket: WebSocket, event: StreamingTranscriptEvent
|
|
) -> None:
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "transcript",
|
|
"text": event.text,
|
|
# Deepgram is_final seals one segment; speech_final seals the
|
|
# learner utterance. The browser's `final` contract means the
|
|
# latter so it never stops capture at an intermediate segment.
|
|
"final": event.speech_final,
|
|
"speech_final": event.speech_final,
|
|
"speaker": "counselor",
|
|
},
|
|
)
|
|
|
|
|
|
async def _handle_streaming_utterance(
|
|
websocket: WebSocket,
|
|
context: VoiceSessionContext,
|
|
utterance: VoiceAudioInput,
|
|
stt: TranscriptResult,
|
|
*,
|
|
transcript_already_sent: bool,
|
|
) -> None:
|
|
"""Persist and run one finalized provider-streamed learner utterance."""
|
|
|
|
learner_text = stt.text.strip()
|
|
await _safe_send_json(websocket, {"type": "state", "state": "thinking"})
|
|
if not transcript_already_sent:
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "transcript",
|
|
"text": learner_text,
|
|
"final": True,
|
|
"speech_final": True,
|
|
"speaker": "counselor",
|
|
},
|
|
)
|
|
if not learner_text:
|
|
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
|
return
|
|
|
|
audio_ref = _voice_audio_ref(utterance.audio, utterance.fmt)
|
|
duration_s = stt.duration or _elapsed_seconds(
|
|
utterance.audio_started_at, utterance.audio_ended_at
|
|
)
|
|
if stt.words:
|
|
duration_s = max(duration_s or 0.0, max(word.end for word in stt.words))
|
|
speech_rate = _estimate_speech_rate(learner_text, duration_s)
|
|
provider_events = _merge_provider_events(
|
|
[
|
|
{
|
|
"type": "stt_metadata",
|
|
"provider": "deepgram",
|
|
"model": stt.model,
|
|
"source": "streaming_stt",
|
|
"is_final": True,
|
|
}
|
|
],
|
|
utterance.prosody.provider_events,
|
|
stt.provider_events,
|
|
)
|
|
try:
|
|
await _persist_streaming_timeline(
|
|
context=context,
|
|
utterance=utterance,
|
|
stt=stt,
|
|
audio_ref=audio_ref,
|
|
duration_s=duration_s,
|
|
)
|
|
except multimodal_alliance_store.MultimodalConsentWithdrawnError as exc:
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "error",
|
|
"code": "multimodal_consent_withdrawn",
|
|
"detail": str(exc),
|
|
},
|
|
)
|
|
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
|
return
|
|
except multimodal_alliance_store.MultimodalConsentRequiredError as exc:
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "error",
|
|
"code": "multimodal_consent_required",
|
|
"detail": str(exc),
|
|
},
|
|
)
|
|
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
|
return
|
|
except multimodal_alliance_store.MultimodalAllianceError:
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "error",
|
|
"code": "multimodal_timeline_unavailable",
|
|
"detail": "multimodal timeline persistence is unavailable; voice turn is blocked",
|
|
},
|
|
)
|
|
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
|
return
|
|
|
|
await _run_turn_and_speak(
|
|
websocket,
|
|
context,
|
|
VoiceTurnInput(
|
|
learner_text=learner_text,
|
|
prosody=VoiceProsody(
|
|
audio_ref=audio_ref,
|
|
duration_s=duration_s,
|
|
silence_ms=utterance.prosody.silence_ms,
|
|
speech_rate=speech_rate,
|
|
barge_in=utterance.prosody.barge_in,
|
|
provider_events=provider_events,
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
async def _persist_streaming_timeline(
|
|
*,
|
|
context: VoiceSessionContext,
|
|
utterance: VoiceAudioInput,
|
|
stt: TranscriptResult,
|
|
audio_ref: str,
|
|
duration_s: float | None,
|
|
) -> dict[str, object] | None:
|
|
audio_sha256 = hashlib.sha256(utterance.audio).hexdigest()
|
|
submission_id = uuid5(
|
|
NAMESPACE_URL,
|
|
f"vignette:g7:streaming-stt:{context.session_id}:{audio_sha256}:{stt.model}",
|
|
)
|
|
duration_ms = max(
|
|
1,
|
|
round((duration_s or 0.0) * 1000),
|
|
*(round(word.end * 1000) for word in stt.words),
|
|
)
|
|
words = []
|
|
for index, word in enumerate(sorted(stt.words, key=lambda item: item.start)):
|
|
start_ms = max(0, min(duration_ms - 1, round(word.start * 1000)))
|
|
end_ms = max(start_ms + 1, min(duration_ms, round(word.end * 1000)))
|
|
words.append(
|
|
{
|
|
"word_index": index,
|
|
"start_ms": start_ms,
|
|
"end_ms": end_ms,
|
|
"speaker": "learner",
|
|
# Common counselling words are dictionary-attackable when stored
|
|
# as plain SHA-256. Bind the pseudonym to this deployment and
|
|
# submission so the timeline remains useful without creating a
|
|
# reusable transcript fingerprint.
|
|
"token_hash": hmac.new(
|
|
settings.session_secret.encode("utf-8"),
|
|
(
|
|
f"{context.session_id}:{submission_id}:"
|
|
f"{word.word.casefold()}"
|
|
).encode("utf-8"),
|
|
hashlib.sha256,
|
|
).hexdigest(),
|
|
}
|
|
)
|
|
|
|
events = []
|
|
for index, event in enumerate(_safe_provider_events(stt.provider_events)):
|
|
provider_type = str(event.get("event_type") or "")
|
|
event_type = _g7_event_type(provider_type)
|
|
if event_type is None:
|
|
continue
|
|
start_ms = max(0, _safe_int(event.get("start_ms")) or 0)
|
|
end_ms = _safe_int(event.get("end_ms"))
|
|
if end_ms is None:
|
|
end_ms = start_ms + max(0, _safe_int(event.get("duration_ms")) or 0)
|
|
start_ms = min(start_ms, duration_ms - 1)
|
|
end_ms = min(duration_ms, max(start_ms + 1, end_ms))
|
|
confidence = _safe_float(event.get("confidence"))
|
|
uncertainty = 0.5 if confidence is None else max(0.0, min(1.0, 1.0 - confidence))
|
|
events.append(
|
|
{
|
|
"event_id": f"oas-g7-event-{submission_id.hex}-{index}",
|
|
"event_type": event_type,
|
|
"start_ms": start_ms,
|
|
"end_ms": end_ms,
|
|
"actor": "learner",
|
|
"observed_feature": f"provider observed {provider_type}",
|
|
"uncertainty": uncertainty,
|
|
"source": "stt_word_timestamps",
|
|
}
|
|
)
|
|
|
|
timeline = multimodal_alliance.align_voice_timeline(
|
|
audio_duration_ms=duration_ms,
|
|
words=words,
|
|
events=events,
|
|
)
|
|
_, media_type = _audio_meta(utterance.fmt)
|
|
return await multimodal_alliance_store.append_runtime_timeline(
|
|
session_id=context.session_id,
|
|
submission_id=submission_id,
|
|
timeline=timeline,
|
|
audio_asset={
|
|
"audio_ref": audio_ref,
|
|
"audio_sha256": audio_sha256,
|
|
"media_type": media_type,
|
|
"byte_size": len(utterance.audio),
|
|
},
|
|
)
|
|
|
|
|
|
def _g7_event_type(provider_type: str) -> str | None:
|
|
if provider_type in {"speech_final", "speech_end", "voice_activity", "speech_rate"}:
|
|
return "pace"
|
|
if provider_type in {"barge_in", "interrupt", "interruption"}:
|
|
return "interruption"
|
|
if provider_type == "overlap":
|
|
return "overlap"
|
|
if provider_type in {"silence", "pause", "long_pause"}:
|
|
return "silence"
|
|
if provider_type in {"background_noise", "noise"}:
|
|
return "audio_quality"
|
|
if provider_type in {"sigh", "cry", "laugh", "breath", "pitch", "intonation", "prosody"}:
|
|
return "prosody"
|
|
return None
|
|
|
|
|
|
async def _run_turn_and_speak(
|
|
websocket: WebSocket,
|
|
context: VoiceSessionContext,
|
|
turn: VoiceTurnInput,
|
|
) -> None:
|
|
"""Run one counseling turn and stream synthesized client speech."""
|
|
learner_text = turn.learner_text
|
|
prosody = turn.prosody
|
|
speech_rate = prosody.speech_rate
|
|
if speech_rate is None:
|
|
speech_rate = _estimate_speech_rate(learner_text, prosody.duration_s)
|
|
sess, err = await _load_voice_session(context.session_id, context.principal)
|
|
if sess is None:
|
|
await _safe_send_json(
|
|
websocket, {"type": "error", "detail": err or "session not found or ended"}
|
|
)
|
|
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
|
return
|
|
|
|
from . import sessions as session_routes
|
|
|
|
if session_routes.session_time_over(sess):
|
|
await _safe_send_json(
|
|
websocket,
|
|
{"type": "error", "detail": "session_time_over"},
|
|
)
|
|
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
|
return
|
|
|
|
ctx = await _prepare_voice_turn_context(
|
|
session_id=context.session_id,
|
|
sess=sess,
|
|
learner_text=learner_text,
|
|
)
|
|
assert ctx.state_after is not None
|
|
|
|
# Voice needs the full client reply before TTS starts.
|
|
try:
|
|
result = await orchestrator.run_turn_generate(
|
|
ctx,
|
|
engine_client,
|
|
eval_hook=evaluator.make_eval_hook(
|
|
engine_client,
|
|
audit_hook=session_persistence.record_llm_call_audit,
|
|
),
|
|
audit_hook=session_persistence.record_llm_call_audit,
|
|
)
|
|
except EngineError as e:
|
|
await _safe_send_json(
|
|
websocket, {"type": "error", "detail": f"engine unavailable: {e}"}
|
|
)
|
|
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
|
return
|
|
|
|
reply = result.client_reply or ""
|
|
# Persist only after the client reply has been generated. A failed AI turn
|
|
# must not leave a learner-only transcript in review or history.
|
|
await turn_runtime.finalize_completed_turn(
|
|
sess,
|
|
ctx,
|
|
result,
|
|
context_prefix="voice session",
|
|
counselor_turn=TurnRecord(
|
|
turn_seq=ctx.state_after.turn_seq,
|
|
speaker="counselor",
|
|
stage=turn_runtime.stage_label(ctx.state_after.stage),
|
|
text=learner_text,
|
|
text_masked=ctx.learner_text_masked,
|
|
audio_ref=prosody.audio_ref,
|
|
silence_ms=prosody.silence_ms,
|
|
speech_rate=speech_rate,
|
|
barge_in=prosody.barge_in,
|
|
provider_events=prosody.provider_events,
|
|
evaluation=result.evaluation,
|
|
),
|
|
)
|
|
|
|
exposed_inner_reaction = inner_reaction_exposure.expose_client_inner_reaction(
|
|
ctx.client_inner_reaction,
|
|
stored=True,
|
|
feedback_enabled=feedback_policy.effective_learner_feedback_enabled(
|
|
sess, context.principal
|
|
),
|
|
)
|
|
|
|
# Send the final client text before audio playback.
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "reply",
|
|
"text": reply,
|
|
"speaker": "client",
|
|
"stage": result.stage,
|
|
"effective_openness": round(result.effective_openness, 4),
|
|
"turn_seq": result.turn_seq,
|
|
"safety_flagged": result.safety_flagged,
|
|
"crisis_kind": result.crisis_kind,
|
|
"crisis_resource": result.crisis_resource,
|
|
"conversation_stopped": result.conversation_stopped,
|
|
"progress": session_routes.build_session_progress(
|
|
result.state_after,
|
|
prev_rapport_credit=sess.prev_rapport_credit,
|
|
goal_stages=list(sess.goal_stages or []),
|
|
).model_dump(),
|
|
"inner_reaction": (
|
|
exposed_inner_reaction.model_dump(mode="json")
|
|
if exposed_inner_reaction is not None
|
|
else None
|
|
),
|
|
},
|
|
)
|
|
|
|
if not reply:
|
|
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
|
return
|
|
|
|
# TTS speaking state comes before chunk metadata and binary audio.
|
|
await _safe_send_json(
|
|
websocket,
|
|
{
|
|
"type": "state",
|
|
"state": "speaking",
|
|
"voice": context.voice_preset.openai_voice,
|
|
"tts_provider": voice_service.tts_provider_for_voice(context.voice_preset),
|
|
},
|
|
)
|
|
try:
|
|
n = 0
|
|
async for ck in voice_service.synthesize_stream(reply, context.voice_preset):
|
|
# 바이너리 오디오 청크만 송신(프론트가 Web Audio AnalyserNode로 립싱크 자체 산출).
|
|
await _safe_send_bytes(websocket, ck.audio)
|
|
n += 1
|
|
await _safe_send_json(websocket, {"type": "tts_end", "chunks": n})
|
|
except VoiceUnavailable as e:
|
|
await _safe_send_json(websocket, {"type": "degraded", "reason": str(e)})
|
|
except Exception as e:
|
|
await _safe_send_json(
|
|
websocket, {"type": "error", "detail": f"TTS failed: {e}"}
|
|
)
|
|
|
|
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
|
|
|
|
|
async def _prepare_voice_turn_context(
|
|
*,
|
|
session_id: str,
|
|
sess: InProcSession,
|
|
learner_text: str,
|
|
) -> orchestrator.TurnContext:
|
|
"""Build voice turn context with the same fail-closed G3 ledger projection."""
|
|
|
|
from . import sessions as session_routes
|
|
|
|
recall = await session_routes.ensure_recall_context(sess)
|
|
kb_cues = session_routes.cached_kb_cues(session_id)
|
|
scenario_context = (
|
|
await rupture_scenario_director.load_stored_scenario_context(
|
|
session_id=session_id,
|
|
case_id=sess.case_id,
|
|
)
|
|
)
|
|
return orchestrator.prepare_turn(
|
|
session_id=session_id,
|
|
case_id=sess.case_id,
|
|
card=sess.persona,
|
|
state=sess.state,
|
|
learner_text=learner_text,
|
|
learner_identity=sess.learner_label,
|
|
memory=build_turn_memory(sess, recall, kb_cues),
|
|
theory_mode=sess.theory_mode,
|
|
scenario_context=scenario_context,
|
|
)
|
|
|
|
|
|
async def _load_voice_session(
|
|
session_id: str,
|
|
principal: Principal,
|
|
) -> tuple[InProcSession | None, str | None]:
|
|
sess, err = await turn_runtime.load_owned_session(session_id, principal)
|
|
if err == turn_runtime.SessionAccessError.NOT_FOUND:
|
|
return None, f"unknown session {session_id}"
|
|
if err == turn_runtime.SessionAccessError.FORBIDDEN:
|
|
return None, "session does not belong to user"
|
|
if err == turn_runtime.SessionAccessError.ENDED:
|
|
return None, "session already ended"
|
|
assert sess is not None
|
|
return sess, None
|
|
|
|
|
|
def _client_turn_text_for_speech(sess: InProcSession, turn_seq: int) -> str | None:
|
|
"""완료된 상담 턴 번호에 대응하는 client-visible 응답을 반환한다.
|
|
|
|
degraded 인메모리 미러는 상담자/내담자 한 쌍이 같은 논리 turn_seq를 쓰지만,
|
|
DB의 app.turns.seq는 발화마다 1씩 증가한다. DB 스냅샷에서 논리 1턴을
|
|
그대로 seq=1로 찾으면 상담자 발화만 잡혀 TTS가 404가 되므로 두 저장 형태를
|
|
명시적으로 구분한다.
|
|
"""
|
|
visible_clients = [
|
|
turn
|
|
for turn in sess.turns
|
|
if turn.speaker == "client" and turn.is_visible_to("client")
|
|
]
|
|
counselor_sequences = {
|
|
turn.turn_seq for turn in sess.turns if turn.speaker == "counselor"
|
|
}
|
|
paired_sequences = counselor_sequences.intersection(
|
|
turn.turn_seq for turn in visible_clients
|
|
)
|
|
if paired_sequences or not counselor_sequences:
|
|
match = next(
|
|
(turn for turn in reversed(visible_clients) if turn.turn_seq == turn_seq),
|
|
None,
|
|
)
|
|
else:
|
|
index = turn_seq - 1
|
|
match = visible_clients[index] if 0 <= index < len(visible_clients) else None
|
|
if match is not None:
|
|
text = (match.text_masked or match.text).strip()
|
|
return guardrail.humanize_pii_placeholders(text) or None
|
|
return None
|
|
|
|
|
|
async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
|
|
"""Restore the same server-side browser session used by REST routes."""
|
|
raw_cookie = websocket.cookies.get(settings.cookie_name)
|
|
if raw_cookie is None and settings.environment == "dev":
|
|
raw_cookie = websocket.cookies.get("vignette_sid")
|
|
|
|
session = await get_session(raw_cookie)
|
|
if session is None:
|
|
return None
|
|
if session.account_status != "approved":
|
|
return None
|
|
|
|
try:
|
|
role = Role(session.role)
|
|
except ValueError:
|
|
return None
|
|
|
|
return Principal(
|
|
user_id=session.user_id,
|
|
role=role,
|
|
admin_access=session.admin_access,
|
|
super_admin=session.super_admin,
|
|
account_status=session.account_status,
|
|
cohort_ids=session.cohort_ids,
|
|
email=session.email,
|
|
display_name=session.display_name,
|
|
consent_at=session.consent_at,
|
|
profile_completed_at=session.profile_completed_at,
|
|
)
|
|
|
|
|
|
async def _practice_access_error(principal: Principal) -> str | None:
|
|
if principal.profile_completed_at is None and not await user_onboarding_complete(
|
|
principal.user_id
|
|
):
|
|
return "onboarding_required"
|
|
if principal.consent_at is None and not await user_has_consent(principal.user_id):
|
|
return "consent_required"
|
|
return None
|
|
|
|
|
|
async def _bind_session(
|
|
websocket: WebSocket,
|
|
principal: Principal,
|
|
) -> tuple[Optional[str], Optional[VoicePreset], Optional[str], dict[str, object]]:
|
|
"""Bind an existing session or create a dev-only voice session."""
|
|
qp = websocket.query_params
|
|
explicit_preset = qp.get("preset")
|
|
access_error = await _practice_access_error(principal)
|
|
if access_error is not None:
|
|
return None, None, access_error, {}
|
|
|
|
session_id = qp.get("session_id")
|
|
if session_id:
|
|
sess, err = await _load_voice_session(session_id, principal)
|
|
if sess is None:
|
|
return None, None, err or f"unknown session {session_id}", {}
|
|
vp = await _resolve_session_voice(
|
|
session_id=session_id,
|
|
persona_code=sess.persona.code,
|
|
explicit_preset=explicit_preset,
|
|
)
|
|
return (
|
|
session_id,
|
|
vp,
|
|
None,
|
|
{"degraded": False, "persona_catalog_source": "session"},
|
|
)
|
|
|
|
# persona_code session creation is local-dev only. Production uses REST start.
|
|
if settings.environment != "dev":
|
|
return None, None, "session_id required", {}
|
|
|
|
persona_code = qp.get("persona_code")
|
|
if not persona_code:
|
|
return None, None, "session_id or persona_code query required", {}
|
|
try:
|
|
catalog_persona = await get_catalog_persona(persona_code)
|
|
except Exception:
|
|
return None, None, "persona catalog database unavailable", {}
|
|
if catalog_persona is None:
|
|
return None, None, f"unknown persona {persona_code}", {}
|
|
card = catalog_persona.card
|
|
|
|
st = state_machine.init_state(
|
|
params=card.openness_params(),
|
|
)
|
|
sess = await session_persistence.create_session(
|
|
learner_id=principal.user_id,
|
|
card=card,
|
|
theory_mode="humanistic",
|
|
state=st,
|
|
session_no=1,
|
|
carry_rapport=st.rapport_credit,
|
|
persona_id=catalog_persona.persona_id,
|
|
persona_version=catalog_persona.version,
|
|
)
|
|
session_source = "database"
|
|
if sess is None:
|
|
require_runtime_fallback_allowed("voice session creation")
|
|
sess = store.create(
|
|
learner_id=principal.user_id,
|
|
persona=card,
|
|
theory_mode="humanistic",
|
|
state=st,
|
|
session_no=1,
|
|
carry_rapport=st.rapport_credit,
|
|
)
|
|
session_source = "runtime"
|
|
else:
|
|
store.put(sess)
|
|
vp = await _resolve_catalog_voice(
|
|
persona_id=catalog_persona.persona_id,
|
|
version=catalog_persona.version,
|
|
persona_code=card.code,
|
|
explicit_preset=explicit_preset,
|
|
)
|
|
degraded_reasons: list[str] = []
|
|
if catalog_persona.degraded:
|
|
degraded_reasons.append(
|
|
"카탈로그 원본을 확인하지 못해 음성 회기를 시작하지 않습니다"
|
|
)
|
|
if session_source == "runtime":
|
|
degraded_reasons.append(
|
|
"세션 저장소 연결 전까지 비영구 개발 런타임 기록을 사용합니다"
|
|
)
|
|
bind_meta = {
|
|
"degraded": bool(degraded_reasons),
|
|
"degraded_reason": "; ".join(degraded_reasons) if degraded_reasons else None,
|
|
"persona_catalog_source": catalog_persona.source,
|
|
"session_source": session_source,
|
|
}
|
|
return sess.session_id, vp, None, bind_meta
|
|
|
|
|
|
async def _resolve_session_voice(
|
|
*,
|
|
session_id: str,
|
|
persona_code: str,
|
|
explicit_preset: str | None,
|
|
) -> VoicePreset:
|
|
fallback = resolve_voice(persona_code=persona_code, preset=explicit_preset)
|
|
if explicit_preset:
|
|
return fallback
|
|
try:
|
|
voice_map = await get_session_voice_map(session_id)
|
|
except Exception:
|
|
return fallback
|
|
return _voice_from_map(voice_map, persona_code=persona_code) or fallback
|
|
|
|
|
|
async def _resolve_catalog_voice(
|
|
*,
|
|
persona_id: str | None,
|
|
version: int | None,
|
|
persona_code: str,
|
|
explicit_preset: str | None,
|
|
) -> VoicePreset:
|
|
fallback = resolve_voice(persona_code=persona_code, preset=explicit_preset)
|
|
if explicit_preset:
|
|
return fallback
|
|
try:
|
|
voice_map = await get_persona_voice_map(persona_id=persona_id, version=version)
|
|
except Exception:
|
|
return fallback
|
|
return _voice_from_map(voice_map, persona_code=persona_code) or fallback
|
|
|
|
|
|
def _voice_from_map(
|
|
voice_map: PersonaVoiceMap | None,
|
|
*,
|
|
persona_code: str,
|
|
) -> VoicePreset | None:
|
|
if voice_map is None:
|
|
return None
|
|
return voice_svc.resolve_voice_from_map(
|
|
provider=voice_map.provider,
|
|
voice_id=voice_map.voice_id,
|
|
base_params=voice_map.base_params,
|
|
persona_code=persona_code,
|
|
)
|
|
|
|
|
|
def _audio_meta(fmt: Optional[str]) -> tuple[str, str]:
|
|
"""Map the browser audio format to upload metadata."""
|
|
f = (fmt or "webm").lower().lstrip(".")
|
|
table = {
|
|
"webm": ("audio.webm", "audio/webm"),
|
|
"ogg": ("audio.ogg", "audio/ogg"),
|
|
"opus": ("audio.ogg", "audio/ogg"),
|
|
"wav": ("audio.wav", "audio/wav"),
|
|
"mp3": ("audio.mp3", "audio/mpeg"),
|
|
"mp4": ("audio.mp4", "audio/mp4"),
|
|
"m4a": ("audio.m4a", "audio/mp4"),
|
|
"pcm": ("audio.wav", "audio/wav"),
|
|
}
|
|
return table.get(f, ("audio.webm", "audio/webm"))
|
|
|
|
|
|
def _normalize_audio_upload(
|
|
audio: bytes,
|
|
*,
|
|
fmt: Optional[str],
|
|
sample_rate: int | None = None,
|
|
channels: int | None = None,
|
|
sample_width: int | None = None,
|
|
) -> tuple[bytes, str]:
|
|
f = (fmt or "webm").lower().lstrip(".") or "webm"
|
|
if f != "pcm":
|
|
return audio, f
|
|
if sample_width not in (None, 2):
|
|
raise ValueError("pcm sample_width must be 2 bytes")
|
|
return _wav_from_pcm16(
|
|
audio,
|
|
sample_rate=_bounded_int(
|
|
sample_rate, default=48000, minimum=8000, maximum=96000
|
|
),
|
|
channels=_bounded_int(channels, default=1, minimum=1, maximum=2),
|
|
), "wav"
|
|
|
|
|
|
def _bounded_int(value: int | None, *, default: int, minimum: int, maximum: int) -> int:
|
|
if value is None:
|
|
return default
|
|
return min(maximum, max(minimum, value))
|
|
|
|
|
|
def _wav_from_pcm16(pcm: bytes, *, sample_rate: int, channels: int) -> bytes:
|
|
byte_rate = sample_rate * channels * 2
|
|
block_align = channels * 2
|
|
data_size = len(pcm)
|
|
header = b"".join(
|
|
[
|
|
b"RIFF",
|
|
(36 + data_size).to_bytes(4, "little"),
|
|
b"WAVE",
|
|
b"fmt ",
|
|
(16).to_bytes(4, "little"),
|
|
(1).to_bytes(2, "little"),
|
|
channels.to_bytes(2, "little"),
|
|
sample_rate.to_bytes(4, "little"),
|
|
byte_rate.to_bytes(4, "little"),
|
|
block_align.to_bytes(2, "little"),
|
|
(16).to_bytes(2, "little"),
|
|
b"data",
|
|
data_size.to_bytes(4, "little"),
|
|
]
|
|
)
|
|
return header + pcm
|
|
|
|
|
|
def _voice_audio_ref(audio: bytes, fmt: Optional[str]) -> str | None:
|
|
if not audio:
|
|
return None
|
|
f = (fmt or "webm").lower().lstrip(".") or "webm"
|
|
digest = hashlib.sha256(audio).hexdigest()[:24]
|
|
return f"voice:{f}:sha256:{digest}"
|
|
|
|
|
|
def _elapsed_seconds(started_at: float | None, ended_at: float | None) -> float | None:
|
|
if started_at is None or ended_at is None:
|
|
return None
|
|
return max(0.001, ended_at - started_at)
|
|
|
|
|
|
def _estimate_speech_rate(text: str, duration_s: float | None) -> float | None:
|
|
if not text or not duration_s or duration_s <= 0:
|
|
return None
|
|
units = sum(1 for ch in text if not ch.isspace())
|
|
if units <= 0:
|
|
return None
|
|
return round((units / duration_s) * 60.0, 2)
|
|
|
|
|
|
def _safe_int(value: object) -> int | None:
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _safe_float(value: object) -> float | None:
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _safe_str(value: object) -> str | None:
|
|
if isinstance(value, str):
|
|
text = value.strip()
|
|
return text or None
|
|
return None
|
|
|
|
|
|
def _safe_bool(value: object) -> bool | None:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, str):
|
|
normalized = value.strip().lower()
|
|
if normalized in {"1", "true", "yes", "y"}:
|
|
return True
|
|
if normalized in {"0", "false", "no", "n"}:
|
|
return False
|
|
return bool(value)
|
|
|
|
|
|
def _provider_event_slug(value: object) -> str:
|
|
text = str(value or "").strip().lower()
|
|
if not text:
|
|
return ""
|
|
chars = []
|
|
previous_underscore = False
|
|
for char in text:
|
|
if char.isalnum():
|
|
chars.append(char)
|
|
previous_underscore = False
|
|
elif not previous_underscore:
|
|
chars.append("_")
|
|
previous_underscore = True
|
|
return "".join(chars).strip("_")[:_PROVIDER_EVENT_MAX_STRING]
|
|
|
|
|
|
def _provider_event_taxonomy(event: dict[str, object]) -> tuple[str, str]:
|
|
for field in _PROVIDER_EVENT_TYPE_FIELDS:
|
|
slug = _provider_event_slug(event.get(field))
|
|
if not slug:
|
|
continue
|
|
canonical = _PROVIDER_EVENT_TAXONOMY.get(slug)
|
|
if canonical:
|
|
return canonical
|
|
return slug, "unknown"
|
|
return "", ""
|
|
|
|
|
|
def _safe_provider_events(value: object) -> list[dict[str, object]]:
|
|
if not isinstance(value, list):
|
|
return []
|
|
events: list[dict[str, object]] = []
|
|
for item in value[:_PROVIDER_EVENT_MAX_ITEMS]:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
safe: dict[str, object] = {}
|
|
for key in _PROVIDER_EVENT_ALLOWED_KEYS:
|
|
raw = item.get(key)
|
|
if isinstance(raw, bool):
|
|
safe[key] = raw
|
|
elif isinstance(raw, (int, float)):
|
|
safe[key] = raw
|
|
elif isinstance(raw, str):
|
|
text = raw.strip()
|
|
if text:
|
|
safe[key] = text[:_PROVIDER_EVENT_MAX_STRING]
|
|
if safe:
|
|
event_type, category = _provider_event_taxonomy(safe)
|
|
if event_type:
|
|
safe["event_type"] = event_type
|
|
if category:
|
|
safe["category"] = category
|
|
events.append(safe)
|
|
return events
|
|
|
|
|
|
def _merge_provider_events(*values: object) -> list[dict[str, object]]:
|
|
merged: list[dict[str, object]] = []
|
|
for value in values:
|
|
merged.extend(_safe_provider_events(value))
|
|
if len(merged) >= _PROVIDER_EVENT_MAX_ITEMS:
|
|
return merged[:_PROVIDER_EVENT_MAX_ITEMS]
|
|
return merged
|
|
|
|
|
|
async def _safe_send_json(websocket: WebSocket, payload: dict) -> None:
|
|
if websocket.client_state != WebSocketState.CONNECTED:
|
|
return
|
|
try:
|
|
await websocket.send_text(json.dumps(payload, ensure_ascii=False))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def _safe_send_bytes(websocket: WebSocket, data: bytes) -> None:
|
|
if websocket.client_state != WebSocketState.CONNECTED:
|
|
return
|
|
try:
|
|
await websocket.send_bytes(data)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def _safe_close(websocket: WebSocket, code: int = 1000) -> None:
|
|
if websocket.client_state == WebSocketState.DISCONNECTED:
|
|
return
|
|
try:
|
|
await websocket.close(code=code)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
__all__ = ["router"]
|