55import structlog
66from aio_pika .abc import AbstractIncomingMessage
77
8+ from ai_server .chain .sentence_split import next_sentences
89from ai_server .messaging .idempotency import LruIdempotencyStore
910from ai_server .messaging .publisher import CallbackPublisher
11+ from ai_server .messaging .session_notify import SessionRealtimeNotifier
1012from ai_server .model .envelope import Envelope
1113from ai_server .model .messages .tts import GenerateTtsRequest , TtsCallbackPayload
1214from 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
1518log = 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+
1834class 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" ,
0 commit comments