음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
|
|
@ -5,7 +5,7 @@ Client sends JSON controls plus binary audio chunks:
|
|||
|
||||
Server emits:
|
||||
ready -> state(listening) -> state(thinking) -> transcript -> reply
|
||||
-> state(speaking) -> tts_chunk + binary audio chunks -> tts_end -> state(idle)
|
||||
-> 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.
|
||||
|
|
@ -23,11 +23,16 @@ from fastapi.responses import JSONResponse
|
|||
from starlette.websockets import WebSocketState
|
||||
|
||||
from .. import session_persistence, turn_runtime
|
||||
from ..auth_sessions import get_session, user_has_consent
|
||||
from ..auth_sessions import get_session, user_has_consent, user_onboarding_complete
|
||||
from ..config import settings
|
||||
from ..deps import Principal, Role
|
||||
from ..engine_client import EngineError, engine_client
|
||||
from ..persona_repository import get_catalog_persona
|
||||
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 ..services import evaluator, orchestrator, state_machine
|
||||
from ..services import voice as voice_svc
|
||||
|
|
@ -72,6 +77,8 @@ async def voice_ws(websocket: WebSocket) -> 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.super_admin:
|
||||
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)
|
||||
|
|
@ -418,6 +425,8 @@ async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
|
|||
session = await get_session(raw_cookie)
|
||||
if session is None:
|
||||
return None
|
||||
if session.account_status != "approved":
|
||||
return None
|
||||
|
||||
try:
|
||||
role = Role(session.role)
|
||||
|
|
@ -427,10 +436,14 @@ async def _principal_from_websocket(websocket: WebSocket) -> Principal | 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,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -447,7 +460,11 @@ async def _bind_session(
|
|||
sess, err = await _load_voice_session(session_id, principal)
|
||||
if sess is None:
|
||||
return None, None, err or f"unknown session {session_id}", {}
|
||||
vp = resolve_voice(persona_code=sess.persona.code, preset=explicit_preset)
|
||||
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.
|
||||
|
|
@ -457,6 +474,11 @@ async def _bind_session(
|
|||
persona_code = qp.get("persona_code")
|
||||
if not persona_code:
|
||||
return None, None, "session_id or persona_code query required", {}
|
||||
if (
|
||||
principal.profile_completed_at is None
|
||||
and not await user_onboarding_complete(principal.user_id)
|
||||
):
|
||||
return None, None, "onboarding_required", {}
|
||||
if principal.consent_at is None and not await user_has_consent(principal.user_id):
|
||||
return None, None, "consent_required", {}
|
||||
try:
|
||||
|
|
@ -494,7 +516,12 @@ async def _bind_session(
|
|||
session_source = "runtime"
|
||||
else:
|
||||
store.put(sess)
|
||||
vp = resolve_voice(persona_code=card.code, preset=explicit_preset)
|
||||
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("카탈로그 원본을 확인하지 못해 음성 회기를 시작하지 않습니다")
|
||||
|
|
@ -509,6 +536,54 @@ async def _bind_session(
|
|||
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(".")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue