Files
wildminder-ComfyUI-Chatterbox/tests/test_chunking.py
T
WildAi ceea0c71cf feat(tts): emotion tags reach the model, long scripts are chunked
The upstream Chatterbox README documents paralinguistic tags ([laugh],
[chuckle], [sigh], ...) as native to the Turbo family, and the vendored
Turbo pack passes them straight to the tokenizer -- it never parses them.
Our engine deleted them before the model saw them, so the feature looked
implemented and was inaudible.

* the text engine keeps a Turbo tag verbatim and records its name; on any
  other pack the nine Turbo names stay literal text instead of being
  swallowed as a voice name, and the run says so
* the v2 angle tokens are re-spelled for Turbo (<giggle> -> [giggle]) and
  left untouched everywhere else
* generate_audio now builds one plan for every family, synthesises one
  chunk per model call and joins the chunks; the classic path goes through
  the engine too, which is what makes "[Alice]" open a character segment
* per-chunk seeds (seed + index) keep a chunked run reproducible without
  giving every chunk the same prosody
* four appended widgets on ChatterboxTTS: enable_chunking,
  max_chars_per_chunk, chunk_combination_method, silence_between_chunks_ms
* chunk boundaries prefer a sentence end and never land inside a tag
* Turbo's exaggeration is passed as 0.0: the family ignores it for
  sampling and upstream warns about every non-zero value, once per chunk
2026-10-22 15:01:53 +03:00

182 lines
7.4 KiB
Python

"""Chunk boundaries -- sentence first, and never inside a tag.
A chunk boundary is a place where the speech stops and starts again, so it has
to be somewhere a speaker would also stop: after a sentence, after a line, at
a space. And it has to be somewhere the *tags* are not, because half a
``[chuckle]`` is not a tag at all -- it is punctuation the model reads out
loud, in two pieces, neither of which means anything.
The rule under test is therefore twofold: prefer the best boundary available,
and move a boundary that landed inside a tag out of it. The one documented
exception is a tag run that is longer than the chunk limit by itself: it stays
whole and the chunk grows past the ceiling, because splitting it would be
worse than exceeding it.
"""
from __future__ import annotations
import pytest
from ComfyUI_Chatterbox.modules.text_processing import chunk_text
def _assert_no_split_tag(chunks: list[str]) -> None:
"""No chunk may contain an opening bracket without its closing one.
That is the whole property: a tag that is split across two chunks shows up
here as a chunk that opens a bracket and never closes it.
"""
for chunk in chunks:
opened = chunk.count("[") - chunk.count("]")
assert opened <= 0, f"a square tag was split: {chunk!r}"
opened = chunk.count("<") - chunk.count(">")
assert opened <= 0, f"an angle tag was split: {chunk!r}"
class TestBoundaries:
def test_a_sentence_end_is_preferred_over_a_space(self) -> None:
"""The cut lands after the sentence, not at the last word before it."""
text = "Alpha beta gamma delta. Epsilon zeta eta theta. Iota kappa."
chunks = chunk_text(text, 45)
assert chunks[0] == "Alpha beta gamma delta.", (
"the cut took the last space instead of the sentence end"
)
def test_an_exclamation_mark_ends_a_sentence_too(self) -> None:
chunks = chunk_text("Alpha beta gamma delta! Epsilon zeta eta theta!", 45)
assert chunks[0] == "Alpha beta gamma delta!"
def test_a_question_mark_ends_a_sentence_too(self) -> None:
chunks = chunk_text("Alpha beta gamma delta? Epsilon zeta eta theta?", 45)
assert chunks[0] == "Alpha beta gamma delta?"
def test_a_space_is_used_when_no_sentence_end_exists(self) -> None:
chunks = chunk_text("alpha beta gamma delta epsilon zeta", 20)
assert chunks[0] == "alpha beta gamma"
def test_a_line_break_is_used_when_no_sentence_end_exists(self) -> None:
chunks = chunk_text("alpha beta gamma\ndelta epsilon zeta", 20)
assert chunks[0] == "alpha beta gamma"
def test_a_hard_cut_only_happens_for_an_unbreakable_run(self) -> None:
"""A URL has no spaces to cut at, so the limit is the only boundary."""
url = "https://example.com/" + "a" * 480
chunks = chunk_text(url, 100)
assert len(chunks) == 5
assert all(len(chunk) == 100 for chunk in chunks)
def test_every_chunk_is_at_most_the_limit(self) -> None:
text = "A sentence here. " * 100
chunks = chunk_text(text, 200)
assert all(len(chunk) <= 200 for chunk in chunks)
class TestTagSafeBoundaries:
def test_a_chunk_boundary_never_splits_a_square_tag(self) -> None:
"""The tag sits where a naive cut would land inside it."""
text = "x" * 398 + "[chuckle]" + "y" * 500
chunks = chunk_text(text, 400)
_assert_no_split_tag(chunks)
assert "[chuckle]" in "".join(chunks)
def test_a_chunk_boundary_never_splits_an_angle_tag(self) -> None:
text = "x" * 398 + "<giggle>" + "y" * 500
chunks = chunk_text(text, 400)
_assert_no_split_tag(chunks)
assert "<giggle>" in "".join(chunks)
def test_a_tag_with_a_space_in_it_is_never_split(self) -> None:
"""``[clear throat]`` is the one Turbo tag with an internal space,
which is exactly where a space-based cut would land."""
text = "[clear throat] and the rest of the script goes on here"
chunks = chunk_text(text, 12)
_assert_no_split_tag(chunks)
assert "[clear throat]" in chunks[0]
def test_a_tag_longer_than_the_limit_is_not_split(self) -> None:
"""The one documented exception: the chunk grows past the ceiling."""
tag = "[" + "a" * 498 + "]"
chunks = chunk_text(tag, 100)
assert chunks == [tag]
assert len(chunks[0]) > 100, "the exception is supposed to exceed the limit"
def test_a_tag_at_the_very_start_of_a_script_is_not_split(self) -> None:
text = "[chuckle]" + "y" * 500
chunks = chunk_text(text, 400)
_assert_no_split_tag(chunks)
assert chunks[0].startswith("[chuckle]")
def test_a_tag_is_never_split_in_a_real_script(self) -> None:
"""The whole thing end to end, with tags scattered through it."""
script = (
"First sentence here. [laugh] Second sentence here. "
"<giggle> Third sentence here. [clear throat] Fourth sentence. "
) * 12
chunks = chunk_text(script, 120)
_assert_no_split_tag(chunks)
assert "[laugh]" in "".join(chunks)
assert "[clear throat]" in "".join(chunks)
class TestNothingIsLost:
def test_the_concatenation_of_the_chunks_loses_no_words(self) -> None:
text = "A sentence here. " * 100
chunks = chunk_text(text, 200)
assert text.split() == " ".join(chunks).split()
def test_no_words_are_lost_when_tags_are_in_the_script(self) -> None:
script = ("One two three. [laugh] Four five six. <giggle> Seven. ") * 20
chunks = chunk_text(script, 100)
assert script.split() == " ".join(chunks).split()
def test_chunking_can_still_be_disabled(self) -> None:
text = "A sentence here. " * 100
assert chunk_text(text, 200, enabled=False) == [text]
@pytest.mark.parametrize("limit", [1, 7, 40, 400])
def test_the_chunker_always_makes_progress(self, limit: int) -> None:
"""A limit of one is the pathological case: it must still terminate."""
chunks = chunk_text("a bb ccc [d] eeeee", limit)
assert "".join(chunks).replace(" ", "") == "abbccc[d]eeeee"
class TestThePlanCutsTheScript:
"""The engine's own view of a long script.
The model-level half of this -- a stub that says more when the script is
longer -- lives in ``tests/test_generation.py``, because the module that
drives the model imports ComfyUI and this file deliberately does not.
"""
def test_a_long_script_is_cut_into_several_chunks(self) -> None:
from ComfyUI_Chatterbox.modules.text_processing import plan_text
plan = plan_text(
"A sentence here. " * 100,
crash_protection_template="",
max_chars_per_chunk=200,
)
assert len(plan.chunks) > 1
def test_the_cut_loses_no_words(self) -> None:
from ComfyUI_Chatterbox.modules.text_processing import plan_text
script = "A sentence here. " * 100
plan = plan_text(
script,
crash_protection_template="",
max_chars_per_chunk=200,
)
assert " ".join(plan.chunks).split() == script.split()
def test_the_cut_can_be_switched_off(self) -> None:
from ComfyUI_Chatterbox.modules.text_processing import plan_text
plan = plan_text(
"A sentence here. " * 100,
crash_protection_template="",
enable_chunking=False,
max_chars_per_chunk=200,
)
assert len(plan.chunks) == 1