vignette/apps/api/app/routes/voice.py
Yun Chan 24b1b7a6e1 feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG
web (Vite+React19+TS, Cloudflare Pages 배포):
- 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브
- 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정
- ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서
- 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨

api (FastAPI):
- 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존)
- services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 /
  턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG
- store: DB off 폴백(in-memory), sessions 실구현

검증:
- web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200
- api: app.main import 통과
- 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시)
- E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
2026-06-25 23:37:22 +09:00

437 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""음성 라우트 — OpenAI STT/TTS 캐스케이드 + WSS 실시간 턴테이킹.
한신대 요구 '음성 필수'. 학습자가 마이크로 말하면 → STT → orchestrator 상담 1턴 →
내담자 텍스트 → TTS 오디오 + 립싱크 힌트(설계 §4.3 RMS)를 역방향으로 흘린다.
캐스케이드(설계 §5.2 음성 오브 4상태 listening→thinking→speaking→idle):
[클라] audio_start(JSON) → 바이너리 오디오 청크들 → audio_end(JSON)
[서버] state(listening) → STT → transcript(JSON) → state(thinking)
→ orchestrator.run_turn(가드레일·상태머신·페르소나·내담자AI·출력가드)
→ reply(JSON, 내담자 텍스트 + stage/openness) → state(speaking)
→ [tts_chunk(JSON: seq/rms) + 바이너리 오디오] × N → tts_end(JSON) → state(idle)
프로토콜(JSON 제어 + 바이너리 오디오 혼합, 단일 WS):
- 클라→서버 텍스트 = JSON 제어({"type": ...}); 클라→서버 바이너리 = 오디오 청크
- 서버→클라 텍스트 = JSON 이벤트; 서버→클라 바이너리 = TTS 오디오 청크
- 각 TTS 바이너리 청크 *직전*에 메타 JSON(tts_chunk: seq, rms)을 보내 프론트가 짝짓는다.
음성 미설정(OPENAI_API_KEY 없음): GET /voice/health → 503 degraded,
WS 는 핸드셰이크 직후 degraded 이벤트 + close(1011). 절대 크래시 금지.
"""
from __future__ import annotations
import json
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from starlette.websockets import WebSocketState
from ..engine_client import EngineError, engine_client
from ..services import memory, orchestrator, persona
from ..services import voice as voice_svc
from ..services.voice import VoicePreset, VoiceUnavailable, resolve_voice, voice_service
from ..store import TurnRecord, store
router = APIRouter(prefix="/voice", tags=["voice"])
# WS close 코드(섹션별 의미 명시)
WS_CLOSE_DEGRADED = 1011 # 서버측 음성 미설정/장애
WS_CLOSE_BAD_REQUEST = 1008 # 프로토콜 위반(세션 누락 등)
# 한 발화당 누적 오디오 상한(메모리 방어, ~10MB)
_MAX_AUDIO_BYTES = 10 * 1024 * 1024
# ════════════════════════════════════════════════════════════════════════════
# 헬스 — 음성 가용성(키 설정) 노출
# ════════════════════════════════════════════════════════════════════════════
@router.get("/health")
async def voice_health() -> JSONResponse:
"""음성 라우터 헬스. 키 미설정이면 503 degraded(시연 투명성)."""
available = voice_service.is_available()
body = {
"status": "ok" if available else "degraded",
"available": available,
"stt_model": voice_svc.STT_MODEL,
"tts_model": voice_svc.TTS_MODEL,
"reason": None if available else "OPENAI_API_KEY 미설정",
}
return JSONResponse(body, status_code=200 if available else 503)
# ════════════════════════════════════════════════════════════════════════════
# WebSocket — 실시간 음성 캐스케이드
# ════════════════════════════════════════════════════════════════════════════
@router.websocket("/ws")
async def voice_ws(websocket: WebSocket) -> None:
"""음성 실시간 턴 캐스케이드.
쿼리: ?session_id=<hex> (없으면 persona_code 로 일회용 in-proc 세션 생성 — 시연용)
오디오 in(바이너리) → STT → 상담 1턴 → TTS out(바이너리) + 립싱크 힌트.
"""
await websocket.accept()
# 1) 음성 미설정 → degraded 알리고 정상 종료(크래시 금지)
if not voice_service.is_available():
await _safe_send_json(
websocket,
{"type": "degraded", "reason": "OPENAI_API_KEY 미설정 — 음성 기능 비활성"},
)
await _safe_close(websocket, WS_CLOSE_DEGRADED)
return
# 2) 세션 바인딩 — session_id 우선, 없으면 persona_code 로 시연 세션 생성
session_id, voice_preset, err = _bind_session(websocket)
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
await _safe_send_json(
websocket,
{
"type": "ready",
"session_id": session_id,
"voice": voice_preset.openai_voice,
"preset": voice_preset.preset,
"state": "idle",
},
)
audio_buf = bytearray()
receiving = False
try:
while True:
msg = await websocket.receive()
mtype = msg.get("type")
if mtype == "websocket.disconnect":
break
# ── 바이너리 = 오디오 청크 누적 ──
if msg.get("bytes") is not None:
if not receiving:
# audio_start 없이 들어온 바이너리 — 관용적으로 자동 시작
receiving = True
audio_buf.clear()
await _safe_send_json(websocket, {"type": "state", "state": "listening"})
audio_buf.extend(msg["bytes"])
if len(audio_buf) > _MAX_AUDIO_BYTES:
await _safe_send_json(
websocket,
{"type": "error", "detail": "audio too large — 발화를 짧게 끊어 주세요"},
)
audio_buf.clear()
receiving = False
continue
# ── 텍스트 = JSON 제어 ──
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":
receiving = True
audio_buf.clear()
await _safe_send_json(websocket, {"type": "state", "state": "listening"})
elif ctype == "audio_end":
receiving = False
await _handle_utterance(
websocket,
session_id=session_id,
voice_preset=voice_preset,
audio=bytes(audio_buf),
fmt=ctrl.get("format"),
)
audio_buf.clear()
elif ctype == "text_turn":
# 음성 없이 텍스트만 보내는 경로(접근성/디버그): STT 건너뛰고 바로 턴.
receiving = False
audio_buf.clear()
learner_text = (ctrl.get("text") or "").strip()
if learner_text:
await _run_turn_and_speak(
websocket,
session_id=session_id,
voice_preset=voice_preset,
learner_text=learner_text,
)
elif ctype == "ping":
await _safe_send_json(websocket, {"type": "pong"})
elif ctype == "close":
break
except WebSocketDisconnect:
pass
except Exception as e: # 어떤 예외도 WS 를 깨끗이 닫고 알린다(크래시 금지)
await _safe_send_json(websocket, {"type": "error", "detail": f"voice ws error: {e}"})
finally:
await _safe_close(websocket)
# ════════════════════════════════════════════════════════════════════════════
# 발화 1건 처리 — STT → 턴 → TTS
# ════════════════════════════════════════════════════════════════════════════
async def _handle_utterance(
websocket: WebSocket,
*,
session_id: str,
voice_preset: VoicePreset,
audio: bytes,
fmt: Optional[str],
) -> None:
"""오디오 1발화 → STT → 상담 턴 → TTS 캐스케이드."""
if not audio:
await _safe_send_json(websocket, {"type": "transcript", "text": "", "final": True})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
# 1) STT (thinking 진입)
await _safe_send_json(websocket, {"type": "state", "state": "thinking"})
filename, content_type = _audio_meta(fmt)
try:
stt = await voice_service.transcribe(
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 실패: {e}"})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
learner_text = stt.text
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,
session_id=session_id,
voice_preset=voice_preset,
learner_text=learner_text,
)
async def _run_turn_and_speak(
websocket: WebSocket,
*,
session_id: str,
voice_preset: VoicePreset,
learner_text: str,
) -> None:
"""상담 1턴(orchestrator) → 내담자 텍스트 → TTS 오디오/립싱크 힌트 역방향 전송."""
sess = store.get(session_id)
if sess is None or sess.ended:
await _safe_send_json(websocket, {"type": "error", "detail": "세션 없음/종료됨"})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
recall = memory.RecallContext()
ctx = orchestrator.prepare_turn(
session_id=session_id,
case_id=sess.case_id,
card=sess.persona,
state=sess.state,
learner_text=learner_text,
recall_summary=recall.recall_summary,
pinned_facts=recall.pinned_facts,
recent_turns=sess.recent_turns(),
)
assert ctx.state_after is not None
# 학습자 발화 로깅(마스킹본) — sessions.py 패턴과 동일
store.append_turn(
session_id,
TurnRecord(
turn_seq=ctx.state_after.turn_seq,
speaker="counselor",
stage=ctx.state_after.stage.value,
text=learner_text,
text_masked=ctx.learner_text_masked,
),
)
# 2) 내담자 AI 1턴(동기 — 음성은 TTS 전 전체 텍스트가 필요)
try:
result = await orchestrator.run_turn_generate(ctx, engine_client)
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 ""
# 내담자 응답 로깅 + 상태 체크포인트
if reply:
store.append_turn(
session_id,
TurnRecord(
turn_seq=result.turn_seq,
speaker="client",
stage=result.stage,
text=reply,
text_masked=reply,
),
)
store.update_state(session_id, result.state_after)
# 내담자 텍스트 이벤트(설계 §5.3 자막 — partial 없이 final)
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,
},
)
if not reply:
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
# 3) TTS (speaking) — 청크별 메타 JSON(립싱크 rms) + 바이너리 오디오
await _safe_send_json(
websocket,
{"type": "state", "state": "speaking", "voice": voice_preset.openai_voice},
)
try:
n = 0
async for ck in voice_service.synthesize_stream(reply, voice_preset):
# 메타 먼저(프론트가 직후 바이너리와 짝지음) — 설계 §4.3 RMS 1채널
await _safe_send_json(
websocket, {"type": "tts_chunk", "seq": ck.seq, "rms": round(ck.rms, 4)}
)
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 실패: {e}"})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
# ════════════════════════════════════════════════════════════════════════════
# 세션 바인딩 / 메타 헬퍼
# ════════════════════════════════════════════════════════════════════════════
def _bind_session(
websocket: WebSocket,
) -> tuple[Optional[str], Optional[VoicePreset], Optional[str]]:
"""쿼리에서 세션을 바인딩(또는 시연 세션 생성)하고 voice preset 을 해석.
우선순위:
?session_id=<hex> — 기존 세션(REST 로 시작된)에 음성 부착
?persona_code=P1[&preset=] — in-proc 시연 세션 생성(DB off 폴백)
반환 (session_id, voice_preset, error).
"""
qp = websocket.query_params
explicit_preset = qp.get("preset")
session_id = qp.get("session_id")
if session_id:
sess = store.get(session_id)
if sess is None:
return None, None, f"unknown session {session_id}"
if sess.ended:
return None, None, "session already ended"
vp = resolve_voice(persona_code=sess.persona.code, preset=explicit_preset)
return session_id, vp, None
# persona_code 로 시연 세션 생성(REST 미경유 음성 단독 데모)
persona_code = qp.get("persona_code")
if not persona_code:
return None, None, "session_id 또는 persona_code 쿼리 필요"
card = persona.get_seed_persona(persona_code)
if card is None:
return None, None, f"unknown persona {persona_code}"
from ..services import state_machine
st = state_machine.init_state(
base_resistance=card.base_resistance(),
unlock_rate=card.unlock_rate(),
decay_floor=card.decay_floor(),
ideation_baseline=card.ideation_baseline(),
)
sess = store.create(
learner_id="dev-learner-voice",
persona=card,
theory_mode="humanistic",
state=st,
session_no=1,
)
vp = resolve_voice(persona_code=card.code, preset=explicit_preset)
return sess.session_id, vp, None
def _audio_meta(fmt: Optional[str]) -> tuple[str, str]:
"""클라가 알려준 포맷 → (filename, content_type). 기본 webm/opus."""
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"))
# ── 안전 송수신(연결 끊김 시 조용히 무시) ───────────────────────────────────
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"]