Files
javis_bot/tests/test_bridge_sentence_split.py
javis-bot 5c295420ea 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>
2026-06-13 01:09:39 +09:00

78 lines
2.7 KiB
Python

"""Unit tests for the bridge sentence splitter that drives streaming TTS.
The splitter is the only new logic on the bridge's streaming path: it chops a
reply into sentence-sized chunks so the first sentence can be synthesised and
played while the rest are still being spoken. It must be language-agnostic
(punctuation only, no hardcoded words) per the project rule.
"""
import pytest
from bridge.text_utils import split_sentences
@pytest.mark.unit
def test_empty_text_yields_no_chunks():
assert split_sentences("") == []
assert split_sentences(" ") == []
assert split_sentences(None) == [] # type: ignore[arg-type]
@pytest.mark.unit
def test_text_without_terminal_punctuation_is_one_chunk():
assert split_sentences("오늘 날씨 맑음") == ["오늘 날씨 맑음"]
@pytest.mark.unit
def test_splits_on_sentence_ending_punctuation():
chunks = split_sentences("안녕하세요. 반갑습니다!")
assert chunks == ["안녕하세요.", "반갑습니다!"]
@pytest.mark.unit
def test_splits_on_fullwidth_cjk_punctuation():
chunks = split_sentences("これはペンです。あれは何ですか?")
assert chunks == ["これはペンです。", "あれは何ですか?"]
@pytest.mark.unit
def test_splits_english_sentences():
chunks = split_sentences("Hello there. How are you? I am fine!")
assert chunks == ["Hello there.", "How are you?", "I am fine!"]
@pytest.mark.unit
def test_short_leading_fragment_merges_forward():
# "네." is below the min length, so it should ride along with the next
# sentence rather than become its own micro-clip.
chunks = split_sentences("네. 지금 바로 처리하겠습니다.")
assert chunks == ["네. 지금 바로 처리하겠습니다."]
@pytest.mark.unit
def test_short_trailing_fragment_merges_backward():
chunks = split_sentences("지금 바로 처리하겠습니다. 응")
assert chunks == ["지금 바로 처리하겠습니다. 응"]
@pytest.mark.unit
def test_newline_is_a_boundary():
chunks = split_sentences("첫 번째 줄입니다\n두 번째 줄입니다")
assert chunks == ["첫 번째 줄입니다", "두 번째 줄입니다"]
@pytest.mark.unit
def test_chunks_preserve_all_visible_content_in_order():
text = "안녕하세요. 오늘 일정 알려드릴게요. 회의가 세 개 있습니다!"
chunks = split_sentences(text)
assert len(chunks) >= 2
# No content lost: stripping spaces, the concatenation matches the source.
joined = "".join(chunks)
assert joined.replace(" ", "") == text.replace(" ", "")
@pytest.mark.unit
def test_collapses_repeated_terminators_into_one_chunk():
chunks = split_sentences("정말요?! 네 맞습니다.")
assert chunks == ["정말요?!", "네 맞습니다."]