feat: 운영 안정성과 세션 음성 경험 개선

This commit is contained in:
Yun Chan 2026-07-31 00:13:08 +09:00
parent facc4ad2d9
commit c788343467
95 changed files with 8431 additions and 1785 deletions

View file

@ -1,8 +1,8 @@
"""음성 캐스케이드 — OpenAI STT(전사) + TTS(멀티보이스) 어댑터.
"""음성 캐스케이드 — OpenAI STT + OpenAI/Higgs 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).
TTS : OpenAI /v1/audio/speech 또는 로컬 Higgs v3. 내담자 텍스트 음성.
설계 원칙( 모듈의 경계):
- 순수 어댑터: httpx OpenAI 음성 엔드포인트만 호출한다. 상담 로직(orchestrator)·상태머신은
@ -34,6 +34,7 @@ from ..paths import repo_root, repo_path
OPENAI_BASE_URL = "https://api.openai.com/v1"
STT_ENDPOINT = "/audio/transcriptions"
TTS_ENDPOINT = "/audio/speech"
HIGGS_TTS_ENDPOINT = "/tts"
# STT 모델: gpt-4o-transcribe(고품질) — 미가용 폴백은 whisper-1.
STT_MODEL = "gpt-4o-transcribe"
@ -41,6 +42,7 @@ STT_MODEL_FALLBACK = "whisper-1"
# TTS 모델: gpt-4o-mini-tts(저지연·표현력) — 폴백 tts-1.
TTS_MODEL = "gpt-4o-mini-tts"
TTS_MODEL_FALLBACK = "tts-1"
HIGGS_TTS_MODEL = "higgs-audio-v3-tts-4b"
# 전사 언어 힌트(상담은 한국어). OpenAI 는 ISO-639-1.
STT_LANGUAGE = "ko"
@ -76,6 +78,25 @@ _POC_SAMPLE_TTS_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = (
),
)
_HIGGS_DELIVERY_TAGS: tuple[tuple[tuple[str, ...], str], ...] = (
(
("엄마", "비밀", "말하지", "불안", "무서", "걱정", "들키", "갈래"),
"<|emotion:fear|><|prosody:speed_fast|><|prosody:pitch_high|>",
),
(
("", "피곤", "무거", "아무것도", "지쳐", "힘들", "에너지"),
"<|emotion:sadness|><|prosody:speed_slow|><|prosody:expressive_low|>",
),
(
("오늘은", "친구", "", "괜찮았", "좋았", "해냈"),
"<|emotion:contentment|><|prosody:speed_fast|>",
),
(
("괜찮", "들어", "고마", "선생님", "편해", "조금", "말해"),
"<|emotion:relief|><|prosody:speed_slow|>",
),
)
# OpenAI 공식 voice 풀(2026 기준): alloy, ash, ballad, coral, echo, fable,
# nova, onyx, sage, shimmer, verse. 페르소나 톤별로 골라 매핑한다.
_OPENAI_VOICES = {
@ -236,6 +257,35 @@ def resolve_voice_from_map(
)
def build_higgs_prompt(text: str, voice: VoicePreset) -> str:
"""합성 seed의 화자 정체성을 지키면서 감정·속도 태그를 첫 단어 뒤에 넣는다."""
normalized = text.casefold()
tags = ""
if voice.preset == POC_SAMPLE_TTS_PRESET:
for keywords, candidate in _HIGGS_DELIVERY_TAGS:
if any(keyword.casefold() in normalized for keyword in keywords):
tags = candidate
break
if not tags:
tags = (
"<|emotion:helplessness|><|prosody:speed_slow|>"
"<|prosody:expressive_low|>"
)
elif voice.rate <= 0.85:
tags = "<|prosody:speed_slow|>"
elif voice.rate >= 1.15:
tags = "<|prosody:speed_fast|>"
if not tags:
return text
# Higgs 강한 감정 태그를 맨 앞에 두면 reference 화자가 흔들릴 수 있다. 첫 단어로
# 화자를 먼저 고정한 뒤 태그 다음 단어를 공백 없이 이어 붙인다.
match = re.match(r"^(\S+\s+)(.+)$", text, flags=re.DOTALL)
if match:
return f"{match.group(1)}{tags}{match.group(2).lstrip()}"
return tags + text
# 비언어 지문 패턴: (…)·(…)·[…]·【…】. 내담자 발화의 무대지시(고개 끄덕/한숨/침묵 등).
_STAGE_DIRECTION_RE = re.compile(r"[\(\[【][^\)\]】]*[\)\]】]")
@ -311,7 +361,7 @@ def assess_end_of_turn(
# OpenAI 음성 서비스
# ════════════════════════════════════════════════════════════════════════════
class VoiceService:
"""OpenAI STT/TTS 어댑터. 앱 수명주기 동안 1 인스턴스 재사용(httpx 풀 공유)."""
"""OpenAI STT와 선택형 OpenAI/Higgs TTS 어댑터(httpx 풀 공유)."""
def __init__(
self,
@ -321,10 +371,27 @@ class VoiceService:
poc_sample_tts_enabled: Optional[bool] = None,
environment: Optional[str] = None,
poc_sample_tts_dir: Optional[str | Path] = None,
tts_provider: Optional[str] = None,
higgs_base_url: Optional[str] = None,
higgs_timeout_seconds: Optional[float] = 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._tts_provider = (
tts_provider if tts_provider is not None else settings.voice_tts_provider
).strip().lower()
self._higgs_base_url = (
higgs_base_url if higgs_base_url is not None else settings.higgs_tts_url
).rstrip("/")
self._higgs_timeout_seconds = max(
1.0,
float(
higgs_timeout_seconds
if higgs_timeout_seconds is not None
else settings.higgs_tts_timeout_seconds
),
)
self._poc_sample_tts_enabled = (
bool(settings.voice_poc_sample_tts_enabled)
if poc_sample_tts_enabled is None
@ -340,35 +407,77 @@ class VoiceService:
sample_dir = repo_root() / sample_dir
self._poc_sample_tts_dir = sample_dir
self._client: Optional[httpx.AsyncClient] = None
self._higgs_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),
)
if self._api_key:
self._client = httpx.AsyncClient(
base_url=self._base_url,
headers={"Authorization": f"Bearer {self._api_key}"},
timeout=httpx.Timeout(60.0, connect=10.0),
)
if self._higgs_enabled():
self._higgs_client = httpx.AsyncClient(
base_url=self._higgs_base_url,
timeout=httpx.Timeout(self._higgs_timeout_seconds, connect=3.0),
)
async def shutdown(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
if self._higgs_client is not None:
await self._higgs_client.aclose()
self._higgs_client = None
def is_available(self) -> bool:
"""음성 기능 가용 여부(키 설정됨). 라우트가 핸드셰이크에서 검사."""
"""마이크 캐스케이드(STT+TTS) 전체 가용 여부."""
return self.stt_available() and self.tts_available()
def stt_available(self) -> bool:
return bool(self._api_key)
def tts_available(self, voice: VoicePreset | None = None) -> bool:
if self._higgs_enabled() and (voice is None or self._should_use_higgs_tts(voice)):
return True
if self._poc_sample_tts_available():
return True
return bool(self._api_key)
def tts_provider(self) -> str:
if self._higgs_enabled():
return "higgs"
if self._poc_sample_tts_available():
return "p1-sample-poc"
if self._tts_provider == "higgs" and self._environment != "dev":
return "disabled-non-dev"
if self._api_key:
return "openai"
if self._poc_sample_tts_enabled and self._environment != "dev":
return "disabled-non-dev"
return "unavailable"
def tts_provider_for_voice(self, voice: VoicePreset) -> str:
if self._should_use_higgs_tts(voice):
return "higgs"
if self._should_use_poc_sample_tts(voice):
return "p1-sample-poc"
return "openai" if self._api_key else "unavailable"
def tts_model_for_voice(self, voice: VoicePreset) -> str:
return HIGGS_TTS_MODEL if self._should_use_higgs_tts(voice) else TTS_MODEL
def tts_media_type_for_voice(self, voice: VoicePreset) -> str:
return "audio/wav" if self._should_use_higgs_tts(voice) else "audio/mpeg"
def _higgs_enabled(self) -> bool:
return self._tts_provider == "higgs" and self._environment == "dev"
def _should_use_higgs_tts(self, voice: VoicePreset) -> bool:
# 현재 권리 안전한 synthetic reference는 P1 서연 프리셋만 보유한다.
return self._higgs_enabled() and voice.preset == POC_SAMPLE_TTS_PRESET
def poc_sample_tts_available(self) -> bool:
return self._poc_sample_tts_available()
@ -402,6 +511,17 @@ class VoiceService:
)
return self._client
@property
def _higgs_http(self) -> httpx.AsyncClient:
if not self._higgs_enabled():
raise VoiceUnavailable("Higgs TTS는 로컬 dev 환경에서만 사용할 수 있습니다.")
if self._higgs_client is None:
self._higgs_client = httpx.AsyncClient(
base_url=self._higgs_base_url,
timeout=httpx.Timeout(self._higgs_timeout_seconds, connect=3.0),
)
return self._higgs_client
# ── STT (transcriptions) ─────────────────────────────
async def transcribe(
self,
@ -456,16 +576,20 @@ class VoiceService:
model: str = TTS_MODEL,
response_format: str = TTS_RESPONSE_FORMAT,
) -> AsyncIterator[TTSChunk]:
"""텍스트 → 음성 스트리밍(OpenAI /audio/speech). 청크 + RMS 힌트 yield.
"""텍스트 → 음성 스트리밍(OpenAI 또는 로컬 Higgs). 오디오 청크를 yield한다.
설계 §5.2 'speaking' 상태: 오디오 청크를 흘리며 진폭 힌트(립싱크) 같이 보낸다.
없으면 VoiceUnavailable. OpenAI 오류는 RuntimeError 전파.
선택 provider가 준비되지 않으면 VoiceUnavailable, 전송 오류는 RuntimeError로 전파한다.
"""
# 비언어 지문((고개 끄덕)·(한숨)·[침묵])은 음성으로 읽지 않는다. 자막엔 남고
# 아바타 애니메이션이 표현한다. 지문만 있는 발화는 합성 생략(빈 오디오).
text = speakable_text(text)
if not text:
return
if self._should_use_higgs_tts(voice):
async for chunk in self._synthesize_higgs_tts(text, voice):
yield chunk
return
if self._should_use_poc_sample_tts(voice):
async for chunk in self._synthesize_poc_sample_tts(text):
yield chunk
@ -516,6 +640,31 @@ class VoiceService:
if chunk:
yield TTSChunk(audio=chunk)
async def _synthesize_higgs_tts(
self, text: str, voice: VoicePreset
) -> AsyncIterator[TTSChunk]:
payload = {
"text": build_higgs_prompt(text, voice),
"preset": voice.preset,
}
try:
async with self._higgs_http.stream(
"POST", HIGGS_TTS_ENDPOINT, json=payload
) as response:
response.raise_for_status()
async for chunk in response.aiter_bytes(chunk_size=POC_SAMPLE_TTS_CHUNK_SIZE):
if chunk:
yield TTSChunk(audio=chunk)
except httpx.HTTPStatusError as exc:
body = ""
try:
body = (await exc.response.aread()).decode("utf-8", "ignore")[:200]
except Exception:
pass
raise RuntimeError(f"Higgs TTS {exc.response.status_code}: {body}") from exc
except httpx.HTTPError as exc:
raise RuntimeError(f"Higgs TTS transport error: {exc}") from exc
def _select_poc_sample_id(self, text: str) -> str:
normalized = text.casefold()
for sample_id, keywords in _POC_SAMPLE_TTS_KEYWORDS:
@ -576,6 +725,7 @@ __all__ = [
"resolve_voice",
"resolve_voice_from_map",
"build_tts_payload",
"build_higgs_prompt",
"assess_end_of_turn",
"EOT_SILENCE_THRESHOLD_MS",
"PRESET_TO_OPENAI_VOICE",
@ -583,4 +733,5 @@ __all__ = [
"DEFAULT_OPENAI_VOICE",
"STT_MODEL",
"TTS_MODEL",
"HIGGS_TTS_MODEL",
]