vignette/apps/api/app/services/voice.py
2026-06-27 11:20:24 +09:00

532 lines
21 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(멀티보이스) 어댑터.
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 re
from dataclasses import dataclass
from pathlib import Path
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"
# End-of-turn readiness default for cascaded STT providers.
EOT_SILENCE_THRESHOLD_MS = 1200
_REPO_ROOT = Path(__file__).resolve().parents[4]
POC_SAMPLE_TTS_PRESET = "soft-young-fem"
POC_SAMPLE_TTS_DEFAULT_DIR = (
_REPO_ROOT / "docs" / "voice-art" / "p1-seoyeon-higgs-v3-20260627"
)
POC_SAMPLE_TTS_CHUNK_SIZE = 4096
_POC_SAMPLE_TTS_DEFAULT_SAMPLE = "p1_seoyeon_01_depressed_slow"
_POC_SAMPLE_TTS_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"p1_seoyeon_03_anxious_guarded",
("엄마", "비밀", "말하지", "불안", "무서", "걱정", "들키", "", "갈래"),
),
(
"p1_seoyeon_02_tired_flat",
("", "피곤", "무거", "아무것도", "지쳐", "힘들", "에너지"),
),
(
"p1_seoyeon_05_recovered_lively",
("오늘은", "친구", "", "괜찮았", "좋았", "해냈"),
),
(
"p1_seoyeon_04_rapport_relief",
("괜찮", "들어", "고마", "선생님", "편해", "조금", "말해"),
),
)
# 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(frozen=True, slots=True)
class EndOfTurnDecision:
"""Provider-neutral readiness signal for a completed learner utterance."""
ready: bool
transcript_ready: bool
silence_ready: bool
silence_ms: int
threshold_ms: int
reason: str
@dataclass(slots=True)
class TTSChunk:
"""TTS 스트림 1청크(오디오 바이트). 립싱크는 프론트 Web Audio AnalyserNode가 자체 산출."""
audio: bytes
# ════════════════════════════════════════════════════════════════════════════
# 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,
)
# 비언어 지문 패턴: (…)·(…)·[…]·【…】. 내담자 발화의 무대지시(고개 끄덕/한숨/침묵 등).
_STAGE_DIRECTION_RE = re.compile(r"[\(\[【][^\)\]】]*[\)\]】]")
def speakable_text(text: str) -> str:
"""TTS로 읽을 텍스트만 남긴다 — 비언어 지문((고개 살짝 끄덕)·(한숨)·[침묵])을 제거.
지문은 자막/회기리뷰에 남고 아바타 애니메이션이 표현하며, 음성으로는 읽지 않는다.
지문만으로 이뤄진 발화(예: "(침묵)")는 빈 문자열을 반환 → 합성 생략.
"""
if not text:
return ""
stripped = _STAGE_DIRECTION_RE.sub(" ", text)
# 말줄임표/중복 공백 정리 + 고아 구두점 앞 공백 제거
stripped = re.sub(r"\s+", " ", stripped)
stripped = re.sub(r"\s+([,.!?…」』】)])", r"\1", stripped)
return stripped.strip()
def build_tts_payload(
text: str,
voice: VoicePreset,
*,
model: str = TTS_MODEL,
response_format: str = TTS_RESPONSE_FORMAT,
) -> dict[str, object]:
"""Build the deterministic OpenAI TTS payload for a resolved voice preset."""
payload: dict[str, object] = {
"model": model,
"voice": voice.openai_voice,
"input": text,
"response_format": response_format,
"speed": _clamp_speed(voice.rate),
}
if voice.instructions and model.startswith("gpt-4o"):
payload["instructions"] = voice.instructions
return payload
def assess_end_of_turn(
*,
transcript_text: Optional[str],
transcript_final: bool,
silence_ms: Optional[int],
silence_threshold_ms: int = EOT_SILENCE_THRESHOLD_MS,
) -> EndOfTurnDecision:
"""Return whether final STT text plus observed silence is enough to run a turn."""
observed_silence = _nonnegative_int(silence_ms)
threshold = max(0, _nonnegative_int(silence_threshold_ms))
has_text = bool((transcript_text or "").strip())
transcript_ready = bool(transcript_final and has_text)
silence_ready = observed_silence >= threshold
ready = transcript_ready and silence_ready
if ready:
reason = "ready"
elif not has_text:
reason = "empty_transcript"
elif not transcript_final:
reason = "final_transcript_pending"
else:
reason = "silence_threshold_pending"
return EndOfTurnDecision(
ready=ready,
transcript_ready=transcript_ready,
silence_ready=silence_ready,
silence_ms=observed_silence,
threshold_ms=threshold,
reason=reason,
)
# ════════════════════════════════════════════════════════════════════════════
# OpenAI 음성 서비스
# ════════════════════════════════════════════════════════════════════════════
class VoiceService:
"""OpenAI STT/TTS 어댑터. 앱 수명주기 동안 1 인스턴스 재사용(httpx 풀 공유)."""
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
*,
poc_sample_tts_enabled: Optional[bool] = None,
environment: Optional[str] = None,
poc_sample_tts_dir: Optional[str | Path] = 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._environment = environment if environment is not None else settings.environment
self._poc_sample_tts_enabled = (
bool(settings.voice_poc_sample_tts_enabled)
if poc_sample_tts_enabled is None
else bool(poc_sample_tts_enabled)
)
sample_dir_value: str | Path = (
poc_sample_tts_dir
if poc_sample_tts_dir is not None
else (settings.voice_poc_sample_tts_dir or POC_SAMPLE_TTS_DEFAULT_DIR)
)
sample_dir = Path(sample_dir_value)
if not sample_dir.is_absolute():
sample_dir = _REPO_ROOT / sample_dir
self._poc_sample_tts_dir = sample_dir
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)
def tts_provider(self) -> str:
if self._poc_sample_tts_available():
return "p1-sample-poc"
if self._api_key:
return "openai"
if self._poc_sample_tts_enabled and self._environment != "dev":
return "disabled-non-dev"
return "unavailable"
def poc_sample_tts_available(self) -> bool:
return self._poc_sample_tts_available()
def _poc_sample_tts_available(self) -> bool:
return (
self._poc_sample_tts_enabled
and self._environment == "dev"
and self._poc_sample_path(_POC_SAMPLE_TTS_DEFAULT_SAMPLE).is_file()
)
def _should_use_poc_sample_tts(self, voice: VoicePreset) -> bool:
return (
self._poc_sample_tts_enabled
and self._environment == "dev"
and voice.preset == POC_SAMPLE_TTS_PRESET
)
def _poc_sample_path(self, sample_id: str) -> Path:
return self._poc_sample_tts_dir / f"{sample_id}.mp3"
@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 전파.
"""
# 비언어 지문((고개 끄덕)·(한숨)·[침묵])은 음성으로 읽지 않는다. 자막엔 남고
# 아바타 애니메이션이 표현한다. 지문만 있는 발화는 합성 생략(빈 오디오).
text = speakable_text(text)
if not text:
return
if self._should_use_poc_sample_tts(voice):
async for chunk in self._synthesize_poc_sample_tts(text):
yield chunk
return
payload = build_tts_payload(
text,
voice,
model=model,
response_format=response_format,
)
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)
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_poc_sample_tts(self, text: str) -> AsyncIterator[TTSChunk]:
sample_id = self._select_poc_sample_id(text)
sample_path = self._poc_sample_path(sample_id)
try:
data = sample_path.read_bytes()
except OSError as e:
raise VoiceUnavailable(f"P1 sample TTS asset is missing: {sample_path}") from e
for i in range(0, len(data), POC_SAMPLE_TTS_CHUNK_SIZE):
chunk = data[i : i + POC_SAMPLE_TTS_CHUNK_SIZE]
if chunk:
yield TTSChunk(audio=chunk)
def _select_poc_sample_id(self, text: str) -> str:
normalized = text.casefold()
for sample_id, keywords in _POC_SAMPLE_TTS_KEYWORDS:
if any(keyword.casefold() in normalized for keyword in keywords):
return sample_id
return _POC_SAMPLE_TTS_DEFAULT_SAMPLE
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
for i in range(0, len(data), 4096):
chunk = data[i : i + 4096]
yield TTSChunk(audio=chunk)
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
def _nonnegative_int(value: object) -> int:
try:
return max(0, int(value)) # type: ignore[arg-type]
except (TypeError, ValueError):
return 0
# 앱 전역 싱글톤 (main lifespan 이 startup/shutdown — Foundation 이 관리하거나
# 라우트가 lazy 사용). engine_client 패턴과 동일.
voice_service = VoiceService()
__all__ = [
"VoiceUnavailable",
"VoicePreset",
"TranscriptResult",
"EndOfTurnDecision",
"TTSChunk",
"VoiceService",
"voice_service",
"resolve_voice",
"build_tts_payload",
"assess_end_of_turn",
"EOT_SILENCE_THRESHOLD_MS",
"PRESET_TO_OPENAI_VOICE",
"PERSONA_CODE_TO_PRESET",
"DEFAULT_OPENAI_VOICE",
"STT_MODEL",
"TTS_MODEL",
]