vignette/apps/api/app/services/voice.py
2026-06-26 14:47:00 +09:00

370 lines
16 KiB
Python

"""음성 캐스케이드 — OpenAI STT(전사) + TTS(멀티보이스) 어댑터.
MASTERPLAN '음성 필수'(한신대 요구) / DESIGN_CONCEPT §5.2(음성 오브 4상태) / §4.3(립싱크 RMS):
STT : OpenAI /v1/audio/transcriptions (gpt-4o-transcribe | whisper-1). 학습자 음성 → 텍스트.
TTS : OpenAI /v1/audio/speech (gpt-4o-mini-tts | tts-1). 내담자 텍스트 → 음성(페르소나 voice).
설계 원칙(이 모듈의 경계):
- 순수 어댑터: httpx 로 OpenAI 음성 엔드포인트만 호출한다. 상담 로직(orchestrator)·상태머신은
호출부(routes/voice.py)가 조립한다. 여기는 "오디오↔텍스트" 변환 + voice preset 매핑만.
- API 키 없으면 명확히 degraded: is_available()=False, 호출 시 VoiceUnavailable.
절대 크래시·무한대기 금지(라우트가 503/close 로 변환).
- 립싱크 힌트: TTS 오디오 청크를 흘리며 RMS(진폭) 힌트를 같이 산출(설계 §4.3 — viseme 정밀
매칭 안 함, RMS 1채널). PCM 디코딩 의존성 없이 바이트 에너지 근사로 임시 RMS 추정.
페르소나 voice preset(persona/*.json voice.preset, 설계 §4.6) → OpenAI voice 매핑은
PRESET_TO_OPENAI_VOICE 테이블이 흡수. 새 preset 추가는 이 테이블만 손대면 된다.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import AsyncIterator, Optional
import httpx
from ..config import settings
# ════════════════════════════════════════════════════════════════════════════
# OpenAI 음성 엔드포인트/모델 상수
# ════════════════════════════════════════════════════════════════════════════
OPENAI_BASE_URL = "https://api.openai.com/v1"
STT_ENDPOINT = "/audio/transcriptions"
TTS_ENDPOINT = "/audio/speech"
# STT 모델: gpt-4o-transcribe(고품질) — 미가용 폴백은 whisper-1.
STT_MODEL = "gpt-4o-transcribe"
STT_MODEL_FALLBACK = "whisper-1"
# TTS 모델: gpt-4o-mini-tts(저지연·표현력) — 폴백 tts-1.
TTS_MODEL = "gpt-4o-mini-tts"
TTS_MODEL_FALLBACK = "tts-1"
# 전사 언어 힌트(상담은 한국어). OpenAI 는 ISO-639-1.
STT_LANGUAGE = "ko"
# TTS 출력 포맷: 브라우저 MediaSource/<audio> 친화. 스트리밍은 mp3/opus 청크.
TTS_RESPONSE_FORMAT = "mp3"
# OpenAI 공식 voice 풀(2026 기준): alloy, ash, ballad, coral, echo, fable,
# nova, onyx, sage, shimmer, verse. 페르소나 톤별로 골라 매핑한다.
_OPENAI_VOICES = {
"alloy", "ash", "ballad", "coral", "echo", "fable",
"nova", "onyx", "sage", "shimmer", "verse",
}
DEFAULT_OPENAI_VOICE = "sage"
# ── 페르소나 voice preset(설계 §4.6) → OpenAI voice ──────────────────────────
# preset 명명: <톤><연령><성별> 조합(soft-young-fem 등). 새 페르소나는 여기에만 추가.
PRESET_TO_OPENAI_VOICE: dict[str, str] = {
# 청소년 여성(P1 서연) — 부드럽고 톤 높은
"soft-young-fem": "coral",
# 성인 남성(P2 민재) — 차분·안정·약간 긴장
"calm-adult-male": "ash",
# 성인 여성(P3 지우) — 따뜻하지만 지친
"warm-adult-fem": "shimmer",
# 범용 폴백 프리셋
"neutral": "sage",
}
# ── 페르소나 code(P1/P2/P3) → 기본 preset (persona 카드에 voice 필드 없을 때) ──
# persona.py 시드는 voice 필드를 갖지 않으므로 code 로 기본 preset 을 정한다.
# DB/JSON 페르소나가 voice.preset 을 직접 주면 그걸 우선한다(resolve_voice 참조).
PERSONA_CODE_TO_PRESET: dict[str, str] = {
"P1": "soft-young-fem",
"P2": "calm-adult-male",
"P3": "warm-adult-fem",
}
# TTS rate(말 속도) 페르소나 기본값(설계 §4.6 voice.rate). 1.0=표준.
PRESET_RATE: dict[str, float] = {
"soft-young-fem": 0.96,
"calm-adult-male": 1.0,
"warm-adult-fem": 0.98,
"neutral": 1.0,
}
class VoiceUnavailable(RuntimeError):
"""음성 미설정/장애. 라우트가 503/WS close(degraded) 로 변환."""
@dataclass(slots=True)
class VoicePreset:
"""해석된 음성 프리셋(페르소나 → OpenAI 파라미터)."""
preset: str # 논리 preset 명(soft-young-fem 등)
openai_voice: str # OpenAI voice 파라미터
rate: float = 1.0 # 말 속도(speed)
instructions: Optional[str] = None # gpt-4o-mini-tts 표현 지시(선택)
@dataclass(slots=True)
class TranscriptResult:
"""STT 결과."""
text: str
language: Optional[str] = None
model: str = STT_MODEL
duration: Optional[float] = None
@dataclass(slots=True)
class TTSChunk:
"""TTS 스트림 1청크 + 립싱크 힌트(설계 §4.3 RMS 1채널)."""
audio: bytes
rms: float = 0.0 # 0~1, 입 열림(scaleY) 매핑용 근사 진폭
seq: int = 0
# ════════════════════════════════════════════════════════════════════════════
# voice preset 해석 (페르소나 → OpenAI 파라미터)
# ════════════════════════════════════════════════════════════════════════════
def resolve_voice(
*,
persona_code: Optional[str] = None,
preset: Optional[str] = None,
instructions: Optional[str] = None,
) -> VoicePreset:
"""페르소나 code 또는 명시 preset → OpenAI voice 파라미터로 해석.
우선순위: 명시 preset > persona_code 기본 preset > 'neutral'.
알 수 없는 preset 은 DEFAULT_OPENAI_VOICE 로 안전 폴백(크래시 없음).
"""
chosen = preset
if not chosen and persona_code:
chosen = PERSONA_CODE_TO_PRESET.get(persona_code.upper())
if not chosen:
chosen = "neutral"
openai_voice = PRESET_TO_OPENAI_VOICE.get(chosen, DEFAULT_OPENAI_VOICE)
if openai_voice not in _OPENAI_VOICES:
openai_voice = DEFAULT_OPENAI_VOICE
rate = PRESET_RATE.get(chosen, 1.0)
return VoicePreset(
preset=chosen,
openai_voice=openai_voice,
rate=rate,
instructions=instructions,
)
# ════════════════════════════════════════════════════════════════════════════
# 립싱크 RMS 근사 (설계 §4.3 — 정밀 viseme 안 함, 진폭 1채널)
# ════════════════════════════════════════════════════════════════════════════
def estimate_chunk_rms(chunk: bytes) -> float:
"""오디오 청크 바이트 에너지로 RMS(0~1) 근사.
압축 포맷(mp3) 바이트를 PCM 디코딩 없이 근사한다(의존성 0). 평균 바이트 편차를
0~1 로 정규화 → 프론트가 데드존(0.04)·지수평활(τ≈180ms) 적용해 입 열림에 매핑.
NOTE: 정밀 진폭이 필요하면 프론트 Web Audio AnalyserNode 가 재계산(설계 §4.3 권장).
이 힌트는 서버측 보조(네트워크 끊김/저사양 폴백)다.
"""
if not chunk:
return 0.0
# 128 중심 편차의 RMS(8bit 가정 근사). mp3 프레임이라 정밀치 아님(상대값).
n = len(chunk)
acc = 0
# 과샘플 비용 회피 — 최대 2048 바이트만 샘플링
step = max(1, n // 2048)
cnt = 0
for i in range(0, n, step):
d = chunk[i] - 128
acc += d * d
cnt += 1
if cnt == 0:
return 0.0
rms = math.sqrt(acc / cnt) / 128.0
return max(0.0, min(1.0, rms))
# ════════════════════════════════════════════════════════════════════════════
# OpenAI 음성 서비스
# ════════════════════════════════════════════════════════════════════════════
class VoiceService:
"""OpenAI STT/TTS 어댑터. 앱 수명주기 동안 1 인스턴스 재사용(httpx 풀 공유)."""
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None) -> None:
self._api_key = (api_key if api_key is not None else settings.openai_api_key) or ""
self._base_url = (base_url or settings.openai_base_url or OPENAI_BASE_URL).rstrip("/")
self._client: Optional[httpx.AsyncClient] = None
# ── 수명주기 ──────────────────────────────────────────
async def startup(self) -> None:
if not self._api_key:
return # 키 없으면 클라이언트도 안 띄움(degraded). 라우트가 503 처리.
self._client = httpx.AsyncClient(
base_url=self._base_url,
headers={"Authorization": f"Bearer {self._api_key}"},
timeout=httpx.Timeout(60.0, connect=10.0),
)
async def shutdown(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
def is_available(self) -> bool:
"""음성 기능 가용 여부(키 설정됨). 라우트가 핸드셰이크에서 검사."""
return bool(self._api_key)
@property
def _http(self) -> httpx.AsyncClient:
if not self._api_key:
raise VoiceUnavailable("OPENAI_API_KEY 미설정 — 음성 기능 degraded")
if self._client is None:
# lazy 보강(테스트/지연 startup 대비)
self._client = httpx.AsyncClient(
base_url=self._base_url,
headers={"Authorization": f"Bearer {self._api_key}"},
timeout=httpx.Timeout(60.0, connect=10.0),
)
return self._client
# ── STT (transcriptions) ─────────────────────────────
async def transcribe(
self,
audio: bytes,
*,
filename: str = "audio.webm",
content_type: str = "audio/webm",
language: str = STT_LANGUAGE,
model: str = STT_MODEL,
) -> TranscriptResult:
"""오디오 바이트 → 텍스트 전사(OpenAI /audio/transcriptions).
클라가 보낸 webm/opus(또는 wav/mp3) 청크를 multipart 로 OpenAI 에 올린다.
키 없으면 VoiceUnavailable, OpenAI 오류는 그대로 RuntimeError 로 전파(라우트가 처리).
"""
if not audio:
return TranscriptResult(text="", model=model)
files = {"file": (filename, audio, content_type)}
data = {
"model": model,
"language": language,
"response_format": "json",
}
try:
r = await self._http.post(STT_ENDPOINT, files=files, data=data)
if r.status_code == 404 and model != STT_MODEL_FALLBACK:
# 모델 미가용(계정 권한) → whisper-1 폴백 1회
data["model"] = STT_MODEL_FALLBACK
r = await self._http.post(STT_ENDPOINT, files=files, data=data)
r.raise_for_status()
except VoiceUnavailable:
raise
except httpx.HTTPStatusError as e:
raise RuntimeError(f"STT {e.response.status_code}: {e.response.text[:200]}") from e
except httpx.HTTPError as e:
raise RuntimeError(f"STT transport error: {e}") from e
body = r.json()
return TranscriptResult(
text=(body.get("text") or "").strip(),
language=body.get("language"),
model=str(data["model"]),
duration=body.get("duration"),
)
# ── TTS (speech) — 스트리밍 ──────────────────────────
async def synthesize_stream(
self,
text: str,
voice: VoicePreset,
*,
model: str = TTS_MODEL,
response_format: str = TTS_RESPONSE_FORMAT,
) -> AsyncIterator[TTSChunk]:
"""텍스트 → 음성 스트리밍(OpenAI /audio/speech). 청크 + RMS 힌트 yield.
설계 §5.2 'speaking' 상태: 오디오 청크를 흘리며 진폭 힌트(립싱크)를 같이 보낸다.
키 없으면 VoiceUnavailable. OpenAI 오류는 RuntimeError 전파.
"""
if not text or not text.strip():
return
payload: dict[str, object] = {
"model": model,
"voice": voice.openai_voice,
"input": text,
"response_format": response_format,
"speed": _clamp_speed(voice.rate),
}
# gpt-4o-mini-tts 계열은 instructions(표현 지시) 지원. tts-1 은 무시됨.
if voice.instructions and model.startswith("gpt-4o"):
payload["instructions"] = voice.instructions
seq = 0
try:
async with self._http.stream("POST", TTS_ENDPOINT, json=payload) as r:
if r.status_code == 404 and model != TTS_MODEL_FALLBACK:
# 모델 미가용 → tts-1 폴백(비스트림 재시도). instructions 제거.
payload["model"] = TTS_MODEL_FALLBACK
payload.pop("instructions", None)
await r.aclose()
async for c in self._synthesize_fallback(payload):
yield c
return
r.raise_for_status()
async for chunk in r.aiter_bytes(chunk_size=4096):
if not chunk:
continue
yield TTSChunk(audio=chunk, rms=estimate_chunk_rms(chunk), seq=seq)
seq += 1
except VoiceUnavailable:
raise
except httpx.HTTPStatusError as e:
text_body = ""
try:
text_body = (await e.response.aread()).decode("utf-8", "ignore")[:200]
except Exception:
pass
raise RuntimeError(f"TTS {e.response.status_code}: {text_body}") from e
except httpx.HTTPError as e:
raise RuntimeError(f"TTS transport error: {e}") from e
async def _synthesize_fallback(self, payload: dict[str, object]) -> AsyncIterator[TTSChunk]:
"""tts-1 폴백(비스트림 POST → 전체 바이트를 청크로 분할)."""
try:
r = await self._http.post(TTS_ENDPOINT, json=payload)
r.raise_for_status()
except httpx.HTTPStatusError as e:
raise RuntimeError(f"TTS(fallback) {e.response.status_code}: {e.response.text[:200]}") from e
except httpx.HTTPError as e:
raise RuntimeError(f"TTS(fallback) transport error: {e}") from e
data = r.content
seq = 0
for i in range(0, len(data), 4096):
chunk = data[i : i + 4096]
yield TTSChunk(audio=chunk, rms=estimate_chunk_rms(chunk), seq=seq)
seq += 1
def _clamp_speed(rate: float) -> float:
"""OpenAI speed 허용범위 [0.25, 4.0] 클램프."""
try:
return max(0.25, min(4.0, float(rate)))
except (TypeError, ValueError):
return 1.0
# 앱 전역 싱글톤 (main lifespan 이 startup/shutdown — Foundation 이 관리하거나
# 라우트가 lazy 사용). engine_client 패턴과 동일.
voice_service = VoiceService()
__all__ = [
"VoiceUnavailable",
"VoicePreset",
"TranscriptResult",
"TTSChunk",
"VoiceService",
"voice_service",
"resolve_voice",
"estimate_chunk_rms",
"PRESET_TO_OPENAI_VOICE",
"PERSONA_CODE_TO_PRESET",
"DEFAULT_OPENAI_VOICE",
"STT_MODEL",
"TTS_MODEL",
]