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
310 lines
13 KiB
Python
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 |