Skip to content

Commit 7f52972

Browse files
authored
Merge pull request #97 from Team-StackUp/feature/tts-via-gateway
feat(ai): TTS를 Mindlogic 게이트웨이(충남대 키)로 라우팅 가능하게
2 parents fa79621 + 0b62d3c commit 7f52972

8 files changed

Lines changed: 173 additions & 9 deletions

File tree

ai/.env.example

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,9 @@ WHISPER_MODEL=whisper-1
6565
WHISPER_LANGUAGE=ko
6666
WHISPER_TIMEOUT_SEC=60
6767

68-
# TTS (질문 음성화). auto=GEMINI_API_KEY > OPENAI_API_KEY 순으로 선택, 둘 다 없으면 mock.
69-
# Deepgram/OpenAI TTS 는 한국어 미지원 → 기본은 Gemini TTS(GEMINI_API_KEY 재사용, raw PCM→WAV).
68+
# TTS (질문 음성화). auto=LLM_API_KEY(gateway) > GEMINI_API_KEY > OPENAI_API_KEY 순, 없으면 mock.
69+
# gateway: Mindlogic 게이트웨이(/audio/speech)가 Gemini TTS 로 라우팅 → 직접 GEMINI_API_KEY 429 부하 분산.
70+
# Deepgram/OpenAI TTS 는 한국어 미지원. gateway/gemini 모두 GEMINI_TTS_MODEL/VOICE 공유, raw PCM→WAV.
7071
TTS_PROVIDER=auto
7172
OPENAI_TTS_MODEL=gpt-4o-mini-tts
7273
OPENAI_TTS_VOICE=alloy

ai/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ chain = prompt | llm | PydanticOutputParser(pydantic_object=...)
190190
- 비용: $0.006 / 분 (1시간 면접 ≈ ₩500 / USD ≈ $0.36)
191191
- 셀프호스팅 옵션: `whisper.cpp` 또는 `faster-whisper` (GPU 권장, 비용 ↓ but 운영 부담 ↑)
192192
- 브라우저 내장 SpeechRecognition API는 정확도 부족으로 채택 안 함
193-
- **TTS: Gemini TTS 기본(한국어)** (`voice/tts/`) — 질문(INTERVIEWER) 메시지 음성화. Deepgram/OpenAI TTS 는 한국어 미지원이라 `GeminiTtsProvider`(`gemini-2.5-flash-preview-tts`, voice=Kore)가 기본. Gemini 는 raw PCM(L16/24kHz)을 반환하므로 WAV 로 감싸 `audio/wav` 로 저장. `TtsProvider` 추상화 + `GeminiTtsProvider`/`OpenAiTtsProvider`(`gpt-4o-mini-tts`, mp3)/`MockTtsProvider`, `build_tts_provider` factory(`TTS_PROVIDER=auto`면 GEMINI_API_KEY > OPENAI_API_KEY 순). `generate.tts` consumer 가 합성 → S3 PUT(확장자는 content_type 기준) → `callback.tts` 발행. 재생은 Core 오디오 프록시(`GET /api/sessions/{sid}/messages/{mid}/audio`) 경유(MinIO presigned URL 이 내부 호스트라 브라우저 직접 접근 불가).
193+
- **TTS: Gemini TTS 기본(한국어)** (`voice/tts/`) — 질문(INTERVIEWER) 메시지 음성화. Deepgram/OpenAI TTS 는 한국어 미지원이라 `GeminiTtsProvider`(`gemini-2.5-flash-preview-tts`, voice=Kore)가 기본. Gemini 는 raw PCM(L16/24kHz)을 반환하므로 WAV 로 감싸 `audio/wav` 로 저장. `TtsProvider` 추상화 + `GatewayTtsProvider`(Mindlogic 게이트웨이 `/audio/speech`, LLM_API_KEY, raw PCM→WAV)/`GeminiTtsProvider`(직접 GEMINI_API_KEY)/`OpenAiTtsProvider`(`gpt-4o-mini-tts`, mp3)/`MockTtsProvider`, `build_tts_provider` factory(`TTS_PROVIDER=auto`면 **LLM_API_KEY(gateway) > GEMINI_API_KEY > OPENAI_API_KEY** 순 — 게이트웨이 우선으로 직접 키 429 부하 분산). gateway/gemini 는 `gemini_tts_model`/`gemini_tts_voice` 공유. `generate.tts` consumer 가 합성 → S3 PUT(확장자는 content_type 기준) → `callback.tts` 발행. 재생은 Core 오디오 프록시(`GET /api/sessions/{sid}/messages/{mid}/audio`) 경유(MinIO presigned URL 이 내부 호스트라 브라우저 직접 접근 불가).
194194
- **스트리밍 STT (실시간 음성 답변, RT3): Deepgram Live** (`voice/stt/deepgram_live.py`) — `websockets`로 Deepgram WS(`wss://api.deepgram.com/v1/listen`, nova-2)에 연결, interim/final 자막을 실시간 반환. `voice/stt/live.py`(`LiveSttProvider`/`LiveSttSession` 추상) + `voice/stt/mock_live.py`(키 없을 때 fallback) + `voice/stt/live_factory.py`(`LIVE_STT_PROVIDER=auto`면 DEEPGRAM_API_KEY 보유 시 deepgram_live).
195195
- FastAPI WS 엔드포인트 `/internal/voice/stream`(`api/voice_stream.py`): RealTime이 프록시한 오디오를 받아 부분/최종 자막을 다운 프레임(`transcript.partial`/`transcript.final`)으로 보내고, 발화 종료(`stop` 또는 UtteranceEnd) 시 메트릭 계산 후 `callback.voice` 발행 → 기존 followup 파이프라인 재사용.
196196
- 추상화 계층 두기: `voice/stt/base.py` (interface), `voice/stt/whisper_api.py`, `voice/tts/base.py` + `voice/tts/{provider}.py`

ai/src/ai_server/config/settings.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,10 @@ class Settings(BaseSettings):
6565
deepgram_live_endpointing_ms: int = 800 # 무음 800ms → utterance end
6666
voice_stream_internal_path: str = "/internal/voice/stream"
6767

68-
# TTS (질문 음성화). "auto" 면 gemini > openai 순으로 키 보유 시 선택, 둘 다 없으면 mock.
69-
# Deepgram/OpenAI TTS 는 한국어 미지원이라 기본은 Gemini TTS(GEMINI_API_KEY 재사용).
70-
tts_provider: Literal["auto", "mock", "openai", "gemini"] = "auto"
68+
# TTS (질문 음성화). "auto" 면 gateway(충남대 키) > gemini(직접 키) > openai, 없으면 mock.
69+
# gateway 는 Mindlogic 게이트웨이(/audio/speech)가 Gemini TTS 로 라우팅 — 직접 키 429 부하 분산.
70+
# Deepgram/OpenAI TTS 는 한국어 미지원. gateway/gemini 모두 gemini_tts_model/voice 를 공유한다.
71+
tts_provider: Literal["auto", "mock", "openai", "gemini", "gateway"] = "auto"
7172
openai_tts_model: str = "gpt-4o-mini-tts"
7273
openai_tts_voice: str = "alloy"
7374
openai_tts_timeout_sec: float = 30.0

ai/src/ai_server/voice/tts/factory.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from ai_server.config.settings import Settings
66
from ai_server.voice.tts.base import TtsProvider
7+
from ai_server.voice.tts.gateway import GatewayTtsProvider
78
from ai_server.voice.tts.gemini import GeminiTtsProvider
89
from ai_server.voice.tts.mock import MockTtsProvider
910
from ai_server.voice.tts.openai_tts import OpenAiTtsProvider
@@ -12,17 +13,34 @@
1213

1314

1415
def build_tts_provider(settings: Settings) -> TtsProvider:
15-
"""TTS 공급자 선택. auto → gemini(한국어) > openai 키 보유 순, 둘 다 없으면 mock."""
16+
"""TTS 공급자 선택. auto → gateway(충남대 키) > gemini(직접 키) > openai, 없으면 mock.
17+
18+
gateway 를 우선해 직접 GEMINI_API_KEY 의 429 부하를 던다(게이트웨이가 Gemini TTS 로 라우팅).
19+
"""
1620
provider = (settings.tts_provider or "auto").lower()
1721

1822
if provider == "auto":
19-
if settings.gemini_api_key:
23+
if settings.llm_api_key:
24+
provider = "gateway"
25+
elif settings.gemini_api_key:
2026
provider = "gemini"
2127
elif settings.openai_api_key:
2228
provider = "openai"
2329
else:
2430
provider = "mock"
2531

32+
if provider == "gateway":
33+
if not settings.llm_api_key:
34+
log.warn("tts.fallback_to_mock", reason="LLM_API_KEY 누락")
35+
return MockTtsProvider()
36+
return GatewayTtsProvider(
37+
api_key=settings.llm_api_key,
38+
base_url=settings.llm_base_url,
39+
model=settings.gemini_tts_model,
40+
voice=settings.gemini_tts_voice,
41+
timeout_sec=settings.gemini_tts_timeout_sec,
42+
)
43+
2644
if provider == "gemini":
2745
if not settings.gemini_api_key:
2846
log.warn("tts.fallback_to_mock", reason="GEMINI_API_KEY 누락")
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from __future__ import annotations
2+
3+
import httpx
4+
import structlog
5+
6+
from ai_server.voice.tts.base import TtsError, TtsProvider, TtsResult
7+
from ai_server.voice.tts.gemini import _parse_rate, pcm_to_wav
8+
9+
log = structlog.get_logger(__name__)
10+
11+
12+
class GatewayTtsProvider(TtsProvider):
13+
"""충남대 Mindlogic 게이트웨이 TTS (OpenAI 호환 /audio/speech).
14+
15+
게이트웨이가 Gemini TTS 로 라우팅하며 raw PCM(L16, mono)을 바디로 직접 반환한다
16+
(네이티브 Gemini 의 base64 JSON 과 다름). WAV 로 감싸 audio/wav 로 저장한다.
17+
GEMINI_API_KEY(직접) 대신 LLM_API_KEY(게이트웨이)를 써서 직접 키의 429 부하를 던다.
18+
"""
19+
20+
def __init__(
21+
self,
22+
*,
23+
api_key: str,
24+
base_url: str,
25+
model: str,
26+
voice: str,
27+
timeout_sec: float,
28+
client: httpx.AsyncClient | None = None,
29+
) -> None:
30+
self._api_key = api_key
31+
self._base_url = base_url.rstrip("/")
32+
self.model_name = model
33+
self._voice = voice
34+
self._timeout = timeout_sec
35+
self._client = client
36+
37+
async def synthesize(self, text: str, *, voice: str) -> TtsResult:
38+
# consumer 가 넘기는 voice 는 OpenAI 용("alloy") 이므로 Gemini 전용 voice 를 쓴다.
39+
url = f"{self._base_url}/audio/speech"
40+
body = {"model": self.model_name, "input": text, "voice": self._voice}
41+
headers = {"Authorization": f"Bearer {self._api_key}"}
42+
try:
43+
if self._client is not None:
44+
resp = await self._client.post(url, headers=headers, json=body)
45+
else:
46+
async with httpx.AsyncClient(timeout=self._timeout) as client:
47+
resp = await client.post(url, headers=headers, json=body)
48+
except httpx.HTTPError as exc:
49+
raise TtsError("TTS_HTTP_ERROR", str(exc)) from exc
50+
if resp.status_code != 200:
51+
raise TtsError(
52+
"TTS_API_ERROR",
53+
f"gateway tts status {resp.status_code}: {resp.text[:200]}",
54+
)
55+
audio = resp.content
56+
if not audio:
57+
raise TtsError("TTS_EMPTY_AUDIO", "gateway tts empty audio")
58+
59+
content_type = resp.headers.get("content-type", "").lower()
60+
# 게이트웨이는 raw PCM(L16/24kHz) 을 반환 → WAV 로 감싼다.
61+
# 혹시 이미 컨테이너(wav/mpeg)면 그대로 전달.
62+
if (
63+
"pcm" in content_type
64+
or "l16" in content_type
65+
or "octet-stream" in content_type
66+
):
67+
rate = _parse_rate(content_type)
68+
wav = pcm_to_wav(audio, sample_rate=rate)
69+
duration = round(len(audio) / float(rate * 2), 2) # mono 16-bit
70+
return TtsResult(
71+
audio_bytes=wav, duration_sec=duration, content_type="audio/wav"
72+
)
73+
return TtsResult(
74+
audio_bytes=audio,
75+
duration_sec=None,
76+
content_type=content_type.split(";")[0] or "audio/mpeg",
77+
)

ai/tests/test_gateway_tts.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from __future__ import annotations
2+
3+
import wave
4+
import io
5+
6+
import httpx
7+
import pytest
8+
9+
from ai_server.voice.tts.base import TtsError
10+
from ai_server.voice.tts.gateway import GatewayTtsProvider
11+
12+
13+
def _client(*, status: int, content: bytes, content_type: str) -> httpx.AsyncClient:
14+
def handler(request: httpx.Request) -> httpx.Response:
15+
return httpx.Response(
16+
status, content=content, headers={"content-type": content_type}
17+
)
18+
19+
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
20+
21+
22+
@pytest.mark.asyncio
23+
async def test_gateway_tts_wraps_raw_pcm_into_wav():
24+
pcm = b"\x00\x01" * 24000 # 1초 분량(24kHz mono 16-bit)
25+
async with _client(status=200, content=pcm, content_type="audio/pcm") as client:
26+
provider = GatewayTtsProvider(
27+
api_key="k",
28+
base_url="https://gw/v1/gateway",
29+
model="gemini-2.5-flash-preview-tts",
30+
voice="Kore",
31+
timeout_sec=5.0,
32+
client=client,
33+
)
34+
result = await provider.synthesize("질문입니다", voice="alloy")
35+
36+
assert result.content_type == "audio/wav"
37+
# 유효한 WAV 컨테이너인지 확인
38+
with wave.open(io.BytesIO(result.audio_bytes), "rb") as wav:
39+
assert wav.getframerate() == 24000
40+
assert wav.getnchannels() == 1
41+
assert result.duration_sec == pytest.approx(1.0, abs=0.05)
42+
43+
44+
@pytest.mark.asyncio
45+
async def test_gateway_tts_raises_on_error_status():
46+
async with _client(
47+
status=400, content=b'{"detail":"bad"}', content_type="application/json"
48+
) as client:
49+
provider = GatewayTtsProvider(
50+
api_key="k",
51+
base_url="https://gw/v1/gateway",
52+
model="gemini-2.5-flash-preview-tts",
53+
voice="Kore",
54+
timeout_sec=5.0,
55+
client=client,
56+
)
57+
with pytest.raises(TtsError):
58+
await provider.synthesize("질문", voice="alloy")

ai/tests/test_tts_factory.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from ai_server.config.settings import Settings
22
from ai_server.voice.tts.factory import build_tts_provider
3+
from ai_server.voice.tts.gateway import GatewayTtsProvider
34
from ai_server.voice.tts.gemini import GeminiTtsProvider
45
from ai_server.voice.tts.mock import MockTtsProvider
56
from ai_server.voice.tts.openai_tts import OpenAiTtsProvider
@@ -12,11 +13,19 @@ def _settings(**over):
1213
s3_access_key="x",
1314
s3_secret_key="x",
1415
s3_bucket_name="b",
16+
# 게이트웨이가 auto 최우선이므로, 명시 안 한 테스트는 비활성으로 고정(.env 누수 방지).
17+
llm_api_key="",
1518
)
1619
base.update(over)
1720
return Settings(**base)
1821

1922

23+
def test_auto_prefers_gateway_when_llm_key_present():
24+
# 충남대 게이트웨이 키가 있으면 직접 Gemini 키보다 우선(429 부하 분산).
25+
s = _settings(tts_provider="auto", llm_api_key="gw-key", gemini_api_key="g-test")
26+
assert isinstance(build_tts_provider(s), GatewayTtsProvider)
27+
28+
2029
def test_auto_falls_back_to_mock_without_key():
2130
s = _settings(tts_provider="auto", openai_api_key="", gemini_api_key="")
2231
assert isinstance(build_tts_provider(s), MockTtsProvider)

docs/environment.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ ANALYZED_WEB_RESUME_MD_KEY_TEMPLATE=analyzed/web-resume/{resume_id}/summary.md
148148
# ===== STT/TTS (Phase 2) =====
149149
STT_PROVIDER=whisper-api # whisper-api | whisper-self-hosted
150150
WHISPER_MODEL=whisper-1 # OpenAI Whisper API 모델
151-
TTS_PROVIDER=auto # auto | mock | openai (auto=OPENAI_API_KEY 보유 시 openai)
151+
TTS_PROVIDER=auto # auto | mock | openai | gemini | gateway (auto=LLM_API_KEY(gateway) > GEMINI_API_KEY > OPENAI_API_KEY)
152152
OPENAI_TTS_MODEL=gpt-4o-mini-tts # OpenAI TTS 모델 (질문 음성화)
153153
OPENAI_TTS_VOICE=alloy # TTS 보이스
154154
OPENAI_TTS_TIMEOUT_SEC=30 # TTS HTTP 타임아웃(초)

0 commit comments

Comments
 (0)