chatterbox-turbo-nano -> chatterbox-nano. The old name stays an accepted alias so saved workflows keep working.
718 lines
30 KiB
Python
718 lines
30 KiB
Python
"""Chatterbox model registry, filesystem discovery and feature flags.
|
|
|
|
Ported from ``ComfyUI-VoxCPM/modules/model_info.py`` (plan S4.6), keeping its
|
|
shape -- a static ``MODEL_CONFIGS`` table, an ``AVAILABLE_*`` registry with
|
|
``type``/``repo_id``/``architecture``/``sample_rate``/``size_gb`` metadata, and
|
|
``read_model_config`` / ``scan_*`` helpers -- and swapping the contents over to
|
|
the Chatterbox model family.
|
|
|
|
Two deliberate differences from the borrowed file:
|
|
|
|
* **Detection is file-based, not ``architecture``-based.** VoxCPM models carry
|
|
a ``config.json`` with an ``architecture`` field; Chatterbox packs do not --
|
|
a pack is a tokenizer plus safetensors blobs. So
|
|
:func:`is_chatterbox_model_dir` looks for the tokenizer and the weights.
|
|
* **Local packs are registered, not baked in.** The registry starts with the
|
|
official entries and gains local ones through :func:`refresh_local_models`,
|
|
which delegates the filesystem walk to
|
|
:mod:`modules.model_discovery` so there is exactly one cached scanner.
|
|
|
|
Nothing here imports the ComfyUI node-API package, ``comfy.*``, ``folder_paths`` or ``server``
|
|
at module scope, and no probe below ever imports the model stack: importing
|
|
``chatterbox.tts_turbo`` would drag in torch, librosa and transformers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import logging
|
|
import os
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from .constants import CHATTERBOX_FILES_TO_DOWNLOAD, CHATTERBOX_REPO_ID
|
|
|
|
|
|
__all__ = [
|
|
"MODEL_CONFIGS",
|
|
"AVAILABLE_CHATTERBOX_MODELS",
|
|
"VALID_CHATTERBOX_ARCHITECTURES",
|
|
"CHATTERBOX_WEIGHT_FILES",
|
|
"TEXT_NORMALIZATION_AVAILABLE",
|
|
"TURBO_AVAILABLE",
|
|
"read_model_config",
|
|
"describe_model_dir",
|
|
"infer_pack_architecture",
|
|
"pack_dir_entries",
|
|
"official_pack_names",
|
|
"PACK_ALIASES",
|
|
"canonical_pack_name",
|
|
"NANO_ENTRY_SUFFIX",
|
|
"is_chatterbox_model_dir",
|
|
"scan_chatterbox_models",
|
|
"scan_custom_model_path",
|
|
"register_local_model",
|
|
"unregister_local_model",
|
|
"refresh_local_models",
|
|
"get_available_models",
|
|
"refresh_feature_flags",
|
|
]
|
|
|
|
# ── Official model configurations ─────────────────────────────────────
|
|
#
|
|
# ``repo_id``/``architecture``/``sample_rate``/``size_gb`` are what the
|
|
# download UI renders and what the loader keys off. ``files`` is the pack's file
|
|
# list, used when downloading that specific model rather than the default pack.
|
|
#
|
|
# Sample rates are not a guess: every Chatterbox model synthesises through
|
|
# S3Gen at 24 kHz (``src/chatterbox/models/s3gen/const.py::S3GEN_SR``).
|
|
|
|
# The multilingual family (and the Single Language Pack finetunes) ship the
|
|
# grapheme map the vendored ``MTLTokenizer`` loads instead of a fast tokenizer.
|
|
MTL_TOKENIZER_FILE = "grapheme_mtl_merged_expanded_v1.json"
|
|
|
|
# ── Shared assets ─────────────────────────────────────────────────────
|
|
# Byte-identical across every repo that ships them (verified 2026-10-07 by
|
|
# sha256 and HF LFS OIDs), so they are fetched at most once into the
|
|
# reserved `_shared` store and hardlinked into each pack that needs them.
|
|
# Downloads never fetch them into a pack directly.
|
|
SHARED_ASSET_DIR_NAME = "_shared"
|
|
SHARED_ASSET_FILES = frozenset(
|
|
{
|
|
"ve.safetensors",
|
|
"s3gen.safetensors",
|
|
"s3gen_v3.safetensors",
|
|
MTL_TOKENIZER_FILE,
|
|
}
|
|
)
|
|
|
|
|
|
MODEL_CONFIGS: Dict[str, Dict[str, Any]] = {
|
|
# The pack a saved workflow names by default. Monolingual English, voice
|
|
# cloning, the source of every other official pack.
|
|
"resembleai_default_voice": {
|
|
"repo_id": CHATTERBOX_REPO_ID,
|
|
"architecture": "chatterbox_tts",
|
|
"sample_rate": 24000,
|
|
"size_gb": 2.7,
|
|
"files": list(CHATTERBOX_FILES_TO_DOWNLOAD),
|
|
"description": "Original Chatterbox, English, voice cloning.",
|
|
},
|
|
# The multilingual family: same repo, same voice encoder, different T3
|
|
# weights. v2 is upstream's default; v3 is the newer, more stable one.
|
|
#
|
|
# These three are **checkpoints, not packs**: they are T3 variants that
|
|
# live inside the classic repository next to the default pack's weights,
|
|
# and the multilingual loader picks one of them out of a pack directory
|
|
# rather than downloading a directory of its own. So they are never
|
|
# offered in the pack dropdown -- ``is_pack: False`` says so, and
|
|
# :func:`official_pack_names` skips them. Every other entry is a pack.
|
|
"T3_v1": {
|
|
"repo_id": CHATTERBOX_REPO_ID,
|
|
"is_pack": False,
|
|
"architecture": "chatterbox_multilingual",
|
|
"sample_rate": 24000,
|
|
"size_gb": 2.7,
|
|
"files": ["t3_cfg.safetensors", "ve.safetensors", "s3gen.safetensors", MTL_TOKENIZER_FILE, "conds.pt"],
|
|
"description": "Original single-language T3 weights, used by the multilingual loader as its v1.",
|
|
},
|
|
"T3_v2": {
|
|
"repo_id": CHATTERBOX_REPO_ID,
|
|
"is_pack": False,
|
|
"architecture": "chatterbox_multilingual",
|
|
"sample_rate": 24000,
|
|
"size_gb": 2.7,
|
|
"files": ["t3_mtl23ls_v2.safetensors", "ve.safetensors", "s3gen.safetensors", MTL_TOKENIZER_FILE, "conds.pt"],
|
|
"description": "23-language multilingual T3 (upstream default).",
|
|
},
|
|
"T3_v3": {
|
|
"repo_id": CHATTERBOX_REPO_ID,
|
|
"is_pack": False,
|
|
"architecture": "chatterbox_multilingual",
|
|
"sample_rate": 24000,
|
|
"size_gb": 2.7,
|
|
"files": ["t3_mtl23ls_v3.safetensors", "ve.safetensors", "s3gen.safetensors", MTL_TOKENIZER_FILE, "conds.pt"],
|
|
"description": "23-language multilingual T3, newer checkpoint: better similarity, fewer hallucinations.",
|
|
},
|
|
# The Single Language Pack: six dedicated finetunes of the multilingual
|
|
# V3 family, one per priority language (2026-06 release). Each ships its
|
|
# own per-language t3 checkpoint ("t3_checkpoint"); the s3gen_v3 decoder,
|
|
# grapheme tokenizer and voice encoder are byte-identical across all six
|
|
# and come from the shared store (SHARED_ASSET_FILES). Only zh-cmn
|
|
# bundles a conds.pt (built-in voice); the rest have none, so a reference
|
|
# clip is required.
|
|
"chatterbox-multilingual-zh-cmn": {
|
|
"repo_id": "ResembleAI/Chatterbox-Multilingual-zh-cmn",
|
|
"architecture": "chatterbox_multilingual",
|
|
"sample_rate": 24000,
|
|
"size_gb": 2.8,
|
|
"files": ["t3_zh_cmn.safetensors", "s3gen_v3.safetensors", MTL_TOKENIZER_FILE, "conds.pt"],
|
|
"t3_checkpoint": "t3_zh_cmn.safetensors",
|
|
"language": "zh",
|
|
"description": "Single Language Pack: Chinese (zh). Ships a built-in voice.",
|
|
},
|
|
"chatterbox-multilingual-es-mx-latam": {
|
|
"repo_id": "ResembleAI/Chatterbox-Multilingual-es-mx-latam",
|
|
"architecture": "chatterbox_multilingual",
|
|
"sample_rate": 24000,
|
|
"size_gb": 2.6,
|
|
"files": ["t3_es_mx_latam.safetensors", "s3gen_v3.safetensors", MTL_TOKENIZER_FILE],
|
|
"t3_checkpoint": "t3_es_mx_latam.safetensors",
|
|
"language": "es",
|
|
"description": "Single Language Pack: Latin-American Spanish (es). Needs a reference clip.",
|
|
},
|
|
"chatterbox-multilingual-es-es": {
|
|
"repo_id": "ResembleAI/Chatterbox-Multilingual-es-es",
|
|
"architecture": "chatterbox_multilingual",
|
|
"sample_rate": 24000,
|
|
"size_gb": 2.6,
|
|
"files": ["t3_es_es.safetensors", "s3gen_v3.safetensors", MTL_TOKENIZER_FILE],
|
|
"t3_checkpoint": "t3_es_es.safetensors",
|
|
"language": "es",
|
|
"description": "Single Language Pack: Spain Spanish (es). Needs a reference clip.",
|
|
},
|
|
"chatterbox-multilingual-pt-br": {
|
|
"repo_id": "ResembleAI/Chatterbox-Multilingual-pt-br",
|
|
"architecture": "chatterbox_multilingual",
|
|
"sample_rate": 24000,
|
|
"size_gb": 2.6,
|
|
"files": ["t3_pt_br.safetensors", "s3gen_v3.safetensors", MTL_TOKENIZER_FILE],
|
|
"t3_checkpoint": "t3_pt_br.safetensors",
|
|
"language": "pt",
|
|
"description": "Single Language Pack: Brazilian Portuguese (pt). Needs a reference clip.",
|
|
},
|
|
"chatterbox-multilingual-pt-pt": {
|
|
"repo_id": "ResembleAI/Chatterbox-Multilingual-pt-pt",
|
|
"architecture": "chatterbox_multilingual",
|
|
"sample_rate": 24000,
|
|
"size_gb": 2.6,
|
|
"files": ["t3_pt_pt.safetensors", "s3gen_v3.safetensors", MTL_TOKENIZER_FILE],
|
|
"t3_checkpoint": "t3_pt_pt.safetensors",
|
|
"language": "pt",
|
|
"description": "Single Language Pack: Portugal Portuguese (pt). Needs a reference clip.",
|
|
},
|
|
"chatterbox-multilingual-hi": {
|
|
"repo_id": "ResembleAI/Chatterbox-Multilingual-hi",
|
|
"architecture": "chatterbox_multilingual",
|
|
"sample_rate": 24000,
|
|
"size_gb": 2.6,
|
|
"files": ["t3_hi.safetensors", "s3gen_v3.safetensors", MTL_TOKENIZER_FILE],
|
|
"t3_checkpoint": "t3_hi.safetensors",
|
|
"language": "hi",
|
|
"description": "Single Language Pack: Hindi (hi). Needs a reference clip.",
|
|
},
|
|
# The Turbo family, in ONE folder. Turbo is the family's name upstream
|
|
# (ResembleAI/chatterbox-turbo); Nano is its smaller member
|
|
# (ResembleAI/chatterbox-nano). The two repos publish the same file list
|
|
# apart from the t3 checkpoint, so both members live in the same
|
|
# ``chatterbox-turbo`` directory and the dropdown entry -- not the
|
|
# folder -- picks the weights. ``folder`` is that download target; it
|
|
# differs from the entry name for Nano.
|
|
#
|
|
# Neither repo ships a ``tokenizer.json``: both publish the GPT2-style
|
|
# HuggingFace layout (``vocab.json`` + ``merges.txt`` plus the config
|
|
# files), which is what the lists below ask for. Declaring a file
|
|
# upstream does not publish makes the pack report itself as never
|
|
# downloaded, and the missing-file message name a file that is not
|
|
# supposed to be there.
|
|
"chatterbox-turbo": {
|
|
"repo_id": "ResembleAI/chatterbox-turbo",
|
|
"folder": "chatterbox-turbo",
|
|
"architecture": "chatterbox_turbo",
|
|
"sample_rate": 24000,
|
|
"size_gb": 1.4,
|
|
"files": ["t3_turbo_v1.safetensors", "t3_turbo_v1.yaml", "ve.safetensors", "s3gen.safetensors", "s3gen_meanflow.safetensors", "vocab.json", "merges.txt", "tokenizer_config.json", "added_tokens.json", "special_tokens_map.json", "conds.pt"],
|
|
"description": "350M English model with paralinguistic tags; lower VRAM.",
|
|
},
|
|
# A quarter of Turbo again; runs on CPU at realtime. Shares the Turbo
|
|
# folder: only the t3 checkpoint is Nano's own.
|
|
"chatterbox-nano": {
|
|
"repo_id": "ResembleAI/chatterbox-nano",
|
|
"folder": "chatterbox-turbo",
|
|
"architecture": "chatterbox_nano",
|
|
"sample_rate": 24000,
|
|
"size_gb": 2.9,
|
|
"files": [
|
|
"t3_nano_v1.safetensors",
|
|
"t3_nano_v1.yaml",
|
|
"ve.safetensors",
|
|
"s3gen.safetensors",
|
|
"s3gen_meanflow.safetensors",
|
|
"vocab.json",
|
|
"merges.txt",
|
|
"tokenizer_config.json",
|
|
"added_tokens.json",
|
|
"special_tokens_map.json",
|
|
"conds.pt",
|
|
],
|
|
"description": "Turbo-family 110M model for CPU and tight latency budgets. "
|
|
"Downloads into the shared chatterbox-turbo folder; ships an HF tokenizer "
|
|
"(vocab.json + merges.txt), not a tokenizer.json.",
|
|
},
|
|
# Block-diffusion member (Fast-dLLM-style masked decoder over the same
|
|
# S3Gen / voice-encoder stack). Not runnable by the vendored 0.1.7 code:
|
|
# the sampler is vendored at src/chatterbox_flash/ and imports S3Gen /
|
|
# VoiceEncoder / T3Cond from the vendored tree. No conds.pt ships in the
|
|
# repo, so a reference clip is required.
|
|
"chatterbox-flash": {
|
|
"repo_id": "ResembleAI/chatterbox-flash",
|
|
"folder": "chatterbox-flash",
|
|
"architecture": "chatterbox_flash",
|
|
"sample_rate": 24000,
|
|
"size_gb": 3.1,
|
|
"files": ["t3_flash.safetensors", "s3gen.safetensors", "ve.safetensors", "tokenizer.json"],
|
|
# Flash's s3gen.safetensors is a DIFFERENT decoder than the one the
|
|
# other families share despite the same filename -- it must never be
|
|
# resolved from, or linked into, the shared store.
|
|
"unique_files": ["s3gen.safetensors"],
|
|
"description": "Block-diffusion Chatterbox: fast parallel decoding. Needs a "
|
|
"reference audio clip.",
|
|
},
|
|
}
|
|
|
|
# Official entries, tagged so the API and the loader can tell them from local
|
|
# packs. Local entries are added by refresh_local_models().
|
|
AVAILABLE_CHATTERBOX_MODELS: Dict[str, Dict[str, Any]] = {
|
|
name: {"type": "official", **config} for name, config in MODEL_CONFIGS.items()
|
|
}
|
|
|
|
|
|
# ── Pack names ───────────────────────────────────────────────────────
|
|
#
|
|
# Upstream renamed a pack; this repository keeps the old spelling alive as
|
|
# an alias, because a saved workflow carries the name it was saved with and
|
|
# has to keep working. An alias is accepted everywhere a pack name is read
|
|
# and offered nowhere: the dropdown lists the current name only.
|
|
PACK_ALIASES: Dict[str, str] = {"chatterbox-turbo-nano": "chatterbox-nano"}
|
|
|
|
|
|
def canonical_pack_name(name: str) -> str:
|
|
"""The current name for ``name`` -- the alias table's answer, or the name.
|
|
|
|
Call it before a name reaches the registry, a path or the offered list:
|
|
an alias must never be looked up, resolved or offered as if it were a
|
|
pack of its own.
|
|
"""
|
|
return PACK_ALIASES.get(name, name)
|
|
|
|
|
|
def official_pack_names() -> List[str]:
|
|
"""Every registry entry that is a downloadable pack, in registry order.
|
|
|
|
This is the answer to "what can the user download?", and it is read from
|
|
the registry rather than from a second, hand-kept list -- so a pack added
|
|
to :data:`MODEL_CONFIGS` is offered by the dropdown without anyone
|
|
remembering to list it in a second place.
|
|
|
|
An entry is a pack unless it says otherwise: ``is_pack: False`` marks the
|
|
entries that are **not** directories of their own. The ``T3_v*`` entries
|
|
are the case in point -- T3 checkpoints that live inside the classic
|
|
repository and are picked out of a pack directory by the multilingual
|
|
loader, never downloaded as a pack. A missing ``is_pack`` key means "is a
|
|
pack", so a new entry is downloadable by default.
|
|
|
|
Only entries with a ``repo_id`` are listed: a pack without a repository
|
|
cannot be fetched, and offering it would be a promise the loader cannot
|
|
keep.
|
|
"""
|
|
return [
|
|
name
|
|
for name, config in MODEL_CONFIGS.items()
|
|
if config.get("is_pack", True) and config.get("repo_id")
|
|
]
|
|
|
|
|
|
# ── Recognised architectures ─────────────────────────────────────────
|
|
|
|
VALID_CHATTERBOX_ARCHITECTURES = (
|
|
"chatterbox",
|
|
"chatterbox_tts",
|
|
"chatterbox_multilingual",
|
|
"chatterbox_turbo",
|
|
"chatterbox_nano",
|
|
"chatterbox_flash",
|
|
)
|
|
|
|
# A file from this set makes a directory a model. ``conds.pt`` is deliberately
|
|
# absent: it is the smallest file in a pack and alone proves nothing.
|
|
CHATTERBOX_WEIGHT_FILES = (
|
|
"t3_cfg.safetensors",
|
|
"t3_mtl23ls_v2.safetensors",
|
|
"t3_mtl23ls_v3.safetensors",
|
|
"t3_turbo_v1.safetensors",
|
|
"t3_nano_v1.safetensors",
|
|
"t3_flash.safetensors",
|
|
"ve.safetensors",
|
|
"s3gen.safetensors",
|
|
"s3gen_v3.safetensors", # Single Language Pack decoder (P-036)
|
|
"s3gen_v3.pt", # legacy serialization, still loaded when present
|
|
"s3gen.pt",
|
|
)
|
|
|
|
# Every Chatterbox entry point needs a tokenizer to build its T3 conditions.
|
|
CHATTERBOX_TOKENIZER_FILE = "tokenizer.json"
|
|
|
|
|
|
|
|
# ── Feature flags ────────────────────────────────────────────────────
|
|
|
|
def _probe_text_normalization() -> bool:
|
|
"""True when at least one CJK segmenter is installed.
|
|
|
|
Chatterbox does not ship a normaliser; it delegates word segmentation for
|
|
Japanese and Chinese to optional packages. The nodes disable text
|
|
normalisation when neither is present, and the frontend greys the widget
|
|
out from ``config["normalization_available"]``.
|
|
"""
|
|
for module_name in ("pykakasi", "spacy_pkuseg"):
|
|
try:
|
|
if importlib.util.find_spec(module_name) is not None:
|
|
return True
|
|
except (ImportError, ValueError, AttributeError):
|
|
# ValueError: a parent package is mocked and has no __spec__.
|
|
logging.debug("[Chatterbox TTS] Could not probe optional package '%s'", module_name)
|
|
return False
|
|
|
|
|
|
def _probe_turbo() -> bool:
|
|
"""True when the vendored source ships the Turbo entry point.
|
|
|
|
Checked on disk rather than by import: the module imports the full model
|
|
stack, and a presence check must cost nothing at node-load time.
|
|
"""
|
|
package_dir = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
"src",
|
|
"chatterbox",
|
|
)
|
|
return os.path.isfile(os.path.join(package_dir, "tts_turbo.py"))
|
|
|
|
|
|
def refresh_feature_flags() -> None:
|
|
"""Recompute the feature flags in this module's namespace."""
|
|
global TEXT_NORMALIZATION_AVAILABLE, TURBO_AVAILABLE
|
|
|
|
TEXT_NORMALIZATION_AVAILABLE = _probe_text_normalization()
|
|
TURBO_AVAILABLE = _probe_turbo()
|
|
logging.debug(
|
|
"[Chatterbox TTS] Chatterbox feature flags: normalization=%s turbo=%s",
|
|
TEXT_NORMALIZATION_AVAILABLE, TURBO_AVAILABLE,
|
|
)
|
|
|
|
|
|
TEXT_NORMALIZATION_AVAILABLE: bool = False
|
|
TURBO_AVAILABLE: bool = False
|
|
refresh_feature_flags()
|
|
|
|
|
|
# ── Model scanning ───────────────────────────────────────────────────
|
|
|
|
def read_model_config(model_dir: str) -> Optional[dict]:
|
|
"""Parse ``<model_dir>/config.json``.
|
|
|
|
Returns ``None`` when the file is absent or unreadable -- Chatterbox packs
|
|
normally ship no ``config.json`` at all, so ``None`` is the common answer
|
|
and not a failure. Use :func:`describe_model_dir` to characterise a pack
|
|
that has none.
|
|
"""
|
|
config_path = os.path.join(model_dir, "config.json")
|
|
if not os.path.isfile(config_path):
|
|
return None
|
|
try:
|
|
with open(config_path, "r", encoding="utf-8") as handle:
|
|
return json.load(handle)
|
|
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
|
|
logging.debug("[Chatterbox TTS] Could not read config.json in %s: %s", model_dir, exc)
|
|
return None
|
|
|
|
|
|
def _list_entries(path: str) -> List[str]:
|
|
try:
|
|
return sorted(os.listdir(path))
|
|
except OSError as exc:
|
|
logging.debug("[Chatterbox TTS] Could not list '%s': %s", path, exc)
|
|
return []
|
|
|
|
|
|
def _infer_architecture(entries: List[str]) -> Optional[str]:
|
|
"""Guess a pack's architecture from the weights it carries."""
|
|
if "t3_nano_v1.safetensors" in entries:
|
|
return "chatterbox_nano"
|
|
if "t3_turbo_v1.safetensors" in entries:
|
|
return "chatterbox_turbo"
|
|
if "t3_mtl23ls_v2.safetensors" in entries or "t3_mtl23ls_v3.safetensors" in entries:
|
|
return "chatterbox_multilingual"
|
|
# Single Language Pack finetunes (and community refinetunes of them) keep
|
|
# the multilingual tokenizer and s3gen but rename the t3 blob per language
|
|
# (t3_zh_cmn.safetensors, t3_es_mx_latam.safetensors, ...). The grapheme
|
|
# map is the family marker no other architecture carries.
|
|
if MTL_TOKENIZER_FILE in entries:
|
|
return "chatterbox_multilingual"
|
|
if "t3_flash.safetensors" in entries:
|
|
return "chatterbox_flash"
|
|
if "t3_cfg.safetensors" in entries:
|
|
return "chatterbox_tts"
|
|
return None
|
|
|
|
|
|
def infer_pack_architecture(pack_dir: str) -> Optional[str]:
|
|
"""A pack directory's architecture from the t3 checkpoint it carries.
|
|
|
|
``"chatterbox_tts"`` / ``"chatterbox_turbo"`` / ``"chatterbox_nano"`` /
|
|
``"chatterbox_multilingual"``, or ``None`` when the directory is missing
|
|
or holds no recognised t3 checkpoint -- a community rename or a half
|
|
download answers ``None`` and the caller falls back to whatever else it
|
|
knows. This is the loader-facing half of :func:`_infer_architecture`: the
|
|
t3 filename is the pack's identity, never the folder it sits in.
|
|
|
|
A folder carrying **both** Turbo-family t3 checkpoints answers
|
|
``chatterbox_nano`` here (the same nano-first rule
|
|
:func:`_infer_architecture` applies) -- callers that need to tell the two
|
|
apart must use :func:`pack_dir_entries` instead.
|
|
"""
|
|
if not pack_dir or not os.path.isdir(pack_dir):
|
|
return None
|
|
return _infer_architecture(_list_entries(pack_dir))
|
|
|
|
|
|
NANO_ENTRY_SUFFIX = "-nano"
|
|
|
|
|
|
def pack_dir_entries(pack_dir: str) -> List[tuple]:
|
|
"""``(entry_name, architecture)`` pairs a pack directory offers.
|
|
|
|
One directory is normally one pack named after itself. The exception is
|
|
the Turbo family: the two repos share every weight except the t3
|
|
checkpoint, so a folder holding **both** ``t3_turbo_v1.safetensors`` and
|
|
``t3_nano_v1.safetensors`` serves both members and is offered twice --
|
|
under its own name (Turbo) and under the canonical Nano name. The
|
|
synthesized name is the user's handle for the variant; the folder stays
|
|
a single copy of the shared weights.
|
|
|
|
Only the synthesized name is canonicalised: it is a name this module
|
|
mints, not one a directory owns. A directory keeps the name it has --
|
|
rewriting it would break the name/path pairing the loader resolves by.
|
|
"""
|
|
if not pack_dir or not os.path.isdir(pack_dir):
|
|
return []
|
|
base = os.path.basename(os.path.normpath(pack_dir))
|
|
entries = set(_list_entries(pack_dir))
|
|
has_turbo = "t3_turbo_v1.safetensors" in entries
|
|
has_nano = "t3_nano_v1.safetensors" in entries
|
|
if has_turbo and has_nano:
|
|
return [
|
|
(base, "chatterbox_turbo"),
|
|
(canonical_pack_name(base + NANO_ENTRY_SUFFIX), "chatterbox_nano"),
|
|
]
|
|
return [(base, _infer_architecture(sorted(entries)) or "unknown")]
|
|
|
|
|
|
def describe_model_dir(model_dir: str) -> Dict[str, Any]:
|
|
"""Summarise a pack directory.
|
|
|
|
Prefers an explicit ``config.json`` (community checkpoints carry one), and
|
|
otherwise infers the architecture from the weight filenames. Returns a dict
|
|
with ``name``, ``architecture`` and ``path``; ``architecture`` is
|
|
``"unknown"`` when neither source says anything.
|
|
"""
|
|
entries = _list_entries(model_dir)
|
|
config = read_model_config(model_dir) or {}
|
|
architecture = config.get("architecture") or _infer_architecture(entries) or "unknown"
|
|
description: Dict[str, Any] = {
|
|
"name": os.path.basename(os.path.normpath(model_dir)),
|
|
"architecture": architecture,
|
|
"path": model_dir,
|
|
}
|
|
if config:
|
|
description["config"] = config
|
|
return description
|
|
|
|
|
|
def is_chatterbox_model_dir(path: str) -> bool:
|
|
"""True when ``path`` is a usable Chatterbox pack directory.
|
|
|
|
Requires both halves of a pack: the tokenizer that builds the T3
|
|
conditions, and at least one weight blob. Either alone is not a model -- an
|
|
empty directory, a partially cancelled download, or an unrelated
|
|
HuggingFace checkpoint all fail one of the two checks.
|
|
|
|
Any ``.safetensors`` counts as a weight, not only the names in
|
|
:data:`CHATTERBOX_WEIGHT_FILES`: community checkpoints rename their blobs,
|
|
and refusing to recognise them would hide a model the loader can actually
|
|
load.
|
|
"""
|
|
if not path or not os.path.isdir(path):
|
|
return False
|
|
|
|
config = read_model_config(path)
|
|
if config is not None:
|
|
architecture = config.get("architecture", "")
|
|
if architecture and architecture not in VALID_CHATTERBOX_ARCHITECTURES:
|
|
# An explicit config.json naming a foreign architecture settles it.
|
|
return False
|
|
|
|
entries = set(_list_entries(path))
|
|
if not _has_usable_tokenizer(entries):
|
|
return False
|
|
return any(weight in entries for weight in CHATTERBOX_WEIGHT_FILES) or any(
|
|
entry.endswith(".safetensors") for entry in entries
|
|
)
|
|
|
|
|
|
def _has_usable_tokenizer(entries: set) -> bool:
|
|
"""``tokenizer.json``, the HF fast pair Nano ships, or the MTL grapheme map.
|
|
|
|
The Nano repo has no ``tokenizer.json``: its tokenizer is the GPT2-style
|
|
HF layout (``vocab.json`` + ``merges.txt``), which ``AutoTokenizer`` reads
|
|
just as well. Demanding ``tokenizer.json`` outright made every Nano pack
|
|
fail the sniff before any loader could look at it. The multilingual family
|
|
(and the Single Language Pack finetunes) ship
|
|
``grapheme_mtl_merged_expanded_v1.json`` instead, which the vendored
|
|
``MTLTokenizer`` loads.
|
|
"""
|
|
if CHATTERBOX_TOKENIZER_FILE in entries:
|
|
return True
|
|
if MTL_TOKENIZER_FILE in entries:
|
|
return True
|
|
return {"vocab.json", "merges.txt"}.issubset(entries)
|
|
|
|
|
|
def scan_chatterbox_models(scan_dir: str) -> List[dict]:
|
|
"""Every valid Chatterbox pack directly under ``scan_dir``.
|
|
|
|
Returns a list of ``{"name", "architecture", "path"}`` dicts, sorted by
|
|
name. A missing directory yields ``[]`` rather than raising: the caller is
|
|
usually a frontend poll that must not 500 because the user has no models
|
|
yet.
|
|
"""
|
|
models: List[dict] = []
|
|
if not scan_dir or not os.path.isdir(scan_dir):
|
|
return models
|
|
|
|
for item in _list_entries(scan_dir):
|
|
item_path = os.path.join(scan_dir, item)
|
|
if not os.path.isdir(item_path):
|
|
continue
|
|
if not is_chatterbox_model_dir(item_path):
|
|
continue
|
|
description = describe_model_dir(item_path)
|
|
models.append(description)
|
|
logging.debug(
|
|
"[Chatterbox TTS] Found Chatterbox pack %s (architecture=%s) at %s",
|
|
item, description["architecture"], item_path,
|
|
)
|
|
|
|
return models
|
|
|
|
|
|
def scan_custom_model_path(path: str) -> List[dict]:
|
|
"""Scan a user-supplied model directory.
|
|
|
|
Warns rather than raises when the path does not exist: this is the answer
|
|
to an interactive "browse for your models" dialog, and a wrong path is the
|
|
user correcting themselves, not a server fault.
|
|
"""
|
|
if not path or not os.path.isdir(path):
|
|
logging.warning("[Chatterbox TTS] Custom model path does not exist: %s", path)
|
|
return []
|
|
|
|
if is_chatterbox_model_dir(path):
|
|
# The user pointed straight at a pack rather than at its parent.
|
|
return [describe_model_dir(path)]
|
|
|
|
return scan_chatterbox_models(path)
|
|
|
|
|
|
# ── Local model registry ─────────────────────────────────────────────
|
|
|
|
def register_local_model(name: str, path: str, architecture: Optional[str] = None) -> None:
|
|
"""Add or replace a local pack in the registry.
|
|
|
|
``architecture`` overrides what the directory would infer on its own: a
|
|
Turbo-family folder serving both members registers twice against the same
|
|
path, and the t3-first sniff cannot tell those two entries apart.
|
|
"""
|
|
description = describe_model_dir(path)
|
|
description["name"] = name
|
|
AVAILABLE_CHATTERBOX_MODELS[name] = {
|
|
"type": "local",
|
|
"repo_id": "",
|
|
"architecture": architecture or description["architecture"],
|
|
"sample_rate": 24000,
|
|
"size_gb": 0.0,
|
|
"path": path,
|
|
}
|
|
logging.debug("[Chatterbox TTS] Registered local Chatterbox pack '%s' at %s", name, path)
|
|
|
|
|
|
def unregister_local_model(name: str) -> bool:
|
|
"""Drop a local pack. Official entries are never removed."""
|
|
entry = AVAILABLE_CHATTERBOX_MODELS.get(name)
|
|
if entry is None or entry.get("type") != "local":
|
|
return False
|
|
del AVAILABLE_CHATTERBOX_MODELS[name]
|
|
return True
|
|
|
|
|
|
def _is_official_variant_of(path: str, entry_name: str) -> bool:
|
|
"""True when ``entry_name`` is the official pack that lives in ``path``.
|
|
|
|
The Turbo family shares one folder, so the scan mints a second entry for
|
|
the folder's other member -- and that member has an official registry
|
|
entry of its own, naming the same folder. Registering the local copy
|
|
would replace that official entry (dropping its ``repo_id`` and its
|
|
download target), and the next sweep would delete the name outright.
|
|
"""
|
|
config = MODEL_CONFIGS.get(entry_name)
|
|
if not config:
|
|
return False
|
|
folder = config.get("folder")
|
|
return bool(folder) and folder == os.path.basename(os.path.normpath(path))
|
|
|
|
|
|
def refresh_local_models() -> List[dict]:
|
|
"""Re-scan the models root and mirror its packs into the registry.
|
|
|
|
The filesystem walk is delegated to :mod:`modules.model_discovery`, which
|
|
caches on the models root's ``st_mtime_ns`` -- so calling this on every
|
|
frontend refresh is cheap. Official entries are preserved; local entries
|
|
for packs that have disappeared are dropped. A Turbo-family folder serving
|
|
both members registers under each of the names
|
|
:func:`pack_dir_entries` offers -- except the member that has an official
|
|
entry of its own for that very folder: there the official entry stays,
|
|
because it is the one that knows the repository and the download target.
|
|
"""
|
|
from . import model_discovery
|
|
|
|
found_paths = {path for _name, path in model_discovery.get_model_pack_entries()}
|
|
current = {
|
|
name for name, entry in AVAILABLE_CHATTERBOX_MODELS.items()
|
|
if entry.get("type") == "local"
|
|
}
|
|
|
|
found_names: set = set()
|
|
local_names: set = set()
|
|
for path in sorted(found_paths):
|
|
base = os.path.basename(os.path.normpath(path))
|
|
for entry_name, architecture in pack_dir_entries(path):
|
|
found_names.add(entry_name)
|
|
if entry_name != base and _is_official_variant_of(path, entry_name):
|
|
# The synthesized name of an official pack that lives in this
|
|
# folder: the official entry already describes it.
|
|
continue
|
|
register_local_model(entry_name, path, architecture=architecture)
|
|
local_names.add(entry_name)
|
|
|
|
for name in current - found_names:
|
|
unregister_local_model(name)
|
|
|
|
return [
|
|
{"name": name, "path": str(AVAILABLE_CHATTERBOX_MODELS[name].get("path", "")), "type": "local"}
|
|
for name in sorted(local_names & set(AVAILABLE_CHATTERBOX_MODELS))
|
|
]
|
|
|
|
|
|
def get_available_models() -> Dict[str, Dict[str, Any]]:
|
|
"""The registry with local packs freshly scanned in."""
|
|
refresh_local_models()
|
|
return AVAILABLE_CHATTERBOX_MODELS |