feat: 운영 안정성과 세션 음성 경험 개선
This commit is contained in:
parent
facc4ad2d9
commit
c788343467
95 changed files with 8431 additions and 1785 deletions
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue