음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View file

@ -21,11 +21,12 @@ from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import AsyncIterator, Optional
from typing import Any, AsyncIterator, Mapping, Optional
import httpx
from ..config import settings
from ..paths import repo_root, repo_path
# ════════════════════════════════════════════════════════════════════════════
# OpenAI 음성 엔드포인트/모델 상수
@ -50,10 +51,9 @@ 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"
repo_path("docs", "voice-art", "p1-seoyeon-higgs-v3-20260627")
)
POC_SAMPLE_TTS_CHUNK_SIZE = 4096
_POC_SAMPLE_TTS_DEFAULT_SAMPLE = "p1_seoyeon_01_depressed_slow"
@ -190,6 +190,51 @@ def resolve_voice(
)
def resolve_voice_from_map(
*,
provider: str,
voice_id: str,
base_params: Mapping[str, Any] | None,
persona_code: Optional[str] = None,
) -> VoicePreset | None:
"""DB persona_voice_map row -> live OpenAI VoicePreset.
provider-agnostic rows are allowed in the catalog, but this service only
knows how to send OpenAI TTS. Unsupported providers return None so callers
can fall back to the existing preset resolver.
"""
if provider.strip().lower() != "openai":
return None
fallback = resolve_voice(persona_code=persona_code)
params = dict(base_params or {})
preset = _clean_optional_text(params.get("preset")) or fallback.preset
mapped_voice = _clean_optional_text(params.get("openai_voice"))
voice_id_value = _clean_optional_text(voice_id)
if not mapped_voice and voice_id_value in _OPENAI_VOICES:
mapped_voice = voice_id_value
if not mapped_voice:
mapped_voice = PRESET_TO_OPENAI_VOICE.get(preset, fallback.openai_voice)
if mapped_voice not in _OPENAI_VOICES:
mapped_voice = fallback.openai_voice
if mapped_voice not in _OPENAI_VOICES:
mapped_voice = DEFAULT_OPENAI_VOICE
rate = PRESET_RATE.get(preset, fallback.rate)
if "rate" in params:
try:
rate = float(params["rate"])
except (TypeError, ValueError):
rate = fallback.rate
return VoicePreset(
preset=preset,
openai_voice=mapped_voice,
rate=rate,
instructions=_clean_optional_text(params.get("instructions")),
)
# 비언어 지문 패턴: (…)·(…)·[…]·【…】. 내담자 발화의 무대지시(고개 끄덕/한숨/침묵 등).
_STAGE_DIRECTION_RE = re.compile(r"[\(\[【][^\)\]】]*[\)\]】]")
@ -291,7 +336,7 @@ class VoiceService:
)
sample_dir = Path(sample_dir_value)
if not sample_dir.is_absolute():
sample_dir = _REPO_ROOT / sample_dir
sample_dir = repo_root() / sample_dir
self._poc_sample_tts_dir = sample_dir
self._client: Optional[httpx.AsyncClient] = None
@ -507,6 +552,13 @@ def _nonnegative_int(value: object) -> int:
return 0
def _clean_optional_text(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
# 앱 전역 싱글톤 (main lifespan 이 startup/shutdown — Foundation 이 관리하거나
# 라우트가 lazy 사용). engine_client 패턴과 동일.
voice_service = VoiceService()
@ -521,6 +573,7 @@ __all__ = [
"VoiceService",
"voice_service",
"resolve_voice",
"resolve_voice_from_map",
"build_tts_payload",
"assess_end_of_turn",
"EOT_SILENCE_THRESHOLD_MS",