Skip to content

Commit def5fee

Browse files
authored
Merge pull request #74 from Team-StackUp/feature/gemini-tts
feat(ai): 질문 TTS를 Gemini TTS로 — 한국어 음성 합성 (기존 GEMINI_API_KEY 재사용)
2 parents 46dd930 + a8d793f commit def5fee

8 files changed

Lines changed: 298 additions & 49 deletions

File tree

ai/.env.example

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,13 @@ WHISPER_MODEL=whisper-1
6363
WHISPER_LANGUAGE=ko
6464
WHISPER_TIMEOUT_SEC=60
6565

66-
# TTS (질문 음성화). auto=OPENAI_API_KEY 보유 시 openai, 없으면 mock. (OPENAI_API_KEY 재사용)
66+
# TTS (질문 음성화). auto=GEMINI_API_KEY > OPENAI_API_KEY 순으로 선택, 둘 다 없으면 mock.
67+
# Deepgram/OpenAI TTS 는 한국어 미지원 → 기본은 Gemini TTS(GEMINI_API_KEY 재사용, raw PCM→WAV).
6768
TTS_PROVIDER=auto
6869
OPENAI_TTS_MODEL=gpt-4o-mini-tts
6970
OPENAI_TTS_VOICE=alloy
7071
OPENAI_TTS_TIMEOUT_SEC=30
72+
GEMINI_TTS_MODEL=gemini-2.5-flash-preview-tts
73+
GEMINI_TTS_VOICE=Kore
74+
GEMINI_TTS_BASE_URL=https://generativelanguage.googleapis.com/v1beta
75+
GEMINI_TTS_TIMEOUT_SEC=30

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: OpenAI TTS 채택** (`voice/tts/`) — 질문(INTERVIEWER) 메시지 음성화. `TtsProvider` 추상화 + `OpenAiTtsProvider`(`gpt-4o-mini-tts`, mp3)/`MockTtsProvider`, `build_tts_provider` factory(`TTS_PROVIDER=auto`OPENAI_API_KEY 보유 시 openai). `generate.tts` consumer 가 합성 → S3 PUT `callback.tts` 발행.
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 이 내부 호스트라 브라우저 직접 접근 불가).
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: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,17 @@ class Settings(BaseSettings):
6666
deepgram_live_endpointing_ms: int = 800 # 무음 800ms → utterance end
6767
voice_stream_internal_path: str = "/internal/voice/stream"
6868

69-
# TTS (질문 음성화). "auto" 면 openai 키 보유 시 openai, 없으면 mock.
70-
tts_provider: Literal["auto", "mock", "openai"] = "auto"
69+
# TTS (질문 음성화). "auto" 면 gemini > openai 순으로 키 보유 시 선택, 둘 다 없으면 mock.
70+
# Deepgram/OpenAI TTS 는 한국어 미지원이라 기본은 Gemini TTS(GEMINI_API_KEY 재사용).
71+
tts_provider: Literal["auto", "mock", "openai", "gemini"] = "auto"
7172
openai_tts_model: str = "gpt-4o-mini-tts"
7273
openai_tts_voice: str = "alloy"
7374
openai_tts_timeout_sec: float = 30.0
75+
# Gemini TTS — 한국어 지원. raw PCM(L16) 반환 → 저장 시 WAV 로 감싼다.
76+
gemini_tts_model: str = "gemini-2.5-flash-preview-tts"
77+
gemini_tts_voice: str = "Kore"
78+
gemini_tts_base_url: str = "https://generativelanguage.googleapis.com/v1beta"
79+
gemini_tts_timeout_sec: float = 30.0
7480
tts_audio_key_template: str = "interview/tts/{session_id}/{message_id}.mp3"
7581
# 음성 분석
7682
voice_filler_pattern: str = r"(?:음+|어+|그+|아+)"

ai/src/ai_server/messaging/consumers/tts_consumer.py

Lines changed: 82 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import re
4+
35
import structlog
46
from aio_pika.abc import AbstractIncomingMessage
57

@@ -38,9 +40,15 @@ def __init__(
3840
async def handle(self, message: AbstractIncomingMessage) -> None:
3941
async with message.process(requeue=False):
4042
try:
41-
envelope = Envelope[GenerateTtsRequest].model_validate_json(message.body)
43+
envelope = Envelope[GenerateTtsRequest].model_validate_json(
44+
message.body
45+
)
4246
except Exception as exc:
43-
log.error("tts.parse.failed", error=str(exc), delivery_tag=message.delivery_tag)
47+
log.error(
48+
"tts.parse.failed",
49+
error=str(exc),
50+
delivery_tag=message.delivery_tag,
51+
)
4452
raise
4553

4654
if self._idempotency.is_seen_then_mark(envelope.message_id):
@@ -59,38 +67,87 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
5967
try:
6068
result = await self._tts.synthesize(req.text, voice=self._voice)
6169
except TtsError as exc:
62-
log.error("tts.synthesize.failed", code=exc.code, error=exc.message,
63-
session_id=req.session_id)
64-
await self._publish(envelope, TtsCallbackPayload(
65-
session_id=req.session_id, message_id=req.message_id,
66-
status="FAILED", error_code=exc.code,
67-
))
70+
log.error(
71+
"tts.synthesize.failed",
72+
code=exc.code,
73+
error=exc.message,
74+
session_id=req.session_id,
75+
)
76+
await self._publish(
77+
envelope,
78+
TtsCallbackPayload(
79+
session_id=req.session_id,
80+
message_id=req.message_id,
81+
status="FAILED",
82+
error_code=exc.code,
83+
),
84+
)
6885
return
6986
except Exception as exc:
70-
log.error("tts.synthesize.unexpected", error=str(exc), session_id=req.session_id)
71-
await self._publish(envelope, TtsCallbackPayload(
72-
session_id=req.session_id, message_id=req.message_id,
73-
status="FAILED", error_code="TTS_FAILED",
74-
))
87+
log.error(
88+
"tts.synthesize.unexpected",
89+
error=str(exc),
90+
session_id=req.session_id,
91+
)
92+
await self._publish(
93+
envelope,
94+
TtsCallbackPayload(
95+
session_id=req.session_id,
96+
message_id=req.message_id,
97+
status="FAILED",
98+
error_code="TTS_FAILED",
99+
),
100+
)
75101
return
76102

77-
key = self._key_template.format(session_id=req.session_id, message_id=req.message_id)
103+
key = self._build_key(req.session_id, req.message_id, result.content_type)
78104
try:
79-
await self._storage.put_bytes(key, result.audio_bytes, content_type=result.content_type)
105+
await self._storage.put_bytes(
106+
key, result.audio_bytes, content_type=result.content_type
107+
)
80108
except Exception as exc:
81109
log.error("tts.storage.failed", error=str(exc), key=key)
82-
await self._publish(envelope, TtsCallbackPayload(
83-
session_id=req.session_id, message_id=req.message_id,
84-
status="FAILED", error_code="TTS_STORAGE_FAILED",
85-
))
110+
await self._publish(
111+
envelope,
112+
TtsCallbackPayload(
113+
session_id=req.session_id,
114+
message_id=req.message_id,
115+
status="FAILED",
116+
error_code="TTS_STORAGE_FAILED",
117+
),
118+
)
86119
return
87120

88-
await self._publish(envelope, TtsCallbackPayload(
89-
session_id=req.session_id, message_id=req.message_id,
90-
status="SUCCEEDED", audio_key=key, duration_sec=result.duration_sec,
91-
))
92-
log.info("tts.synthesize.done", session_id=req.session_id,
93-
interview_message_id=req.message_id, key=key)
121+
await self._publish(
122+
envelope,
123+
TtsCallbackPayload(
124+
session_id=req.session_id,
125+
message_id=req.message_id,
126+
status="SUCCEEDED",
127+
audio_key=key,
128+
duration_sec=result.duration_sec,
129+
),
130+
)
131+
log.info(
132+
"tts.synthesize.done",
133+
session_id=req.session_id,
134+
interview_message_id=req.message_id,
135+
key=key,
136+
)
137+
138+
_EXT_BY_CONTENT_TYPE = {
139+
"audio/wav": "wav",
140+
"audio/mpeg": "mp3",
141+
"audio/ogg": "ogg",
142+
"audio/mp4": "m4a",
143+
}
144+
145+
def _build_key(self, session_id: int, message_id: int, content_type: str) -> str:
146+
# 템플릿의 확장자를 실제 오디오 포맷(content_type)에 맞춰 교체한다.
147+
# (Gemini=WAV, OpenAI=mp3 등 공급자마다 다름 → Core 프록시가 확장자로 content-type 판단)
148+
base = self._key_template.format(session_id=session_id, message_id=message_id)
149+
ext = self._EXT_BY_CONTENT_TYPE.get(content_type.split(";")[0].strip(), "mp3")
150+
return re.sub(r"\.[^./]+$", "", base) + "." + ext
94151

95152
async def _publish(self, envelope, payload: TtsCallbackPayload) -> None:
96153
await self._publisher.publish(

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

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

55
from ai_server.config.settings import Settings
66
from ai_server.voice.tts.base import TtsProvider
7+
from ai_server.voice.tts.gemini import GeminiTtsProvider
78
from ai_server.voice.tts.mock import MockTtsProvider
89
from ai_server.voice.tts.openai_tts import OpenAiTtsProvider
910

1011
log = structlog.get_logger(__name__)
1112

1213

1314
def build_tts_provider(settings: Settings) -> TtsProvider:
14-
"""TTS 공급자 선택. auto → openai 키 보유 시 openai, 없으면 mock."""
15+
"""TTS 공급자 선택. auto → gemini(한국어) > openai 키 보유 순, 둘 다 없으면 mock."""
1516
provider = (settings.tts_provider or "auto").lower()
1617

1718
if provider == "auto":
18-
provider = "openai" if settings.openai_api_key else "mock"
19+
if settings.gemini_api_key:
20+
provider = "gemini"
21+
elif settings.openai_api_key:
22+
provider = "openai"
23+
else:
24+
provider = "mock"
25+
26+
if provider == "gemini":
27+
if not settings.gemini_api_key:
28+
log.warn("tts.fallback_to_mock", reason="GEMINI_API_KEY 누락")
29+
return MockTtsProvider()
30+
return GeminiTtsProvider(
31+
api_key=settings.gemini_api_key,
32+
base_url=settings.gemini_tts_base_url,
33+
model=settings.gemini_tts_model,
34+
voice=settings.gemini_tts_voice,
35+
timeout_sec=settings.gemini_tts_timeout_sec,
36+
)
1937

2038
if provider == "openai":
2139
if not settings.openai_api_key:
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from __future__ import annotations
2+
3+
import base64
4+
import io
5+
import wave
6+
7+
import httpx
8+
import structlog
9+
10+
from ai_server.voice.tts.base import TtsError, TtsProvider, TtsResult
11+
12+
log = structlog.get_logger(__name__)
13+
14+
15+
def _parse_rate(mime: str, default: int = 24000) -> int:
16+
# 예: "audio/L16;codec=pcm;rate=24000"
17+
for part in mime.split(";"):
18+
part = part.strip()
19+
if part.startswith("rate="):
20+
try:
21+
return int(part[len("rate=") :])
22+
except ValueError:
23+
return default
24+
return default
25+
26+
27+
def pcm_to_wav(
28+
pcm: bytes, *, sample_rate: int, channels: int = 1, sample_width: int = 2
29+
) -> bytes:
30+
"""raw PCM(L16) 을 WAV 컨테이너로 감싼다(브라우저 재생 가능)."""
31+
buf = io.BytesIO()
32+
with wave.open(buf, "wb") as wav:
33+
wav.setnchannels(channels)
34+
wav.setsampwidth(sample_width)
35+
wav.setframerate(sample_rate)
36+
wav.writeframes(pcm)
37+
return buf.getvalue()
38+
39+
40+
class GeminiTtsProvider(TtsProvider):
41+
"""Gemini TTS (generateContent, responseModalities=AUDIO). 한국어 지원.
42+
43+
Gemini 는 raw PCM(L16, mono)을 base64 로 반환하므로 WAV 로 감싸 audio/wav 로 저장한다.
44+
Deepgram/OpenAI TTS 가 한국어 미지원이라 기존 GEMINI_API_KEY 를 재사용한다.
45+
"""
46+
47+
def __init__(
48+
self,
49+
*,
50+
api_key: str,
51+
base_url: str,
52+
model: str,
53+
voice: str,
54+
timeout_sec: float,
55+
) -> None:
56+
self._api_key = api_key
57+
self._base_url = base_url.rstrip("/")
58+
self.model_name = model
59+
self._voice = voice
60+
self._timeout = timeout_sec
61+
62+
async def synthesize(self, text: str, *, voice: str) -> TtsResult:
63+
# consumer 가 넘기는 voice 는 OpenAI 용("alloy") 이므로 Gemini 전용 voice 를 쓴다.
64+
voice_name = self._voice
65+
url = f"{self._base_url}/models/{self.model_name}:generateContent"
66+
body = {
67+
"contents": [{"parts": [{"text": text}]}],
68+
"generationConfig": {
69+
"responseModalities": ["AUDIO"],
70+
"speechConfig": {
71+
"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": voice_name}}
72+
},
73+
},
74+
}
75+
try:
76+
async with httpx.AsyncClient(timeout=self._timeout) as client:
77+
resp = await client.post(url, params={"key": self._api_key}, json=body)
78+
except httpx.HTTPError as exc:
79+
raise TtsError("TTS_HTTP_ERROR", str(exc)) from exc
80+
if resp.status_code != 200:
81+
raise TtsError(
82+
"TTS_API_ERROR",
83+
f"gemini tts status {resp.status_code}: {resp.text[:200]}",
84+
)
85+
try:
86+
data = resp.json()
87+
part = data["candidates"][0]["content"]["parts"][0]
88+
inline = part.get("inlineData") or part.get("inline_data")
89+
pcm = base64.b64decode(inline["data"])
90+
mime = inline.get("mimeType") or inline.get("mime_type") or ""
91+
except (KeyError, IndexError, TypeError, ValueError) as exc:
92+
raise TtsError("TTS_EMPTY_AUDIO", f"gemini tts no audio: {exc}") from exc
93+
if not pcm:
94+
raise TtsError("TTS_EMPTY_AUDIO", "empty pcm")
95+
rate = _parse_rate(mime)
96+
wav = pcm_to_wav(pcm, sample_rate=rate)
97+
duration = round(len(pcm) / float(rate * 2), 2) # mono 16-bit
98+
return TtsResult(
99+
audio_bytes=wav, duration_sec=duration, content_type="audio/wav"
100+
)

0 commit comments

Comments
 (0)