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

289 lines
12 KiB
Python

"""S6.3 -- the three appended widgets, and what they do to the frozen prefix.
Adding a widget is the one change to a node schema that is *allowed* to alter a
saved graph, and it is only safe in one shape: appended after every frozen
input. Insert ``device`` next to a related setting and every stored value from
that point on shifts by one -- a graph saved on a machine with a second GPU
silently re-binds ``dtype`` to whatever now occupies the ``device`` index.
So this module checks three separate things, and the first one is the reason the
other two exist:
1. the frozen prefix is byte-for-byte what the snapshot recorded;
2. what sits behind that prefix is *closed* -- exactly ``device``, ``dtype``,
``attention_mode``, in that order;
3. each new widget's option list is derived from the live machine, and an option
that cannot work here is **removed from the schema** rather than offered and
then refused.
Point 3 is the interesting one. An attention backend that is unavailable must
not appear in the dropdown, because a dropdown is a promise. But a mode string
inside a saved workflow is not a promise: it is a record of the machine that
saved the graph, and the graph has to keep opening on a machine that cannot run
it. The node therefore drops the unavailable mode from its schema while
*accepting* it in ``validate_inputs`` and downgrading it at load time through
``resolve_attention_mode``.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
import torch
from ComfyUI_Chatterbox.modules.attention_utils import resolve_attention_mode
from ComfyUI_Chatterbox.modules.device_utils import get_device_options
from ComfyUI_Chatterbox.modules.dtype_utils import get_dtype_options
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 frozen prefix records an empty models dir; keep it that way."""
TTS_NODE_ID = "ChatterboxTTS"
VC_NODE_ID = "ChatterboxVC"
# The appended widgets, and the option source each one is built from. Naming
# the source here is the point: a dropdown whose options are hard-coded here
# would drift from the machine it is rendered on.
APPENDED_WIDGETS = {
"device": get_device_options,
"dtype": get_dtype_options,
}
# Everything each schema appends *after* its frozen block, in the one order it
# may be appended. Per node, because S8.6 added three widgets to VC only: the
# assertion below is that this set is closed, and deriving it from "whatever
# happens to follow the frozen block" would make it true by construction.
APPENDED_WIDGET_ORDER: dict[str, tuple[str, ...]] = {
TTS_NODE_ID: (
"device",
"dtype",
"attention_mode",
"top_k", # Turbo-family controls (Turbo node merged into TTS node).
"normalize_loudness",
# Flash controls (Single Language Pack round, 2026-10-07).
"num_steps",
"cfg_scale",
# Long-script controls (emotion-tag and chunking round, 2026-10-22).
"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 _required_info(node_cls) -> dict:
return node_cls.GET_NODE_INFO_V1()["input"]["required"]
class TestFrozenPrefixSurvived:
"""The appendage must not have moved a single frozen index."""
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
def test_the_frozen_prefix_is_unchanged(self, node_cls, snapshot: dict) -> None:
node_id = node_cls.GET_SCHEMA().node_id
expected = snapshot_signatures(snapshot, node_id, "required")
assert schema_signatures(node_cls, "required")[: len(expected)] == expected
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
def test_the_appendage_is_closed(self, node_cls, snapshot: dict) -> None:
"""The set of post-frozen widgets is spelled out, per node.
The point is that it is *closed*: "we appended one more widget" has to
be a failing test and a reviewed change, which it cannot be if the
expectation is derived from whatever happens to follow the frozen block.
"""
node_id = node_cls.GET_SCHEMA().node_id
frozen_count = len(snapshot["nodes"][node_id]["required"])
actual = schema_signatures(node_cls, "required")
assert tuple(entry[0] for entry in actual[frozen_count:]) == APPENDED_WIDGET_ORDER[
node_id
]
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
def test_optional_inputs_moved_no_widget_index(self, node_cls) -> None:
"""The optional inputs are AUDIO sockets, so they consume no index."""
info = node_cls.GET_NODE_INFO_V1()["input"]
for name, (_type, cfg) in info["optional"].items():
assert "options" not in cfg, (
f"optional input {name!r} is a widget; an appended socket-free "
f"widget there would shift nothing today and everything the "
f"day it is reordered"
)
class TestAppendedWidgetOptions:
"""Each dropdown is built from the live machine, not from a literal."""
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
@pytest.mark.parametrize("widget", sorted(APPENDED_WIDGETS))
def test_options_come_from_the_live_source(
self, node_cls, widget: str
) -> None:
info = _required_info(node_cls)
assert widget in info
assert info[widget][0] == "COMBO"
assert info[widget][1]["options"] == APPENDED_WIDGETS[widget](), (
f"{widget} options must be read from "
f"{APPENDED_WIDGETS[widget].__module__} at schema-build time"
)
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
def test_every_advertised_dtype_is_accepted_by_the_validator(
self, node_cls
) -> None:
"""Offering a value the node would refuse is a UI lie."""
for option in _required_info(node_cls)["dtype"][1]["options"]:
assert node_cls.validate_inputs(
model_pack_name=None, dtype=option, attention_mode="auto"
) is True, f"dtype={option!r} is offered but refused"
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
def test_every_advertised_device_resolves_to_a_torch_device(self, node_cls) -> None:
"""A device the loader cannot resolve is a device that fails at runtime."""
from ComfyUI_Chatterbox.modules.device_utils import get_torch_device
for option in _required_info(node_cls)["device"][1]["options"]:
assert isinstance(get_torch_device(option), torch.device), (
f"device={option!r} is offered but does not resolve"
)
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
def test_auto_is_the_last_device_option(self, node_cls) -> None:
"""Why it is last, in one line.
A saved workflow binds ``device`` **by index**. Inserting ``"auto"`` at
the front -- the natural place for a default -- would re-bind every
stored device value to whatever shifted up into its slot. ``modules/
device_utils.get_device_options`` appends it for exactly this reason and
the schema must not reorder it afterwards.
"""
options = _required_info(node_cls)["device"][1]["options"]
assert options[-1] == "auto"
assert options.count("auto") == 1
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
def test_dtype_leads_with_auto(self, node_cls) -> None:
options = _required_info(node_cls)["dtype"][1]["options"]
assert options[0] == "auto"
assert _required_info(node_cls)["dtype"][1]["default"] == "auto"
class TestUnavailableAttentionModesAreRemovedAtSchemaBuildTime:
"""A backend this machine cannot run must not be in the dropdown.
``get_available_attention_modes`` already returns only the modes that can be
loaded; ``nodes.common.runtime_widgets`` passes that list straight into the
Combo, so an unavailable ``sage`` / ``flash_attention_2`` is *removed while
the schema is built* and never reaches the frontend's range check in the
first place.
"""
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
def test_an_unavailable_mode_is_dropped_from_the_schema(self, node_cls) -> None:
with patch(
"ComfyUI_Chatterbox.nodes.common.get_available_attention_modes",
return_value=["eager", "sdpa"],
):
options = _required_info(node_cls)["attention_mode"][1]["options"]
assert options == ["eager", "sdpa"]
assert "sage" not in options
assert "flash_attention_2" not in options
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
def test_a_saved_graph_naming_sage_is_not_refused(self, node_cls) -> None:
"""The graph has to keep opening on a machine without SageAttention.
A mode string in a saved workflow describes the machine that saved it.
Refusing the prompt here would mean a user who shared a graph cannot
run it at all; downgrading it means it runs, on a different backend, and
says so in the log.
"""
with patch(
"ComfyUI_Chatterbox.nodes.common.get_available_attention_modes",
return_value=["eager", "sdpa"],
):
# Schema side: the option is gone...
options = _required_info(node_cls)["attention_mode"][1]["options"]
assert "sage" not in options
# ...but the validator side: the request is honoured, not rejected.
assert node_cls.validate_inputs(
model_pack_name=None, dtype="auto", attention_mode="sage"
) is True
def test_an_unavailable_mode_still_reaches_the_loader_intact(self) -> None:
"""The fallback happens in the loader, where the hardware is known."""
with patch(
"ComfyUI_Chatterbox.modules.attention_utils.check_sage_attention_compatible",
return_value=False,
):
assert resolve_attention_mode("sage") == "sdpa"
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
def test_the_live_options_are_offered_when_they_are_available(
self, node_cls
) -> None:
"""Sanity check against the real machine, no mocks involved."""
from ComfyUI_Chatterbox.modules.attention_utils import (
get_available_attention_modes,
)
assert (
_required_info(node_cls)["attention_mode"][1]["options"]
== get_available_attention_modes()
)
class TestDtypeAttentionCrossCheck:
"""The pair that crashes inside a CUDA kernel must be caught at queue time."""
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
def test_fp32_with_sage_is_refused_with_a_message(self, node_cls) -> None:
result = node_cls.validate_inputs(
model_pack_name=None, dtype="fp32", attention_mode="sage"
)
assert isinstance(result, str)
assert "fp32" in result and "sage" in result
assert "bf16" in result and "sdpa" in result
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
@pytest.mark.parametrize("dtype", ["auto", "bf16", "fp16"])
def test_sage_with_a_half_dtype_is_accepted(self, node_cls, dtype: str) -> None:
assert node_cls.validate_inputs(
model_pack_name=None, dtype=dtype, attention_mode="sage"
) is True
@pytest.mark.parametrize("node_cls", [ChatterboxTTSNode, ChatterboxVCNode])
def test_fp32_with_sdpa_is_accepted(self, node_cls) -> None:
assert node_cls.validate_inputs(
model_pack_name=None, dtype="fp32", attention_mode="sdpa"
) is True