Files
wildminder-ComfyUI-Chatterbox/tests/test_node_schema.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

310 lines
13 KiB
Python

"""S6.1/S6.2 -- the V3 schemas reproduce the frozen V1 widget surface exactly.
ComfyUI binds a saved workflow's widget values **by index**. Nothing in the
protocol records which widget a stored value belonged to, so a schema that
merely *contains* the right widgets in a different order silently re-binds
every graph a user already has: ``use_watermark`` lands on ``seed``, the text
script lands on ``max_new_tokens``, and nothing errors until the audio comes out
wrong.
That is why the assertions here are **exact tuple equality**, never a prefix
match. A prefix assertion -- ``schema_names[:12] == frozen_names`` on its own,
or ``frozen_names[0] in schema_names`` -- passes the moment a widget is
inserted in the middle of the frozen block or a fourth one is appended, which
is precisely the failure being guarded against.
The three widgets this rebuild legitimately adds (``device``, ``dtype``,
``attention_mode``) are therefore handled explicitly, not waved through: the
frozen window is compared for exact equality *and* the remainder is pinned to
exactly those three names, in order, so "we appended one more widget" is a
failing test and a deliberate, reviewed change.
"""
from __future__ import annotations
import pytest
from ComfyUI_Chatterbox.nodes import ChatterboxTTSNode, ChatterboxVCNode
from .conftest_helpers import (
empty_pack_root,
load_frozen_snapshot,
schema_signatures,
snapshot_signatures,
)
@pytest.fixture(autouse=True)
def _pin_pack_discovery_to_an_empty_root(empty_pack_root):
"""The schema snapshots record an empty models dir; keep it that way."""
# The node ids are frozen too: they are what a saved graph names.
TTS_NODE_ID = "ChatterboxTTS"
VC_NODE_ID = "ChatterboxVC"
# The widgets each schema appends after its frozen block, in the one order they
# may be appended. Per node, not a single tuple: S8.6 added three widgets to
# ``ChatterboxVC`` only, and a shared tuple would either force them onto the TTS
# node or force this test to stop filtering by name -- and a test that stops
# filtering by name stops being exact tuple equality, which is the whole point.
APPENDED_WIDGETS: dict[str, tuple[str, ...]] = {
TTS_NODE_ID: (
"device",
"dtype",
"attention_mode",
# Turbo-family controls, appended when the Turbo node merged into the
# TTS node (2026-10-06). Classic packs ignore both.
"top_k",
"normalize_loudness",
# Flash controls, appended with the Single Language Pack round
# (2026-10-07). Flash packs ignore the Turbo ones and vice versa.
"num_steps",
"cfg_scale",
# Long-script controls, appended with the emotion-tag and chunking
# round (2026-10-22). Every family uses them.
"enable_chunking",
"max_chars_per_chunk",
"chunk_combination_method",
"silence_between_chunks_ms",
),
VC_NODE_ID: (
"device",
"dtype",
"attention_mode",
"refinement_passes",
"max_chunk_duration",
"chunk_method",
),
}
NODES = (
pytest.param(ChatterboxTTSNode, TTS_NODE_ID, id="tts"),
pytest.param(ChatterboxVCNode, VC_NODE_ID, id="vc"),
)
@pytest.fixture(scope="module")
def snapshot() -> dict:
return load_frozen_snapshot()
def _schema(node_cls):
"""The finalized, validated schema core will register."""
return node_cls.GET_SCHEMA()
class TestTTSSchema:
"""``ChatterboxTTS``: identity, frozen widgets, outputs."""
def test_node_id_is_frozen(self) -> None:
assert _schema(ChatterboxTTSNode).node_id == TTS_NODE_ID
def test_display_name_and_category(self, snapshot: dict) -> None:
node = _schema(ChatterboxTTSNode)
assert node.display_name == snapshot["nodes"][TTS_NODE_ID]["display_name"]
# Category was moved to the WMNodes/sound group at the owner's
# direction; the snapshot keeps the V1 value as history only.
assert node.category == "WMNodes/sound/tts"
def test_required_inputs_equal_the_snapshot_exactly(self, snapshot: dict) -> None:
"""The load-bearing assertion. Exact tuple equality, twice over.
Not a prefix match. The schema's required inputs are partitioned into
the frozen set (compared for **whole-tuple equality** with the snapshot)
and the three widgets S6.3 sanctioned (compared for whole-tuple equality
with each other), and the total length is pinned. A reorder fails the
first comparison; a fourth appended widget fails the third. A prefix
assertion would pass on both, which is exactly the defect being
guarded against.
"""
expected = snapshot_signatures(snapshot, TTS_NODE_ID, "required")
actual = schema_signatures(ChatterboxTTSNode, "required")
appended_names = APPENDED_WIDGETS[TTS_NODE_ID]
frozen = tuple(entry for entry in actual if entry[0] not in appended_names)
appended = tuple(entry[0] for entry in actual if entry[0] in appended_names)
assert frozen == expected
assert appended == appended_names
assert len(actual) == len(expected) + len(appended_names)
def test_optional_inputs_equal_the_snapshot_exactly(self, snapshot: dict) -> None:
expected = snapshot_signatures(snapshot, TTS_NODE_ID, "optional")
assert schema_signatures(ChatterboxTTSNode, "optional") == expected
def test_seed_carries_control_after_generate(self) -> None:
"""The flag that makes the shipped graph store 13 values, not 12."""
seed = next(
entry
for entry in ChatterboxTTSNode.GET_NODE_INFO_V1()["input"]["required"].items()
if entry[0] == "seed"
)
assert seed[1][0] == "INT"
assert seed[1][1]["control_after_generate"] is True
def test_seed_is_the_only_control_after_generate_input(self) -> None:
flagged = [
name
for name, (_type, cfg) in ChatterboxTTSNode.GET_NODE_INFO_V1()["input"]["required"].items()
if cfg.get("control_after_generate")
]
assert flagged == ["seed"]
def test_text_is_a_multiline_string(self) -> None:
config = ChatterboxTTSNode.GET_NODE_INFO_V1()["input"]["required"]["text"]
assert config[0] == "STRING"
assert config[1]["multiline"] is True
def test_audio_prompt_is_optional(self) -> None:
optional = ChatterboxTTSNode.GET_NODE_INFO_V1()["input"]["optional"]
assert list(optional) == ["audio_prompt"]
assert optional["audio_prompt"][0] == "AUDIO"
def test_output_is_a_real_audio_socket_named_audio(self) -> None:
schema = _schema(ChatterboxTTSNode)
assert [output.io_type for output in schema.outputs] == ["AUDIO"]
assert [output.display_name for output in schema.outputs] == ["audio"]
def test_output_node_is_false(self) -> None:
"""V1 declared ``OUTPUT_NODE = True`` *and* returned a socket.
Both cannot be right. An output node is a terminal: ComfyUI executes it
on its own even with nothing downstream, and caches its UI result
instead of passing it along. Returning the socket is what this node
actually does. The flag is behaviour, not a widget, so changing it does
not move a single saved index.
"""
assert _schema(ChatterboxTTSNode).is_output_node is False
assert ChatterboxTTSNode.OUTPUT_NODE is False
def test_schema_validates_and_finalizes(self) -> None:
"""``finalize`` gives every output an id and adds the hidden inputs."""
schema = ChatterboxTTSNode.FINALIZE_SCHEMA()
assert schema.outputs[0].id
# Not an output node, so it must not have been handed a prompt.
assert schema.hidden == []
class TestVCSchema:
"""``ChatterboxVC``: the mirror image, plus one frozen output name."""
def test_node_id_is_frozen(self) -> None:
assert _schema(ChatterboxVCNode).node_id == VC_NODE_ID
def test_display_name_and_category(self, snapshot: dict) -> None:
node = _schema(ChatterboxVCNode)
assert node.display_name == snapshot["nodes"][VC_NODE_ID]["display_name"]
# Category moved to WMNodes/sound; see the TTS note above.
assert node.category == "WMNodes/sound/vc"
def test_required_inputs_equal_the_snapshot_exactly(self, snapshot: dict) -> None:
expected = snapshot_signatures(snapshot, VC_NODE_ID, "required")
actual = schema_signatures(ChatterboxVCNode, "required")
appended_names = APPENDED_WIDGETS[VC_NODE_ID]
frozen = tuple(entry for entry in actual if entry[0] not in appended_names)
appended = tuple(entry[0] for entry in actual if entry[0] in appended_names)
assert frozen == expected
assert appended == appended_names
assert len(actual) == len(expected) + len(appended_names)
def test_optional_inputs_equal_the_snapshot_exactly(self, snapshot: dict) -> None:
expected = snapshot_signatures(snapshot, VC_NODE_ID, "optional")
assert schema_signatures(ChatterboxVCNode, "optional") == expected
def test_output_socket_is_named_converted_audio(self) -> None:
"""The name is part of the frozen surface; ``audio`` is the TTS node's.
A saved graph refers to an output by its displayed name. Renaming this
to ``audio`` would collide with the sibling node's socket and leave
every existing VC link unresolved.
"""
schema = _schema(ChatterboxVCNode)
assert [output.io_type for output in schema.outputs] == ["AUDIO"]
assert [output.display_name for output in schema.outputs] == ["converted_audio"]
def test_output_node_is_false(self) -> None:
assert _schema(ChatterboxVCNode).is_output_node is False
assert ChatterboxVCNode.OUTPUT_NODE is False
def test_schema_validates_and_finalizes(self) -> None:
schema = ChatterboxVCNode.FINALIZE_SCHEMA()
assert schema.outputs[0].id
assert schema.hidden == []
class TestValidateInputs:
"""Queue-time checks must *refuse with a message*, never with a traceback."""
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
def test_the_default_pack_is_accepted(self, node_cls) -> None:
from ComfyUI_Chatterbox.modules.model_discovery import (
get_chatterbox_model_pack_names,
)
for name in get_chatterbox_model_pack_names():
assert node_cls.validate_inputs(model_pack_name=name) is True
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
def test_an_unknown_pack_returns_a_readable_message(self, node_cls) -> None:
result = node_cls.validate_inputs(model_pack_name="no_such_pack")
assert isinstance(result, str), (
"an unknown voice pack must produce a message the user reads in "
"the queue panel, not an exception"
)
assert "no_such_pack" in result
assert result.strip() == result and "\n" not in result
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
def test_no_inputs_at_all_is_fine(self, node_cls) -> None:
assert node_cls.validate_inputs() is True
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
def test_every_advertised_combo_option_passes_validation(self, node_cls) -> None:
"""A dropdown must never offer a value its own validator refuses.
Only the inputs the validator actually declares are swept: core calls
``validate_inputs`` with just those, so an undeclared widget is not a
path this test can reach.
"""
import inspect
declared = set(inspect.signature(node_cls.validate_inputs).parameters)
info = node_cls.GET_NODE_INFO_V1()["input"]
for section in ("required", "optional"):
for name, (_type, cfg) in info.get(section, {}).items():
options = cfg.get("options")
if not options or name not in declared:
continue
for option in options:
kwargs = {name: option}
if name == "attention_mode":
# `auto` resolves to a half dtype on every GPU Sage
# supports, so it is exempt from the cross-check.
kwargs["dtype"] = "auto"
assert node_cls.validate_inputs(**kwargs) is True, (
f"{name}={option!r} is offered but refused"
)
class TestNoVendoredImportAtModuleScope:
"""Importing the node modules must never pull in the vendored model stack.
ComfyUI imports every custom-node package at startup and the frontend asks
``/object_info`` before a single model is loaded. A module-scope
``from chatterbox.tts import ChatterboxTTS`` would drag torch weights,
``librosa`` and the whole transformer stack into that path.
"""
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
def test_the_node_modules_never_import_chatterbox(self, node_cls) -> None:
import inspect
import ComfyUI_Chatterbox.nodes.tts_node as tts_module
import ComfyUI_Chatterbox.nodes.vc_node as vc_module
for module in (tts_module, vc_module):
source = inspect.getsource(module)
assert "import chatterbox" not in source
assert "from chatterbox" not in source