텍스트 응답 음성 재생 연결

This commit is contained in:
Yun Chan 2026-07-13 16:09:34 +09:00
parent 64e06a1185
commit d80e33da5e
9 changed files with 524 additions and 16 deletions

View file

@ -18,14 +18,15 @@ import hashlib
import time
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.responses import JSONResponse
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 Principal, Role
from ..deps import CurrentPrincipal, Principal, Role
from ..engine_client import EngineError, engine_client
from ..persona_repository import (
PersonaVoiceMap,
@ -41,6 +42,13 @@ 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)
# WebSocket close codes.
WS_CLOSE_DEGRADED = 1011
WS_CLOSE_BAD_REQUEST = 1008
@ -123,6 +131,89 @@ async def voice_health() -> JSONResponse:
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)
if not voice_service.is_available():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="OPENAI_API_KEY is not configured",
)
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,
)
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="audio/mpeg",
headers={
"Cache-Control": "no-store",
"X-Vignette-TTS-Model": voice_svc.TTS_MODEL,
"X-Vignette-TTS-Provider": voice_service.tts_provider(),
},
)
@router.websocket("/ws")
async def voice_ws(websocket: WebSocket) -> None:
"""Run one authenticated learner voice cascade."""
@ -596,6 +687,19 @@ async def _load_voice_session(
return sess, None
def _client_turn_text_for_speech(sess: InProcSession, turn_seq: int) -> str | None:
"""Return the persisted client-visible reply for one completed turn."""
for turn in reversed(sess.turns):
if (
turn.turn_seq == turn_seq
and turn.speaker == "client"
and turn.is_visible_to("client")
):
text = (turn.text_masked or turn.text).strip()
return 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)