Files
wildminder-ComfyUI-Chatterbox/modules/model_discovery.py
T
WildAi 2b1cf209d6 feat(loader): dedupe shared weights across packs
- ve/s3gen/s3gen_v3 safetensors + grapheme tokenizer are byte-identical
  across every repo that ships them (sha256-verified): fetched at most
  once into a reserved _shared store, hardlinked into each pack
- downloads carry pack-unique files only (allow_patterns); legacy
  full-repo folders satisfy the store before anything is fetched
- vendored MTL loads ve/s3gen from either serialization, safetensors
  first; upstream's strict load refused every SLP pack (P-036)
- .pt twins (s3gen.pt, s3gen_v3.pt, t3_cfg.pt, ve.pt) are never fetched
2026-10-07 13:22:03 +03:00

187 lines
7.1 KiB
Python

"""Cached discovery of the Chatterbox model packs on disk.
The pack combo on the TTS and VC nodes is a dynamic list: ComfyUI rebuilds
every node's schema whenever the frontend refreshes, and each rebuild used to
``listdir`` the models root again. This module exists so that walk happens
once per change and not once per widget render.
The cache key is the models root's ``st_mtime_ns`` (plus its inode, so a
directory replaced by a fresh one at the same path is not mistaken for the
old one). Any change to the root's entries updates that timestamp, so the
check is a single ``stat`` -- far cheaper than the ``scandir`` it guards.
Ported from the pack scanning in ``ComfyUI-VoxCPM/modules/api_routes.py`` and
the deleted V1 ``chatterbox_handler.py`` (plan S4.7), with the cache added.
This module never imports the ComfyUI node-API package, ``comfy.*`` or ``server``.
``folder_paths`` is imported lazily, inside :func:`get_models_root`, so the
module works without ComfyUI running.
"""
from __future__ import annotations
import logging
import os
from typing import Dict, List, Optional, Tuple
from .constants import CHATTERBOX_MODEL_SUBDIR, DEFAULT_MODEL_PACK_NAME
__all__ = [
"get_models_root",
"get_chatterbox_model_pack_names",
"get_model_pack_path",
"get_model_pack_entries",
"invalidate_cache",
]
# Cache state. ``_packs`` is None before the first scan. The key is
# (st_mtime_ns, st_ino) of the models root; a missing root keys on None, which
# is stable, so a missing directory is also cached rather than re-probed.
_cache: Dict[str, object] = {"key": "unset", "packs": None}
def get_models_root() -> str:
"""``<models>/tts/chatterbox``, resolved lazily.
Falls back to a relative ``models/tts/chatterbox`` when ``folder_paths`` is
unavailable, so the module imports and runs outside ComfyUI.
"""
try:
import folder_paths
except ImportError:
logging.debug(
"[Chatterbox TTS] folder_paths is not importable; resolving the "
"Chatterbox models root relative to the working directory"
)
return os.path.join("models", CHATTERBOX_MODEL_SUBDIR)
try:
tts_paths = folder_paths.get_folder_paths("tts")
except Exception as exc: # noqa: BLE001 -- an unregistered folder is normal
logging.debug("[Chatterbox TTS] No 'tts' folder registered with ComfyUI: %s", exc)
tts_paths = None
if tts_paths:
return os.path.join(tts_paths[0], os.path.basename(CHATTERBOX_MODEL_SUBDIR))
try:
return os.path.join(folder_paths.models_dir, CHATTERBOX_MODEL_SUBDIR)
except Exception as exc: # noqa: BLE001 -- a broken ComfyUI must not break the schema
logging.debug("[Chatterbox TTS] Could not resolve models_dir: %s", exc)
return os.path.join("models", CHATTERBOX_MODEL_SUBDIR)
def _root_key(root: str) -> Optional[Tuple[int, int]]:
"""``(st_mtime_ns, st_ino)`` of ``root``, or ``None`` when it is absent."""
try:
stat_result = os.stat(root)
except OSError:
return None
return (stat_result.st_mtime_ns, stat_result.st_ino)
def _scan(root: str) -> List[Tuple[str, str]]:
"""Every subdirectory of ``root``, as sorted ``(name, path)`` pairs.
Uses ``os.scandir`` rather than ``os.listdir`` so the directory is read
once and each entry's type comes from the readdir data already in hand.
Every subdirectory counts as a pack: a partially downloaded one must stay
selectable so the download can finish it. The reserved shared-weight
store (``SHARED_ASSET_DIR_NAME``) is not a pack and never appears.
"""
from .model_info import SHARED_ASSET_DIR_NAME
packs: List[Tuple[str, str]] = []
try:
with os.scandir(root) as entries:
for entry in entries:
try:
if entry.name == SHARED_ASSET_DIR_NAME:
continue
if entry.is_dir():
packs.append((entry.name, entry.path))
except OSError as exc:
# A pack deleted mid-scan is not a reason to lose the rest.
logging.debug(
"[Chatterbox TTS] Skipping '%s' during the pack scan: %s",
entry.name,
exc,
)
except OSError as exc:
logging.debug("[Chatterbox TTS] Could not scan '%s': %s", root, exc)
return []
# Sort by name, then hoist the default pack so a freshly saved workflow
# that carries no widget value lands on the official voice rather than
# whatever sorts first alphabetically.
packs.sort(key=lambda pair: pair[0])
for index, (name, _path) in enumerate(packs):
if name == DEFAULT_MODEL_PACK_NAME:
default_pack = packs.pop(index)
packs.insert(0, default_pack)
break
return packs
def get_model_pack_entries() -> List[Tuple[str, str]]:
"""``(name, path)`` for every pack, served from cache when unchanged.
A Turbo-family folder carrying both t3 checkpoints is listed twice -- once
under its own name, once with the nano suffix -- because the dropdown
entry, not the folder, picks the weights. The rule (and the suffix) live
in :func:`modules.model_info.pack_dir_entries`; this pass only borrows the
names, and skips a synthesized name that a real directory already owns.
"""
from .model_info import pack_dir_entries
root = get_models_root()
key = _root_key(root)
if _cache["packs"] is None or _cache["key"] != key:
_cache["packs"] = _scan(root)
_cache["key"] = key
logging.debug(
"[Chatterbox TTS] Scanned %d Chatterbox pack(s) under '%s'",
len(_cache["packs"]), root,
)
packs = list(_cache["packs"]) # type: ignore[arg-type]
taken = {name for name, _path in packs}
offered: List[Tuple[str, str]] = []
for name, path in packs:
offered.append((name, path))
for entry_name, _architecture in pack_dir_entries(path):
if entry_name != name and entry_name not in taken:
offered.append((entry_name, path))
return offered
def get_chatterbox_model_pack_names() -> List[str]:
"""Every selectable pack name, default pack first.
Never returns an empty list: when nothing is on disk the default pack is
still offered, because selecting it is what triggers the download.
"""
names = [name for name, _path in get_model_pack_entries()]
if not names:
return [DEFAULT_MODEL_PACK_NAME]
return names
def get_model_pack_path(model_pack_name: Optional[str]) -> Optional[str]:
"""Absolute path of ``model_pack_name``, or ``None`` for a blank name.
``None`` for empty input is deliberate: a saved workflow can carry an
empty combo value, and the loader turns ``None`` into a clear message
instead of resolving to the models root and loading the wrong thing.
"""
if not model_pack_name:
return None
return os.path.join(get_models_root(), model_pack_name)
def invalidate_cache() -> None:
"""Force the next scan to hit the filesystem."""
_cache["key"] = "unset"
_cache["packs"] = None