perf(bridge): stream TTS per sentence to cut voice reply latency

The /converse turn synthesised the entire reply before any audio played, so
time-to-first-audio grew with reply length. Add a streaming /converse_stream
endpoint that emits the transcript/reply first, then one audio clip per
sentence as each finishes synthesising. The Discord voice layer enqueues each
clip on arrival via the existing FIFO playQueue, so the first sentence starts
speaking while the rest are still being synthesised.

STT and the reply engine still run to completion before the first clip; only
TTS is pipelined. The non-streaming /converse and /text endpoints are
unchanged.

- bridge: language-agnostic sentence splitter (bridge/text_utils.py) + NDJSON
  streaming route
- bot: ndjson() reader + converseStream() client; voice.ts plays clips
  progressively
- tests: splitter unit tests + bot ndjson/converseStream tests

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
javis-bot
2026-06-13 01:09:39 +09:00
parent 989a4f3e98
commit 5c295420ea
6 changed files with 363 additions and 18 deletions

50
bridge/text_utils.py Normal file
View File

@@ -0,0 +1,50 @@
"""Small, dependency-free text helpers for the brain bridge.
Kept separate from ``bridge.server`` (which imports Flask and the heavy brain)
so the pure logic here can be unit-tested in isolation.
"""
from __future__ import annotations
import re
from typing import List, Optional
# Sentence terminators across scripts: ASCII . ! ? plus the CJK fullwidth forms
# and the ellipsis. Runs of terminators ("?!", "...") collapse into one boundary.
# A run of newlines is also a boundary. This is punctuation-only and therefore
# language-agnostic (no hardcoded words), per the project's multilingual rule.
_BOUNDARY = re.compile(r"([.!?。!?…]+|\n+)")
def split_sentences(text: Optional[str], min_len: int = 5) -> List[str]:
"""Split ``text`` into sentence-sized chunks for streaming TTS.
Each chunk ends at a sentence boundary so it can be synthesised and played
while later chunks are still being spoken. Fragments shorter than
``min_len`` characters (interjections like "네.", "") are merged into an
adjacent chunk so we don't emit choppy micro-clips. Returns an empty list
for blank input and never loses visible content.
"""
text = (text or "").strip()
if not text:
return []
parts = _BOUNDARY.split(text)
chunks: List[str] = []
buf = ""
for i in range(0, len(parts), 2):
seg = parts[i]
delim = parts[i + 1] if i + 1 < len(parts) else ""
buf += seg + delim
# Flush at a real boundary once the buffer is a worthwhile clip.
if delim and len(buf.strip()) >= min_len:
chunks.append(buf.strip())
buf = ""
tail = buf.strip()
if tail:
if chunks and len(tail) < min_len:
chunks[-1] = chunks[-1] + " " + tail
else:
chunks.append(tail)
return chunks