Files
kat3ri e562c94708 Fix torchaudio.save failing when torchcodec is not installed
Recent torchaudio (>=2.6) defaults torchaudio.save() to the torchcodec
backend, which is not yet available for all CUDA builds (e.g. cu130).

Replace direct torchaudio.save() call with a _save_audio() helper that:
1. Tries torchaudio.save(..., backend='soundfile') first
2. Falls back to scipy.io.wavfile.write
3. Falls back to bare torchaudio.save() as a last resort
2026-05-17 10:56:53 -04:00

429 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
ComfyUI-DramaBox: Custom nodes for DramaBox expressive TTS with voice cloning.
Source: https://github.com/resemble-ai/DramaBox
Models: https://huggingface.co/ResembleAI/Dramabox
This node clones the DramaBox repository on first use and downloads the required
model weights (~17 GB total) from HuggingFace into ComfyUI/models/DramaBox/.
"""
import os
import sys
import subprocess
import tempfile
import logging
import urllib.request
import zipfile
import shutil
from pathlib import Path
import torch
import torchaudio
import folder_paths
logger = logging.getLogger(__name__)
def _save_audio(path: str, waveform: "torch.Tensor", sample_rate: int) -> None:
"""Save audio, falling back gracefully when torchcodec is unavailable.
Recent torchaudio (≥2.6) defaults torchaudio.save() to the torchcodec backend
which may not be installed (especially on bleeding-edge CUDA builds). This
wrapper tries soundfile first, then falls back to the scipy/wave writer.
"""
# Preferred: explicit soundfile backend (no torchcodec dependency)
try:
torchaudio.save(path, waveform, sample_rate, backend="soundfile")
return
except Exception:
pass
# Last resort: scipy wavfile (always available with numpy)
try:
import scipy.io.wavfile as _wavfile
import numpy as _np
data = waveform.numpy()
if data.ndim == 2:
data = data.T # scipy expects (samples, channels)
_wavfile.write(path, sample_rate, data.astype(_np.float32))
return
except Exception:
pass
# Final fallback: let torchaudio pick whatever backend it has
torchaudio.save(path, waveform, sample_rate)
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
NODE_DIR = Path(__file__).parent
DRAMABOX_REPO_DIR = NODE_DIR / "DramaBox"
MODELS_DIR = Path(folder_paths.models_dir) / "DramaBox"
# ---------------------------------------------------------------------------
# Repository bootstrap
# ---------------------------------------------------------------------------
_repo_paths_added = False
def _add_repo_paths():
"""Insert DramaBox source directories into sys.path (idempotent)."""
global _repo_paths_added
if _repo_paths_added:
return
for subdir in ["ltx2", "src"]:
p = str(DRAMABOX_REPO_DIR / subdir)
if p not in sys.path:
sys.path.insert(0, p)
_repo_paths_added = True
def _clone_via_git():
"""Clone with git. Returns True on success."""
try:
result = subprocess.run(
[
"git", "clone", "--depth=1",
"https://github.com/resemble-ai/DramaBox.git",
str(DRAMABOX_REPO_DIR),
],
capture_output=True,
text=True,
)
if result.returncode == 0:
logger.info("[DramaBox] Repository cloned successfully via git.")
return True
logger.warning(f"[DramaBox] git clone returned {result.returncode}: {result.stderr}")
except FileNotFoundError:
logger.warning("[DramaBox] git not found on PATH, falling back to zipball download.")
return False
def _clone_via_zipball():
"""Download GitHub zipball as a fallback when git is unavailable."""
url = "https://github.com/resemble-ai/DramaBox/archive/refs/heads/master.zip"
tmp_zip = NODE_DIR / "_dramabox_tmp.zip"
logger.info(f"[DramaBox] Downloading DramaBox zipball from {url} …")
try:
urllib.request.urlretrieve(url, str(tmp_zip))
with zipfile.ZipFile(str(tmp_zip), "r") as zf:
zf.extractall(str(NODE_DIR))
extracted = NODE_DIR / "DramaBox-master"
extracted.rename(DRAMABOX_REPO_DIR)
logger.info("[DramaBox] Repository extracted successfully.")
return True
except Exception as e:
logger.error(f"[DramaBox] Zipball download failed: {e}")
return False
finally:
if tmp_zip.exists():
tmp_zip.unlink()
def _ensure_repo():
"""Ensure the DramaBox GitHub repository is present and on sys.path."""
if not DRAMABOX_REPO_DIR.exists():
logger.info("[DramaBox] DramaBox repository not found – downloading…")
if not _clone_via_git():
if not _clone_via_zipball():
raise RuntimeError(
"[DramaBox] Could not download the DramaBox repository.\n"
"Please manually clone https://github.com/resemble-ai/DramaBox "
f"into {DRAMABOX_REPO_DIR}"
)
_add_repo_paths()
# ---------------------------------------------------------------------------
# Model download helpers
# ---------------------------------------------------------------------------
def _download_models():
"""Download all required model weights and return paths dict.
All models are stored under ``ComfyUI/models/DramaBox/``:
- dramabox-dit-v1.safetensors (transformer)
- dramabox-audio-components.safetensors (audio components)
- gemma-3-12b-it-bnb-4bit/ (Gemma text encoder directory)
"""
_ensure_repo()
MODELS_DIR.mkdir(parents=True, exist_ok=True)
from huggingface_hub import hf_hub_download, snapshot_download # noqa: PLC0415
logger.info("[DramaBox] Verifying model weights (will download if missing)…")
hf_token = os.environ.get("HF_TOKEN")
# --- DramaBox-unique weights → ComfyUI/models/DramaBox/ ---
dramabox_repo = "ResembleAI/Dramabox"
model_files = {
"transformer": "dramabox-dit-v1.safetensors",
"audio_components": "dramabox-audio-components.safetensors",
}
paths = {}
for name, filename in model_files.items():
local_path = MODELS_DIR / filename
if local_path.exists():
logger.info(f"[DramaBox] {filename} found locally.")
else:
logger.info(f"[DramaBox] Downloading {filename} from {dramabox_repo}…")
hf_hub_download(
repo_id=dramabox_repo,
filename=filename,
local_dir=str(MODELS_DIR),
token=hf_token,
)
logger.info(f"[DramaBox] {filename} downloaded.")
paths[name] = str(local_path)
# --- Gemma text encoder → ComfyUI/models/DramaBox/ ---
gemma_dir = MODELS_DIR / "gemma-3-12b-it-bnb-4bit"
if gemma_dir.exists() and any(gemma_dir.iterdir()):
logger.info("[DramaBox] Gemma encoder found locally.")
else:
logger.info("[DramaBox] Downloading Gemma encoder (unsloth/gemma-3-12b-it-bnb-4bit)…")
snapshot_download(
repo_id="unsloth/gemma-3-12b-it-bnb-4bit",
local_dir=str(gemma_dir),
token=hf_token,
)
logger.info("[DramaBox] Gemma encoder downloaded.")
paths["gemma_root"] = str(gemma_dir)
return paths
# ---------------------------------------------------------------------------
# TTSServer singleton (lazy, loaded on first generate() call)
# ---------------------------------------------------------------------------
_tts_server = None
def _get_server():
"""Return the cached TTSServer, creating and loading it on first call."""
global _tts_server
if _tts_server is not None:
return _tts_server
_ensure_repo()
paths = _download_models()
from inference_server import TTSServer # noqa: PLC0415
device = "cuda" if torch.cuda.is_available() else "cpu"
logger.info(f"[DramaBox] Loading TTSServer on {device} (first run may take several minutes)…")
_tts_server = TTSServer(
checkpoint=paths["transformer"],
full_checkpoint=paths["audio_components"],
gemma_root=paths["gemma_root"],
device=device,
dtype="bf16",
compile_model=False, # torch.compile can be unstable in some setups
bnb_4bit=True, # uses pre-quantised unsloth Gemma weights
)
logger.info("[DramaBox] TTSServer ready.")
return _tts_server
# ---------------------------------------------------------------------------
# ComfyUI node
# ---------------------------------------------------------------------------
class DramaBoxTTS:
"""
DramaBox expressive TTS with voice cloning.
Generates rich, dramatic speech from a structured scene prompt.
An optional voice reference (10+ seconds) clones the speaker timbre.
Prompt format:
<speaker description>, "<dialogue>" <action direction> "<more dialogue>"
Example:
A woman speaks warmly, "Hello, how are you today?" She laughs,
"Hahaha, it is so good to see you!"
Tips:
- Phonetic sounds go INSIDE quotes: "Hahaha", "Hmm", "Ugh", "Argh"
- Stage directions go OUTSIDE: She sighs deeply. He clears his throat.
- Match the gender/age in your description to the voice reference.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": (
"STRING",
{
"multiline": True,
"default": (
'A woman speaks warmly, "Hello, how are you today?" '
'She laughs, "Hahaha, it is so good to see you!"'
),
"tooltip": (
"Scene prompt. Put dialogue in double quotes, stage "
"directions outside them. Phonetic sounds (Hahaha, Hmm) "
"go inside quotes; named actions (She sighs.) go outside."
),
},
),
"cfg_scale": (
"FLOAT",
{
"default": 2.5,
"min": 1.0,
"max": 10.0,
"step": 0.5,
"tooltip": (
"CFG guidance scale. Lower = more natural delivery; "
"higher = more text-faithful. DramaBox default: 2.5."
),
},
),
"stg_scale": (
"FLOAT",
{
"default": 1.5,
"min": 0.0,
"max": 5.0,
"step": 0.5,
"tooltip": (
"Skip-token guidance scale. DramaBox default: 1.5."
),
},
),
},
"optional": {
"voice_sample": (
"AUDIO",
{
"tooltip": (
"Optional voice reference for timbre cloning. "
"10+ seconds of clean speech recommended."
),
},
),
"seed": (
"INT",
{
"default": 42,
"min": 0,
"max": 2**31 - 1,
"tooltip": "Random seed for reproducible generations.",
},
),
"duration_multiplier": (
"FLOAT",
{
"default": 1.1,
"min": 0.5,
"max": 3.0,
"step": 0.05,
"tooltip": (
"Multiply the auto-estimated speech duration. "
"1.1 adds 10 %% breathing room. Increase for slower delivery."
),
},
),
},
}
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "generate"
CATEGORY = "audio/DramaBox"
DESCRIPTION = (
"DramaBox expressive TTS with voice cloning. "
"Generates dramatic, expressive speech from a structured scene prompt. "
"Requires ~24 GB VRAM on first run."
)
# ------------------------------------------------------------------
def generate(
self,
text: str,
cfg_scale: float,
stg_scale: float,
voice_sample=None,
seed: int = 42,
duration_multiplier: float = 1.1,
):
if not text or not text.strip():
raise ValueError("[DramaBox] Text prompt cannot be empty.")
server = _get_server()
# ---- Write voice reference to a temp WAV if an AUDIO input was given ----
tmp_wav = None
voice_ref_path = None
try:
if voice_sample is not None:
waveform = voice_sample["waveform"]
sr = int(voice_sample["sample_rate"])
# Normalise to [C, S]
if waveform.dim() == 3:
waveform = waveform[0] # [B, C, S] -> [C, S]
elif waveform.dim() == 1:
waveform = waveform.unsqueeze(0) # [S] -> [1, S]
tmp_wav = tempfile.NamedTemporaryFile(
suffix=".wav", delete=False, prefix="dramabox_ref_"
)
tmp_wav.close()
voice_ref_path = tmp_wav.name
_save_audio(voice_ref_path, waveform.float().cpu(), sr)
logger.info(
f"[DramaBox] Voice reference saved to temp file: {voice_ref_path} "
f"({waveform.shape[-1] / sr:.1f}s)"
)
# ---- Run inference ----
waveform_out, sr_out = server.generate(
prompt=text,
voice_ref=voice_ref_path,
cfg_scale=cfg_scale,
stg_scale=stg_scale,
duration_multiplier=duration_multiplier,
seed=seed,
)
finally:
# Clean up temp file regardless of success/failure
if tmp_wav is not None:
try:
os.unlink(tmp_wav.name)
except OSError:
pass
# ---- Format output for ComfyUI: {"waveform": [B, C, S], "sample_rate": int} ----
wav = waveform_out.float().cpu()
if wav.dim() == 1:
wav = wav.unsqueeze(0).unsqueeze(0) # -> [1, 1, S]
elif wav.dim() == 2:
wav = wav.unsqueeze(0) # -> [1, C, S]
# dim == 3 is already [B, C, S]; leave as-is
duration = wav.shape[-1] / sr_out
logger.info(f"[DramaBox] Generated {duration:.1f}s of audio at {sr_out} Hz.")
return ({"waveform": wav, "sample_rate": sr_out},)
# ---------------------------------------------------------------------------
# Node registration
# ---------------------------------------------------------------------------
NODE_CLASS_MAPPINGS = {
"DramaBoxTTS": DramaBoxTTS,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DramaBoxTTS": "DramaBox TTS",
}