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
289 lines
12 KiB
Python
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 |