"""음성 라우트 — 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= (없으면 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= — 기존 세션(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"]