현재 작업 상태 저장
This commit is contained in:
parent
07cc67761e
commit
6bd91b0d5e
674 changed files with 8726 additions and 298 deletions
|
|
@ -20,6 +20,7 @@ from __future__ import annotations
|
|||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
import httpx
|
||||
|
|
@ -49,6 +50,32 @@ 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 = {
|
||||
|
|
@ -240,9 +267,32 @@ def assess_end_of_turn(
|
|||
class VoiceService:
|
||||
"""OpenAI STT/TTS 어댑터. 앱 수명주기 동안 1 인스턴스 재사용(httpx 풀 공유)."""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None) -> None:
|
||||
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
|
||||
|
||||
# ── 수명주기 ──────────────────────────────────────────
|
||||
|
|
@ -264,6 +314,35 @@ class VoiceService:
|
|||
"""음성 기능 가용 여부(키 설정됨). 라우트가 핸드셰이크에서 검사."""
|
||||
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:
|
||||
|
|
@ -341,6 +420,10 @@ class VoiceService:
|
|||
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,
|
||||
|
|
@ -375,6 +458,25 @@ class VoiceService:
|
|||
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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue