#!/usr/bin/env python3 """Chatterbox-Flash — Gradio demo (ZeroGPU-ready). Warm-loads ChatterboxFlashTTS once, then serves zero-shot TTS. Long prompts are split at sentence boundaries and stitched with a short crossfade (the block-diffusion decoder can hallucinate on very long single inputs). """ import os import re import logging import subprocess import tempfile import urllib.request import numpy as np import librosa import torch import gradio as gr # ZeroGPU: `spaces` patches torch so module-level .to("cuda") pins weights and # each @spaces.GPU call maps them onto a GPU. Absent on non-ZeroGPU → no-op. try: import spaces GPU = spaces.GPU _ON_SPACE = True except Exception: # noqa: BLE001 _ON_SPACE = False def GPU(*args, **kwargs): if args and callable(args[0]): return args[0] return lambda fn: fn from chatterbox_flash import ChatterboxFlashTTS logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") # ZeroGPU requires that CUDA is NOT initialized in the main process (it forks a # worker per request). The model's uncond-prior precompute runs a real forward # at load — disable it so no CUDA op fires at import; the prior is recomputed # lazily on the first generate() inside the GPU context. try: from chatterbox_flash.model import ChatterboxFlashT3 ChatterboxFlashT3.prime_uncond_block_prior = lambda self, *a, **k: None except Exception: # noqa: BLE001 pass # On a HF GPU Space (ZeroGPU or dedicated) load to CUDA so spaces can pin the # weights; only fall back to CPU when running locally without a GPU. DEVICE = "cuda" if (_ON_SPACE or torch.cuda.is_available()) else "cpu" DTYPE = torch.bfloat16 if DEVICE == "cuda" else torch.float32 BLOCK_SIZE = 16 # fixed os.makedirs("output", exist_ok=True) logging.info("Loading Chatterbox-Flash (device=%s) ...", DEVICE) tts = ChatterboxFlashTTS.from_pretrained( "ResembleAI/chatterbox-flash", device=DEVICE, dtype=DTYPE, drf_block_size=BLOCK_SIZE, ) SR = tts.sr logging.info("Chatterbox-Flash ready (sr=%d).", SR) try: import perth _WM = perth.PerthImplicitWatermarker() except Exception: # noqa: BLE001 _WM = None # ── example voices: fetched at runtime so no binaries live in the repo ─────── _GCS = "https://storage.googleapis.com/chatterbox-demo-samples/prompts" _VOICE_SRC = { "Radio host (M)": "male_old_movie.flac", "Talk-show host (M)": "male_conan.mp3", "Soft narrator (F)": "female_shadowheart.flac", "Podcast (F)": "female_random_podcast.wav", "Cartoon dad (M)": "male_petergriffin.wav", } _VOICE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_voices") os.makedirs(_VOICE_DIR, exist_ok=True) def _ffmpeg_to_wav(src: str, dst: str) -> str: subprocess.run( ["ffmpeg", "-y", "-loglevel", "error", "-i", src, "-ar", str(SR), "-ac", "1", dst], check=True, ) return dst def _ensure_voice(label: str): dst = os.path.join(_VOICE_DIR, re.sub(r"\W+", "_", label) + ".wav") if os.path.exists(dst): return dst fname = _VOICE_SRC[label] raw = os.path.join(_VOICE_DIR, fname) try: urllib.request.urlretrieve(f"{_GCS}/{fname}", raw) return _ffmpeg_to_wav(raw, dst) except Exception as e: # noqa: BLE001 logging.warning("Could not fetch example voice %s: %s", label, e) return None _VOICES = {label: _ensure_voice(label) for label in _VOICE_SRC} def _to_wav(path): """Normalize any uploaded/example audio to a wav the loader can read.""" if path is None: return None path = str(path) if path.lower().endswith(".wav"): return path return _ffmpeg_to_wav(path, tempfile.mktemp(suffix=".wav", dir="output")) # ── text → sentence chunks ─────────────────────────────────────────────────── _ABBR = r"(Dr|Mr|Mrs|Ms|Mister|St|Sr|Jr|vs|etc|Inc|Co|No)" def _split_sentences(text: str): t = re.sub(rf"\b{_ABBR}\.", r"\1", text.strip()) parts = re.split(r"(?<=[.!?])\s+(?=[A-Z\"'])", t) return [p.replace("", ".").strip() for p in parts if p.strip()] or [text.strip()] def _chunk(text: str, max_chars: int = 240): chunks, cur = [], "" for s in _split_sentences(text): if cur and len(cur) + 1 + len(s) > max_chars: chunks.append(cur) cur = s else: cur = f"{cur} {s}".strip() if cur else s if cur: chunks.append(cur) return chunks def _crossfade(segs, sr, ms=45): if not segs: return np.zeros(1, dtype=np.float32) out = segs[0].astype(np.float32) n = int(ms / 1000 * sr) for s in segs[1:]: s = s.astype(np.float32) if n > 0 and out.size >= n and s.size >= n: fo = np.linspace(1, 0, n, dtype=np.float32) fi = np.linspace(0, 1, n, dtype=np.float32) out = np.concatenate([out[:-n], out[-n:] * fo + s[:n] * fi, s[n:]]) else: out = np.concatenate([out, s]) return out @GPU(duration=300) def generate(text, voice_ref, num_steps, temperature, cfg_scale, time_shift_tau, exaggeration, seed, progress=gr.Progress()): if not text or not text.strip(): raise gr.Error("Please enter some text.") if not voice_ref: raise gr.Error("Please upload a voice reference (or click an example to load one).") try: ref = _to_wav(voice_ref) except Exception as e: # noqa: BLE001 raise gr.Error(f"Could not read the voice reference: {e}") if seed and int(seed) > 0: torch.manual_seed(int(seed)) tts.t3.set_block_size(BLOCK_SIZE) tts.prepare_conditionals(ref, exaggeration=float(exaggeration)) chunks = _chunk(text) segs = [] for i, c in enumerate(chunks): progress(i / len(chunks), desc=f"Chunk {i + 1}/{len(chunks)}") wav = tts.generate( c, num_steps=int(num_steps), temperature=float(temperature), cfg_scale=float(cfg_scale), time_shift_tau=float(time_shift_tau), ) w = wav.squeeze(0).detach().cpu().float().numpy() wt, _ = librosa.effects.trim(w, top_db=28) segs.append(wt if wt.size > SR * 0.05 else w) audio = _crossfade(segs, SR) if _WM is not None: try: audio = _WM.apply_watermark(audio, sample_rate=SR).astype(np.float32) except Exception: # noqa: BLE001 pass return (SR, audio) EXAMPLE_TEXTS = { "Radio host (M)": "Good evening, dear listeners, and welcome back to the After Hours Hour. " "What a night it has been. The rain is tapping at my window like an old friend.", "Talk-show host (M)": "So I want you to get up now. I want all of you to get up out of your chairs.", "Soft narrator (F)": "Every day I carry her name like a shield, and every night I wonder what I'm defending.", "Podcast (F)": "Introducing the next generation of refreshment. Bolder, smoother, and brewed to perfection.", "Cartoon dad (M)": "The point is, ladies and gentlemen, that greed, for lack of a better word, is good.", } EXAMPLES = [[_VOICES[k], t] for k, t in EXAMPLE_TEXTS.items() if _VOICES.get(k)] # Force the built-in Gradio UI to English (it otherwise follows the visitor's # browser locale). Override navigator.language before the frontend reads it. _FORCE_EN = """ """ with gr.Blocks(title="Chatterbox-Flash — Zero-shot TTS", analytics_enabled=False) as app: gr.Markdown( "# ⚡ Chatterbox-Flash — Zero-shot TTS\n" "Prior-calibrated **block-diffusion** TTS — same quality as " "[Chatterbox](https://github.com/resemble-ai/chatterbox), decoded in parallel " "(~2× faster). Pick an example or upload a 5–10 s **English** voice reference, " "type your text, and generate.\n\n" "Long text is auto-split at sentence boundaries and stitched with a crossfade. " "*Made with ♥️ by [Resemble AI](https://resemble.ai).*" ) with gr.Row(): with gr.Column(scale=3): text = gr.Textbox(label="Text", lines=5, placeholder="Type what the voice should say...") voice = gr.Audio(label="Voice reference (5–10 s)", type="filepath") btn = gr.Button("Generate", variant="primary", size="lg") with gr.Column(scale=2): out = gr.Audio(label="Generated audio", type="numpy") with gr.Accordion("Settings", open=False): num_steps = gr.Slider(2, 20, value=12, step=1, label="Diffusion steps / block") temperature = gr.Slider(0.1, 1.0, value=0.6, step=0.05, label="Temperature") cfg_scale = gr.Slider(0.0, 5.0, value=1.0, step=0.5, label="CFG scale") time_shift_tau = gr.Slider(0.0, 1.0, value=0.2, step=0.05, label="Early-decode τ (time-shift)") exaggeration = gr.Slider(0.25, 1.0, value=0.5, step=0.05, label="Exaggeration") seed = gr.Number(value=0, label="Seed (0 = random)", precision=0) ctrls = [text, voice, num_steps, temperature, cfg_scale, time_shift_tau, exaggeration, seed] btn.click(generate, inputs=ctrls, outputs=[out]) if EXAMPLES: gr.Examples( examples=EXAMPLES, inputs=[voice, text], label="Examples — click to load a voice + text, then press Generate", ) if __name__ == "__main__": port = int(os.environ.get("GRADIO_SERVER_PORT", "7860")) app.queue(max_size=10).launch(server_name="0.0.0.0", server_port=port, head=_FORCE_EN, ssr_mode=False)