Skip to content

Commit da04789

Browse files
authored
Merge pull request #100 from Team-StackUp/feature/interview-voice-streaming
Feature/interview voice streaming
2 parents 7205e5e + 0f66c35 commit da04789

9 files changed

Lines changed: 426 additions & 53 deletions

File tree

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

Lines changed: 148 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,39 @@
55
import structlog
66
from aio_pika.abc import AbstractIncomingMessage
77

8+
from ai_server.chain.sentence_split import next_sentences
89
from ai_server.messaging.idempotency import LruIdempotencyStore
910
from ai_server.messaging.publisher import CallbackPublisher
11+
from ai_server.messaging.session_notify import SessionRealtimeNotifier
1012
from ai_server.model.envelope import Envelope
1113
from ai_server.model.messages.tts import GenerateTtsRequest, TtsCallbackPayload
1214
from ai_server.storage.base import ObjectStorage
13-
from ai_server.voice.tts.base import TtsError, TtsProvider
15+
from ai_server.voice.tts.audio_join import join_audio
16+
from ai_server.voice.tts.base import TtsError, TtsProvider, TtsResult
1417

1518
log = structlog.get_logger(__name__)
1619

1720

21+
def _split_all_sentences(text: str) -> list[str]:
22+
"""완성된 텍스트를 문장 단위로 모두 쪼갠다(스트리밍과 달리 마지막 문장도 포함)."""
23+
sents, consumed = next_sentences(text, 0)
24+
tail = text[consumed:].strip()
25+
if tail:
26+
sents.append(tail)
27+
cleaned = [s for s in sents if s.strip()]
28+
if cleaned:
29+
return cleaned
30+
stripped = text.strip()
31+
return [stripped] if stripped else []
32+
33+
1834
class TtsConsumer:
19-
"""generate.tts consumer — 질문 텍스트를 TTS 합성 → S3 PUT → callback.tts 발행."""
35+
"""generate.tts consumer — 질문 텍스트를 TTS 합성 → S3 PUT → callback.tts 발행.
36+
37+
session_notifier 가 주입되면 문장 단위 세그먼트로 합성·발행해(SESSION_MESSAGE_AUDIO)
38+
첫 문장부터 즉시 재생되게 하고, 합성된 세그먼트를 이어붙여 전체 파일도 저장한다(다시 듣기용).
39+
notifier 가 없으면 기존처럼 전체 텍스트를 한 번에 합성한다.
40+
"""
2041

2142
def __init__(
2243
self,
@@ -28,6 +49,7 @@ def __init__(
2849
callback_routing_key: str,
2950
voice: str,
3051
key_template: str,
52+
session_notifier: SessionRealtimeNotifier | None = None,
3153
) -> None:
3254
self._tts = tts
3355
self._storage = storage
@@ -36,6 +58,7 @@ def __init__(
3658
self._callback_routing_key = callback_routing_key
3759
self._voice = voice
3860
self._key_template = key_template
61+
self._notifier = session_notifier
3962

4063
async def handle(self, message: AbstractIncomingMessage) -> None:
4164
async with message.process(requeue=False):
@@ -61,44 +84,16 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
6184
message_id=envelope.message_id,
6285
session_id=req.session_id,
6386
interview_message_id=req.message_id,
87+
streaming=self._notifier is not None,
6488
trace_id=envelope.trace_id,
6589
)
6690

67-
try:
68-
result = await self._tts.synthesize(req.text, voice=self._voice)
69-
except TtsError as exc:
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-
)
85-
return
86-
except Exception as exc:
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-
)
101-
return
91+
if self._notifier is not None:
92+
result = await self._synthesize_segmented(envelope, req)
93+
else:
94+
result = await self._synthesize_once(envelope, req)
95+
if result is None:
96+
return # 실패 — 콜백은 내부에서 이미 발행
10297

10398
key = self._build_key(req.session_id, req.message_id, result.content_type)
10499
try:
@@ -135,6 +130,122 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
135130
key=key,
136131
)
137132

133+
async def _synthesize_once(
134+
self, envelope: Envelope[GenerateTtsRequest], req: GenerateTtsRequest
135+
) -> TtsResult | None:
136+
"""전체 텍스트를 한 번에 합성(레거시/notifier 미주입 경로). 실패 시 콜백 발행 후 None."""
137+
try:
138+
return await self._tts.synthesize(req.text, voice=self._voice)
139+
except TtsError as exc:
140+
log.error(
141+
"tts.synthesize.failed",
142+
code=exc.code,
143+
error=exc.message,
144+
session_id=req.session_id,
145+
)
146+
await self._publish(
147+
envelope,
148+
TtsCallbackPayload(
149+
session_id=req.session_id,
150+
message_id=req.message_id,
151+
status="FAILED",
152+
error_code=exc.code,
153+
),
154+
)
155+
return None
156+
except Exception as exc:
157+
log.error(
158+
"tts.synthesize.unexpected",
159+
error=str(exc),
160+
session_id=req.session_id,
161+
)
162+
await self._publish(
163+
envelope,
164+
TtsCallbackPayload(
165+
session_id=req.session_id,
166+
message_id=req.message_id,
167+
status="FAILED",
168+
error_code="TTS_FAILED",
169+
),
170+
)
171+
return None
172+
173+
async def _synthesize_segmented(
174+
self, envelope: Envelope[GenerateTtsRequest], req: GenerateTtsRequest
175+
) -> TtsResult | None:
176+
"""문장 단위로 순차 합성 → 세그먼트 PUT + SESSION_MESSAGE_AUDIO 즉시 발행.
177+
178+
seq 는 '성공한' 세그먼트만 0..n 연속 부여해 프론트 큐가 빈 seq 에서 멈추지 않게 한다.
179+
합성된 세그먼트를 이어붙여 전체 파일(다시 듣기용)을 반환한다. 전부 실패하면 콜백 발행 후 None.
180+
"""
181+
sentences = _split_all_sentences(req.text)
182+
if not sentences:
183+
await self._publish(
184+
envelope,
185+
TtsCallbackPayload(
186+
session_id=req.session_id,
187+
message_id=req.message_id,
188+
status="FAILED",
189+
error_code="TTS_EMPTY_TEXT",
190+
),
191+
)
192+
return None
193+
194+
parts: list[TtsResult] = []
195+
seq = 0
196+
for sentence in sentences:
197+
try:
198+
res = await self._tts.synthesize(sentence, voice=self._voice)
199+
except Exception as exc: # noqa: BLE001 — 한 문장 실패가 전체를 막지 않음
200+
log.warning(
201+
"tts.segment.failed",
202+
session_id=req.session_id,
203+
interview_message_id=req.message_id,
204+
seq=seq,
205+
error=str(exc),
206+
)
207+
continue
208+
ext = self._EXT_BY_CONTENT_TYPE.get(
209+
res.content_type.split(";")[0].strip(), "mp3"
210+
)
211+
seg_key = (
212+
f"interview/tts/{req.session_id}/{req.message_id}/seg-{seq}.{ext}"
213+
)
214+
try:
215+
await self._storage.put_bytes(
216+
seg_key, res.audio_bytes, content_type=res.content_type
217+
)
218+
except Exception as exc: # noqa: BLE001
219+
log.warning(
220+
"tts.segment.storage_failed",
221+
key=seg_key,
222+
error=str(exc),
223+
)
224+
continue
225+
await self._notifier.emit_audio(
226+
session_id=req.session_id,
227+
message_id=req.message_id,
228+
seq=seq,
229+
ext=ext,
230+
duration_sec=res.duration_sec,
231+
trace_id=envelope.trace_id,
232+
)
233+
parts.append(res)
234+
seq += 1
235+
236+
if not parts:
237+
await self._publish(
238+
envelope,
239+
TtsCallbackPayload(
240+
session_id=req.session_id,
241+
message_id=req.message_id,
242+
status="FAILED",
243+
error_code="TTS_FAILED",
244+
),
245+
)
246+
return None
247+
return join_audio(parts)
248+
138249
_EXT_BY_CONTENT_TYPE = {
139250
"audio/wav": "wav",
140251
"audio/mpeg": "mp3",

ai/src/ai_server/messaging/runner.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,8 @@ def __init__(self, settings: Settings) -> None:
236236
core_client=core_client,
237237
)
238238

239-
# 질문 TTS (Part A) — tts 인스턴스는 위에서 이미 생성됨
239+
# 질문 TTS (Part A) — tts 인스턴스는 위에서 이미 생성됨.
240+
# notifier 주입 시 문장 단위 세그먼트로 발행해 첫 문장부터 즉시 재생되게 한다.
240241
self._tts_consumer = TtsConsumer(
241242
tts=tts,
242243
storage=storage,
@@ -245,6 +246,7 @@ def __init__(self, settings: Settings) -> None:
245246
callback_routing_key=settings.ai_callback_routing_tts,
246247
voice=settings.openai_tts_voice,
247248
key_template=settings.tts_audio_key_template,
249+
session_notifier=session_notifier,
248250
)
249251

250252
self._consumers: list[tuple[AbstractRobustQueue, str]] = []
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from __future__ import annotations
2+
3+
import io
4+
import wave
5+
6+
from ai_server.voice.tts.base import TtsResult
7+
8+
9+
def join_audio(parts: list[TtsResult]) -> TtsResult:
10+
"""여러 세그먼트 오디오를 하나로 합친다(재합성 없음).
11+
12+
- WAV(Gemini/게이트웨이): PCM 프레임을 이어붙여 단일 WAV 로 — 헤더 중복 없는 정상 파일.
13+
- 그 외(mp3 등): 컨테이너 바이트 단순 연결(best-effort, 대부분 플레이어가 재생).
14+
"""
15+
if not parts:
16+
raise ValueError("join_audio: empty parts")
17+
if len(parts) == 1:
18+
return parts[0]
19+
20+
total_dur = _sum_dur(parts)
21+
if all(_ctype(p) == "audio/wav" for p in parts):
22+
return TtsResult(
23+
audio_bytes=_concat_wav([p.audio_bytes for p in parts]),
24+
duration_sec=total_dur,
25+
content_type="audio/wav",
26+
)
27+
return TtsResult(
28+
audio_bytes=b"".join(p.audio_bytes for p in parts),
29+
duration_sec=total_dur,
30+
content_type=parts[0].content_type,
31+
)
32+
33+
34+
def _ctype(part: TtsResult) -> str:
35+
return part.content_type.split(";")[0].strip()
36+
37+
38+
def _sum_dur(parts: list[TtsResult]) -> float | None:
39+
if any(p.duration_sec is None for p in parts):
40+
return None
41+
return round(sum(p.duration_sec or 0.0 for p in parts), 2)
42+
43+
44+
def _concat_wav(wavs: list[bytes]) -> bytes:
45+
frames: list[bytes] = []
46+
params = None
47+
for w in wavs:
48+
with wave.open(io.BytesIO(w), "rb") as reader:
49+
if params is None:
50+
params = reader.getparams()
51+
frames.append(reader.readframes(reader.getnframes()))
52+
assert params is not None
53+
buf = io.BytesIO()
54+
with wave.open(buf, "wb") as out:
55+
out.setnchannels(params.nchannels)
56+
out.setsampwidth(params.sampwidth)
57+
out.setframerate(params.framerate)
58+
out.writeframes(b"".join(frames))
59+
return buf.getvalue()

0 commit comments

Comments
 (0)