Skip to content

Commit df3cd17

Browse files
i3monthsclaude
andcommitted
perf(ai): 질문 TTS 세그먼트 look-ahead 선합성으로 문장 간 공백 제거
순차 합성은 게이트웨이 문장당 지연(~4~6s)이 재생 길이와 비슷해 짧은 문장 뒤 무음이 생겼다(런타임 측정: 첫 문장 뒤 ~2.8s 공백). - 모든 문장을 _TTS_LOOKAHEAD(=2) 동시 한도로 미리 합성 시작, 발행은 문장 순서대로 await → 재생 순서=읽기 순서 유지 - seq 연속성·중간 실패 견고성은 그대로(성공분만 0..n 부여) - 꼬리질문의 무제한 병렬보다 보수적(429 방지) 런타임 재측정: 다음 세그먼트가 직전 재생 종료 전 준비되어 공백 0, 첫 음성까지는 ~0.5s 증가(동시 합성 트레이드오프). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent da04789 commit df3cd17

2 files changed

Lines changed: 22 additions & 5 deletions

File tree

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import re
45

56
import structlog
@@ -17,6 +18,11 @@
1718

1819
log = structlog.get_logger(__name__)
1920

21+
# 다음 문장(들)을 미리 합성해 발화 중간 공백을 줄인다. 게이트웨이 문장당 지연이
22+
# 재생 길이와 비슷해 순차 합성 시 짧은 문장 뒤 무음이 생길 수 있어 소폭 선합성한다.
23+
# 과도한 동시 호출(429)을 피해 보수적으로 2로 둔다(꼬리질문의 무제한 병렬보다 안전).
24+
_TTS_LOOKAHEAD = 2
25+
2026

2127
def _split_all_sentences(text: str) -> list[str]:
2228
"""완성된 텍스트를 문장 단위로 모두 쪼갠다(스트리밍과 달리 마지막 문장도 포함)."""
@@ -173,8 +179,9 @@ async def _synthesize_once(
173179
async def _synthesize_segmented(
174180
self, envelope: Envelope[GenerateTtsRequest], req: GenerateTtsRequest
175181
) -> TtsResult | None:
176-
"""문장 단위로 순차 합성 → 세그먼트 PUT + SESSION_MESSAGE_AUDIO 즉시 발행.
182+
"""문장 단위로 합성 → 세그먼트 PUT + SESSION_MESSAGE_AUDIO 즉시 발행.
177183
184+
다음 문장을 미리 합성(_TTS_LOOKAHEAD 만큼 선합성)하되, 발행은 문장 순서대로 한다.
178185
seq 는 '성공한' 세그먼트만 0..n 연속 부여해 프론트 큐가 빈 seq 에서 멈추지 않게 한다.
179186
합성된 세그먼트를 이어붙여 전체 파일(다시 듣기용)을 반환한다. 전부 실패하면 콜백 발행 후 None.
180187
"""
@@ -191,11 +198,21 @@ async def _synthesize_segmented(
191198
)
192199
return None
193200

201+
# 동시 합성을 _TTS_LOOKAHEAD 개로 제한하면서 모든 문장을 미리 시작한다.
202+
# 발행은 문장 순서대로 await 하므로 재생 순서는 읽기 순서와 동일하다.
203+
sem = asyncio.Semaphore(_TTS_LOOKAHEAD)
204+
205+
async def _synth(sentence: str) -> TtsResult:
206+
async with sem:
207+
return await self._tts.synthesize(sentence, voice=self._voice)
208+
209+
synth_tasks = [asyncio.create_task(_synth(s)) for s in sentences]
210+
194211
parts: list[TtsResult] = []
195212
seq = 0
196-
for sentence in sentences:
213+
for task in synth_tasks:
197214
try:
198-
res = await self._tts.synthesize(sentence, voice=self._voice)
215+
res = await task
199216
except Exception as exc: # noqa: BLE001 — 한 문장 실패가 전체를 막지 않음
200217
log.warning(
201218
"tts.segment.failed",

ai/tests/test_tts_consumer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,8 @@ async def test_tts_consumer_streams_segments_then_assembles_full_file():
188188

189189
await consumer.handle(_FakeMsg(_envelope_body("첫 문장입니다. 둘째 문장입니다.")))
190190

191-
# 문장 2개 → 세그먼트 2개 합성
192-
assert tts.calls == ["첫 문장입니다.", "둘째 문장입니다."]
191+
# 문장 2개 → 세그먼트 2개 합성 (선합성으로 호출 순서는 보장 안 함)
192+
assert sorted(tts.calls) == sorted(["첫 문장입니다.", "둘째 문장입니다."])
193193
# seq 0,1 로 라이브 푸시
194194
assert [a["seq"] for a in notifier.audio] == [0, 1]
195195
assert all(a["ext"] == "wav" for a in notifier.audio)

0 commit comments

Comments
 (0)