- flash sampler's num_steps/cfg_scale surface as TTS widgets - MTL node loads the 6 SLP finetunes: per-pack t3/s3gen resolution, companion ve.pt fetch, no-voice packs refuse with an actionable error (P-036) - s3gen loads non-strict: SLP checkpoints predate two recomputed tokenizer buffers, strict load refused them - schema tests pin pack discovery to an empty models root
495 lines
20 KiB
Python
495 lines
20 KiB
Python
"""S8.1 -- ``ChatterboxMTLTTS``: 23 languages, a v3 default, and an additive id.
|
|
|
|
Three things here can only be asserted against the real vendored source, and each
|
|
is worth the subprocess:
|
|
|
|
* **The language table is a copy, and a copy is a bug waiting to happen.**
|
|
``modules/constants.py`` cannot import ``chatterbox.mtl_tts`` -- that module
|
|
drags in librosa, transformers and the whole model stack -- so the 23 codes are
|
|
duplicated on purpose. This file reads the real ``SUPPORTED_LANGUAGES`` in a
|
|
child interpreter and compares it with the copy, so the two cannot drift.
|
|
* **``language_id`` must be prepended exactly once.** The vendored tokenizer
|
|
prepends it itself; a node that also inserted it emits ``<de><de>Hallo``, which
|
|
is not a token sequence and dies inside the T3. The assertion is on what the
|
|
model was actually handed.
|
|
* **The new node id is additive.** ``tests/frozen_widget_snapshot.json`` is the
|
|
V1 compatibility record and is never written to; this file asserts the frozen
|
|
entries are still exactly what they were, and that the new ids are new.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
|
|
import pytest
|
|
|
|
from ComfyUI_Chatterbox.modules.constants import (
|
|
MTL_DEFAULT_T3_MODEL,
|
|
MTL_LANGUAGES,
|
|
MTL_LANGUAGE_CODES,
|
|
MTL_LANGUAGE_LOOKUP,
|
|
MTL_T3_MODELS,
|
|
)
|
|
from ComfyUI_Chatterbox.modules.text_processing import CRASH_PROTECTION_TEMPLATE
|
|
from ComfyUI_Chatterbox.nodes.mtl_node import (
|
|
MTL_MODEL_VERSION_DEFAULT,
|
|
MTL_MODEL_VERSION_OPTIONS,
|
|
ChatterboxMTLTTSNode,
|
|
)
|
|
|
|
from conftest import ROOT_DIR
|
|
|
|
SRC_DIR = os.path.join(ROOT_DIR, "src")
|
|
|
|
MTL_NODE_ID = "ChatterboxMTLTTS"
|
|
FROZEN_NODE_IDS = ("ChatterboxTTS", "ChatterboxVC")
|
|
|
|
#: The 23 ISO 639-1 codes upstream v0.1.7 ships. Spelled out here rather than
|
|
#: derived, so a table that loses an entry fails instead of shrinking quietly.
|
|
EXPECTED_CODES = frozenset(
|
|
{
|
|
"ar", "da", "de", "el", "en", "es", "fi", "fr", "he", "hi", "it",
|
|
"ja", "ko", "ms", "nl", "no", "pl", "pt", "ru", "sv", "sw", "tr", "zh",
|
|
}
|
|
)
|
|
|
|
|
|
class _RecordingModel:
|
|
"""A stand-in for ``ChatterboxMultilingualTTS`` that records its call."""
|
|
|
|
def __init__(self) -> None:
|
|
self.calls: list[dict] = []
|
|
# A pack with a built-in voice: without this the no-conds guard in
|
|
# generate_mtl_audio refuses the call before the model is reached.
|
|
self.conds = object()
|
|
|
|
def generate(self, text, language_id, **kwargs):
|
|
import torch
|
|
|
|
self.calls.append({"text": text, "language_id": language_id, **kwargs})
|
|
return torch.zeros((1, 2400), dtype=torch.float32)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schema
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSchema:
|
|
def test_the_node_id_is_new_and_does_not_replace_anything(self) -> None:
|
|
schema = ChatterboxMTLTTSNode.GET_SCHEMA()
|
|
assert schema.node_id == MTL_NODE_ID
|
|
assert MTL_NODE_ID not in FROZEN_NODE_IDS
|
|
assert schema.outputs[0].display_name == "audio"
|
|
assert schema.is_output_node is False
|
|
|
|
def test_the_frozen_snapshot_is_untouched_and_still_complete(self) -> None:
|
|
"""``ChatterboxMTLTTS`` is added to the surface, never carved out of it."""
|
|
with open(
|
|
os.path.join(ROOT_DIR, "tests", "frozen_widget_snapshot.json"),
|
|
encoding="utf-8",
|
|
) as handle:
|
|
snapshot = json.load(handle)
|
|
assert set(snapshot["nodes"]) == set(FROZEN_NODE_IDS), (
|
|
"the frozen record holds exactly ChatterboxTTS and ChatterboxVC; a "
|
|
"new node id belongs in the node list, not in this file"
|
|
)
|
|
assert MTL_NODE_ID not in snapshot["nodes"]
|
|
|
|
def test_the_frozen_tts_and_vc_still_render_unchanged(self) -> None:
|
|
from ComfyUI_Chatterbox.nodes import ChatterboxTTSNode, ChatterboxVCNode
|
|
|
|
assert ChatterboxTTSNode.GET_SCHEMA().node_id == "ChatterboxTTS"
|
|
assert ChatterboxVCNode.GET_SCHEMA().node_id == "ChatterboxVC"
|
|
|
|
def test_the_schema_finalizes(self) -> None:
|
|
schema = ChatterboxMTLTTSNode.FINALIZE_SCHEMA()
|
|
assert schema.outputs[0].id
|
|
|
|
def test_the_sampling_widgets_are_the_multilingual_ones(self) -> None:
|
|
"""Wider ``exaggeration``, a zero-floor ``cfg_weight``, and no ``top_k``."""
|
|
required = ChatterboxMTLTTSNode.GET_NODE_INFO_V1()["input"]["required"]
|
|
assert (required["exaggeration"][1]["min"], required["exaggeration"][1]["max"]) == (
|
|
0.0,
|
|
5.0,
|
|
)
|
|
assert (required["cfg_weight"][1]["min"], required["cfg_weight"][1]["max"]) == (
|
|
0.0,
|
|
1.0,
|
|
)
|
|
assert required["repetition_penalty"][1]["default"] == 1.2
|
|
assert "min_p" in required and "top_p" in required
|
|
|
|
def test_top_k_is_rejected_on_the_multilingual_node(self) -> None:
|
|
"""Turbo-only knob. The multilingual sampler has no top-k at all.
|
|
|
|
A widget the model ignores is how a user spends an afternoon on a knob
|
|
with no effect, so it is not offered rather than offered and refused.
|
|
"""
|
|
info = ChatterboxMTLTTSNode.GET_NODE_INFO_V1()["input"]
|
|
names = set(info["required"]) | set(info.get("optional", {}))
|
|
assert "top_k" not in names
|
|
|
|
import inspect
|
|
|
|
assert "top_k" not in inspect.signature(
|
|
ChatterboxMTLTTSNode.execute
|
|
).parameters, "the multilingual sampler has no top-k to pass"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Languages
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLanguages:
|
|
def test_there_are_exactly_twenty_three(self) -> None:
|
|
assert len(MTL_LANGUAGES) == 23
|
|
assert len(MTL_LANGUAGE_CODES) == 23
|
|
|
|
def test_the_codes_are_the_documented_set(self) -> None:
|
|
assert set(MTL_LANGUAGE_CODES) == EXPECTED_CODES
|
|
assert set(MTL_LANGUAGES) == EXPECTED_CODES
|
|
|
|
def test_there_is_no_vietnamese(self) -> None:
|
|
"""Vietnamese is a community finetune, not an upstream language."""
|
|
assert "vi" not in MTL_LANGUAGES
|
|
assert "Vietnamese" not in MTL_LANGUAGES.values()
|
|
|
|
def test_the_dropdown_offers_every_code_and_defaults_to_english(self) -> None:
|
|
info = ChatterboxMTLTTSNode.GET_NODE_INFO_V1()["input"]
|
|
config = info["required"]["language"]
|
|
assert set(config[1]["options"]) == EXPECTED_CODES
|
|
assert config[1]["options"] == sorted(MTL_LANGUAGE_CODES)
|
|
assert config[1]["default"] == "en"
|
|
|
|
@pytest.mark.parametrize("code", sorted(EXPECTED_CODES))
|
|
def test_every_language_id_is_accepted(self, code: str) -> None:
|
|
assert ChatterboxMTLTTSNode.validate_inputs(language=code) is True
|
|
|
|
def test_an_unknown_language_is_refused_with_a_message(self) -> None:
|
|
result = ChatterboxMTLTTSNode.validate_inputs(language="xx")
|
|
assert isinstance(result, str)
|
|
assert "xx" in result
|
|
|
|
@pytest.mark.parametrize(
|
|
("tag", "expected"),
|
|
[("de", "de"), ("German", "de"), ("german", "de"), ("zh", "zh"), ("Chinese", "zh")],
|
|
)
|
|
def test_both_a_code_and_a_display_name_resolve(self, tag: str, expected: str) -> None:
|
|
assert MTL_LANGUAGE_LOOKUP[tag.lower()] == expected
|
|
|
|
|
|
class TestLanguageTableMatchesTheVendoredSource:
|
|
"""The copy in ``modules/constants.py`` is pinned to the real dictionary."""
|
|
|
|
def test_the_23_codes_are_the_ones_the_tokenizer_accepts(self) -> None:
|
|
probe = textwrap.dedent(
|
|
"""
|
|
import json, sys
|
|
sys.path.insert(0, {src_dir!r})
|
|
from chatterbox.mtl_tts import SUPPORTED_LANGUAGES
|
|
from chatterbox.mtl_tts import _resolve_multilingual_t3_model
|
|
print("__PROBE__" + json.dumps({{
|
|
"languages": SUPPORTED_LANGUAGES,
|
|
"default_t3": _resolve_multilingual_t3_model(None),
|
|
"v3": _resolve_multilingual_t3_model("v3"),
|
|
}}))
|
|
"""
|
|
).format(src_dir=SRC_DIR)
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", probe],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=ROOT_DIR,
|
|
timeout=600,
|
|
)
|
|
line = next(
|
|
(l for l in result.stdout.splitlines() if l.startswith("__PROBE__")), None
|
|
)
|
|
assert line is not None, (
|
|
"the vendored multilingual entry point did not import in a child "
|
|
f"interpreter; stdout={result.stdout[-800:]} stderr={result.stderr[-800:]}"
|
|
)
|
|
payload = json.loads(line[len("__PROBE__") :])
|
|
assert payload["languages"] == MTL_LANGUAGES, (
|
|
"modules/constants.MTL_LANGUAGES has drifted from "
|
|
"chatterbox.mtl_tts.SUPPORTED_LANGUAGES"
|
|
)
|
|
# And the vendor's own default is v2 -- which is why ours is not.
|
|
assert payload["default_t3"] == "t3_mtl23ls_v2.safetensors"
|
|
assert payload["v3"] == "t3_mtl23ls_v3.safetensors"
|
|
assert MTL_DEFAULT_T3_MODEL == "v3"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# model_version
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestModelVersion:
|
|
def test_the_dropdown_shows_resolved_filenames(self) -> None:
|
|
assert MTL_MODEL_VERSION_OPTIONS == (
|
|
"t3_mtl23ls_v3.safetensors",
|
|
"t3_mtl23ls_v2.safetensors",
|
|
"t3_23lang.safetensors",
|
|
)
|
|
options = ChatterboxMTLTTSNode.GET_NODE_INFO_V1()["input"]["required"][
|
|
"model_version"
|
|
][1]["options"]
|
|
assert tuple(options) == MTL_MODEL_VERSION_OPTIONS
|
|
|
|
def test_the_default_is_v3_not_upstreams_v2(self) -> None:
|
|
"""The single most important assertion in this file.
|
|
|
|
Upstream still resolves ``None`` to v2. A node that shipped v2 as its
|
|
default would be silently giving users the checkpoint upstream itself
|
|
treats as the older one, while looking identical in every screenshot.
|
|
"""
|
|
config = ChatterboxMTLTTSNode.GET_NODE_INFO_V1()["input"]["required"][
|
|
"model_version"
|
|
]
|
|
assert config[1]["default"] == "t3_mtl23ls_v3.safetensors"
|
|
assert MTL_MODEL_VERSION_DEFAULT == "t3_mtl23ls_v3.safetensors"
|
|
assert config[1]["default"] == MTL_T3_MODELS["v3"]
|
|
assert MTL_T3_MODELS["v3"] != MTL_T3_MODELS["v2"]
|
|
|
|
@pytest.mark.parametrize("filename", MTL_MODEL_VERSION_OPTIONS)
|
|
def test_every_offered_version_is_accepted(self, filename: str) -> None:
|
|
assert ChatterboxMTLTTSNode.validate_inputs(model_version=filename) is True
|
|
|
|
def test_an_unknown_version_is_refused_with_a_message(self) -> None:
|
|
result = ChatterboxMTLTTSNode.validate_inputs(model_version="t3_nope.safetensors")
|
|
assert isinstance(result, str)
|
|
assert "t3_mtl23ls_v3.safetensors" in result
|
|
|
|
def test_the_tooltip_says_that_v3_exaggeration_is_inert(self) -> None:
|
|
tooltip = ChatterboxMTLTTSNode.GET_NODE_INFO_V1()["input"]["required"][
|
|
"exaggeration"
|
|
][1].get("tooltip", "")
|
|
assert "v3" in tooltip
|
|
assert "no audible response" in tooltip
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Language id is prepended exactly once
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLanguageIdIsPrependedExactlyOnce:
|
|
def test_the_text_reaches_the_model_without_a_language_prefix(self, monkeypatch) -> None:
|
|
"""The vendored tokenizer adds the prefix; the node must not add a second.
|
|
|
|
``MTLTokenizer.text_to_tokens`` does ``clean_text + lang_id`` before
|
|
encoding. A node that also spliced ``<de>`` into the string would emit
|
|
``<de><de>Hallo``, which is not a token sequence and fails inside the T3
|
|
with a message about tokens rather than about the text box.
|
|
"""
|
|
model = _RecordingModel()
|
|
monkeypatch.setattr(
|
|
"ComfyUI_Chatterbox.nodes.mtl_node.load_chatterbox_entry_point",
|
|
lambda *a, **k: (type("P", (), {"cache_key": "k"})(), model),
|
|
)
|
|
ChatterboxMTLTTSNode.execute(
|
|
model_pack_name="resembleai_default_voice",
|
|
model_version="t3_mtl23ls_v3.safetensors",
|
|
language="de",
|
|
text="Hallo, wie geht es dir?",
|
|
exaggeration=0.5,
|
|
temperature=0.8,
|
|
cfg_weight=0.5,
|
|
repetition_penalty=1.2,
|
|
min_p=0.05,
|
|
top_p=1.0,
|
|
seed=1,
|
|
use_watermark=False,
|
|
)
|
|
|
|
assert len(model.calls) == 1
|
|
call = model.calls[0]
|
|
assert call["language_id"] == "de"
|
|
assert call["text"] == "Hallo, wie geht es dir?"
|
|
assert "<de>" not in call["text"]
|
|
assert call["text"].lower().count("hallo") == 1
|
|
|
|
def test_the_language_id_is_a_keyword_not_part_of_the_text(self) -> None:
|
|
import inspect
|
|
|
|
from ComfyUI_Chatterbox.modules.generation import generate_mtl_audio
|
|
|
|
assert "language_id" in inspect.signature(generate_mtl_audio).parameters
|
|
|
|
def test_empty_text_returns_silence_without_loading_a_model(self, monkeypatch) -> None:
|
|
"""Blank text is silence, and it costs nothing: no load, no download.
|
|
|
|
The loader is swapped for a recorder rather than a raiser, so what this
|
|
asserts is the *count* -- the model was never built -- rather than "it did
|
|
not explode". The output is then checked to be real silence, which is the
|
|
one legitimate silence in the node contract (the other, cancellation, is
|
|
a BaseException and never reaches here).
|
|
"""
|
|
loads: list[dict] = []
|
|
|
|
monkeypatch.setattr(
|
|
"ComfyUI_Chatterbox.nodes.mtl_node.load_chatterbox_entry_point",
|
|
lambda *a, **k: loads.append(k),
|
|
)
|
|
output = ChatterboxMTLTTSNode.execute(
|
|
model_pack_name="resembleai_default_voice",
|
|
model_version="t3_mtl23ls_v3.safetensors",
|
|
language="en",
|
|
text=" ",
|
|
exaggeration=0.5,
|
|
temperature=0.8,
|
|
cfg_weight=0.5,
|
|
repetition_penalty=1.2,
|
|
min_p=0.05,
|
|
top_p=1.0,
|
|
seed=1,
|
|
use_watermark=False,
|
|
)
|
|
assert loads == [], "an empty text box triggered a model load"
|
|
waveform = output.result[0]["waveform"]
|
|
assert waveform.numel() > 0
|
|
assert float(waveform.abs().sum()) == 0.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Optional dependencies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOptionalTokenizers:
|
|
"""``pykakasi`` / ``spacy-pkuseg`` are optional, and the node does not assume them.
|
|
|
|
The degradation is upstream's: both are imported *inside* the tokenizer's
|
|
functions and warn when absent. What this repository owns is that it does not
|
|
break that contract -- so the assertions are about the import being lazy and
|
|
guarded, not about a code path the mocked vendor tree cannot execute.
|
|
"""
|
|
|
|
def test_the_normaliser_is_genuinely_absent_here(self) -> None:
|
|
"""This is the situation being guarded: neither package is installed."""
|
|
import importlib.util
|
|
|
|
assert importlib.util.find_spec("pykakasi") is None
|
|
assert importlib.util.find_spec("spacy_pkuseg") is None
|
|
|
|
def test_no_shipped_module_imports_a_normaliser_at_module_scope(self) -> None:
|
|
import ast
|
|
|
|
for directory in ("modules", "nodes"):
|
|
for name in sorted(os.listdir(os.path.join(ROOT_DIR, directory))):
|
|
if not name.endswith(".py"):
|
|
continue
|
|
path = os.path.join(ROOT_DIR, directory, name)
|
|
with open(path, encoding="utf-8") as handle:
|
|
tree = ast.parse(handle.read(), filename=path)
|
|
for node in tree.body: # module scope only
|
|
if isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
assert "pykakasi" not in alias.name, path
|
|
assert "spacy" not in alias.name, path
|
|
elif isinstance(node, ast.ImportFrom):
|
|
assert "pykakasi" not in (node.module or ""), path
|
|
|
|
def test_the_vendored_tokenizer_imports_the_normaliser_lazily(self) -> None:
|
|
"""The upstream guarantee the node relies on, asserted on the source.
|
|
|
|
A module-scope ``import pykakasi`` would make
|
|
``chatterbox.mtl_tts`` -- and therefore the whole multilingual entry
|
|
point -- unimportable on a machine without it, which is the failure the
|
|
corpus says must not happen.
|
|
"""
|
|
import ast
|
|
|
|
path = os.path.join(SRC_DIR, "chatterbox", "models", "tokenizers", "tokenizer.py")
|
|
with open(path, encoding="utf-8") as handle:
|
|
tree = ast.parse(handle.read(), filename=path)
|
|
|
|
targets = [
|
|
node
|
|
for node in ast.walk(tree)
|
|
if isinstance(node, ast.Import)
|
|
for alias in node.names
|
|
if "pykakasi" in alias.name or "spacy" in alias.name
|
|
]
|
|
assert targets, "the tokenizer no longer mentions the normalisers at all"
|
|
for node in targets:
|
|
assert not any(node is child for child in tree.body), (
|
|
f"line {node.lineno}: the optional normaliser is imported at "
|
|
f"module scope, which makes the whole entry point unimportable "
|
|
f"without it"
|
|
)
|
|
|
|
def test_the_feature_flag_reports_the_absent_normaliser_honestly(self) -> None:
|
|
"""``_probe_text_normalization`` is the live answer; the module constant is not.
|
|
|
|
``TEXT_NORMALIZATION_AVAILABLE`` is recomputed only by
|
|
``refresh_feature_flags()``, which other tests in this suite call with a
|
|
stubbed probe, so asserting on it here would be asserting on whatever the
|
|
last caller left behind. The probe is a pure function of what is
|
|
installed, which is the thing worth pinning.
|
|
"""
|
|
from ComfyUI_Chatterbox.modules.model_info import _probe_text_normalization
|
|
|
|
assert _probe_text_normalization() is False, (
|
|
"neither pykakasi nor spacy-pkuseg is installed, so the probe must be "
|
|
"False; a True here would grey-out nothing and promise a normaliser"
|
|
)
|
|
|
|
def test_the_nodes_offer_no_normalisation_widget_that_does_nothing(self) -> None:
|
|
"""No ``use_normalization`` switch: there is no switch to make true."""
|
|
info = ChatterboxMTLTTSNode.GET_NODE_INFO_V1()["input"]
|
|
names = set(info["required"]) | set(info.get("optional", {}))
|
|
assert not any("normal" in name for name in names)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Runtime widgets and validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSharedWidgets:
|
|
def test_the_runtime_widgets_are_present_and_follow_the_model_specific_ones(self) -> None:
|
|
info = ChatterboxMTLTTSNode.GET_NODE_INFO_V1()
|
|
required = list(info["input_order"]["required"])
|
|
assert required[-4:] == [
|
|
"device",
|
|
"dtype",
|
|
"attention_mode",
|
|
"crash_protection_template",
|
|
]
|
|
assert list(info["input"]["optional"]) == ["audio_prompt"], (
|
|
"only the voice reference is a socket; every other input is a widget"
|
|
)
|
|
|
|
def test_auto_is_the_last_device_option(self) -> None:
|
|
options = ChatterboxMTLTTSNode.GET_NODE_INFO_V1()["input"]["required"][
|
|
"device"
|
|
][1]["options"]
|
|
assert options[-1] == "auto"
|
|
|
|
def test_the_crash_protection_default_is_the_documented_one(self) -> None:
|
|
config = ChatterboxMTLTTSNode.GET_NODE_INFO_V1()["input"]["required"][
|
|
"crash_protection_template"
|
|
]
|
|
assert config[1]["default"] == CRASH_PROTECTION_TEMPLATE
|
|
assert "{seg}" in config[1]["tooltip"]
|
|
|
|
@pytest.mark.parametrize(
|
|
("dtype", "attention", "accepted"),
|
|
[("fp32", "sage", False), ("auto", "sdpa", True), ("bf16", "sage", True)],
|
|
)
|
|
def test_the_shared_dtype_attention_cross_check_applies(
|
|
self, dtype: str, attention: str, accepted: bool
|
|
) -> None:
|
|
result = ChatterboxMTLTTSNode.validate_inputs(
|
|
dtype=dtype, attention_mode=attention
|
|
)
|
|
assert (result is True) is accepted
|