import os import re import tempfile from typing import Optional, Tuple import gradio as gr import torch import torchaudio as ta from chatterbox.tts_turbo import ChatterboxTurboTTS MAX_CHARS = 2000 APP_DIR = os.path.dirname(os.path.abspath(__file__)) DEFAULT_VOICES = [ { "label": "Elizabeth Klett (female)", "path": os.path.join(APP_DIR, "assets", "voices", "elizabeth_klett_female.mp3"), }, { "label": "Mark F. Smith (male)", "path": os.path.join(APP_DIR, "assets", "voices", "mark_smith_male.mp3"), }, { "label": "David Clarke (male)", "path": os.path.join(APP_DIR, "assets", "voices", "david_clarke_male.mp3"), }, ] DEFAULT_VOICE_CHOICES = [voice["label"] for voice in DEFAULT_VOICES] DEFAULT_VOICE_PATH_BY_LABEL = { voice["label"]: voice["path"] for voice in DEFAULT_VOICES } PARALINGUISTIC_TAGS = [ "[clear throat]", "[sigh]", "[shush]", "[cough]", "[groan]", "[sniff]", "[gasp]", "[chuckle]", "[laugh]", ] PARALINGUISTIC_TAG_PATTERN = re.compile(r"\[[^\[\]]+\]") PARALINGUISTIC_TAG_SET = set(PARALINGUISTIC_TAGS) PRONUNCIATION_REPLACEMENTS = [ (re.compile(r"\bzapply\b", flags=re.IGNORECASE), "zap-lee"), ] def _env_flag(name: str, default: bool = False) -> bool: value = os.getenv(name) if value is None: return default return value.strip().lower() in {"1", "true", "yes", "on"} def _resolve_device() -> str: override = os.getenv("CHATTERBOX_DEVICE") if override: return override return "cpu" DEVICE = _resolve_device() MODEL = ChatterboxTurboTTS.from_pretrained(device=DEVICE) def _validate_script(script: str) -> str: script = (script or "").strip() if not script: raise gr.Error("Paste a script before generating a voiceover.") if len(script) > MAX_CHARS: raise gr.Error(f"Keep scripts to {MAX_CHARS} characters or fewer.") tags = PARALINGUISTIC_TAG_PATTERN.findall(script) unsupported_tags = sorted(set(tags) - PARALINGUISTIC_TAG_SET) if unsupported_tags: raise gr.Error( "Unsupported tag: " f"{', '.join(unsupported_tags)}. Use only {', '.join(PARALINGUISTIC_TAGS)}." ) return script def _apply_pronunciation_replacements(script: str) -> str: for pattern, replacement in PRONUNCIATION_REPLACEMENTS: script = pattern.sub(replacement, script) return script def _save_wav(wav: torch.Tensor, sample_rate: int) -> str: if not isinstance(wav, torch.Tensor): wav = torch.as_tensor(wav) wav = wav.detach().cpu().float() if wav.ndim == 1: wav = wav.unsqueeze(0) output = tempfile.NamedTemporaryFile( delete=False, suffix=".wav", prefix="chatterbox_voiceover_", ) output.close() ta.save(output.name, wav, sample_rate) return output.name def generate_voiceover( script: str, reference_audio: Optional[str], ) -> Tuple[str, str]: script = _validate_script(script) script = _apply_pronunciation_replacements(script) generation_args = {} if reference_audio: generation_args["audio_prompt_path"] = reference_audio try: with torch.inference_mode(): wav = MODEL.generate(script, **generation_args) except TypeError as exc: if not reference_audio: raise gr.Error( "Chatterbox Turbo requires a reference voice clip. " "Upload a short WAV, MP3, FLAC, M4A, or OGG file and try again." ) from exc raise gr.Error(f"Generation failed: {exc}") from exc except Exception as exc: raise gr.Error(f"Generation failed: {exc}") from exc output_path = _save_wav(wav, MODEL.sr) return output_path, output_path def character_count(script: str) -> str: count = len(script or "") status = "OK" if count <= MAX_CHARS else "Too long" return f"{count}/{MAX_CHARS} characters - {status}" def sync_script_and_count(script: str) -> Tuple[str, str]: return script, character_count(script) def select_default_voice(default_voice: str) -> str: fallback_voice = DEFAULT_VOICE_CHOICES[0] return DEFAULT_VOICE_PATH_BY_LABEL.get( default_voice, DEFAULT_VOICE_PATH_BY_LABEL[fallback_voice], ) CSS = """ :root, body, .gradio-container { color-scheme: dark; background: #101114; } .gradio-container { color: #f4f4f5; } .tag-row button { min-width: 6rem; } .voiceover-output audio { width: 100%; } textarea, input, select { background: #14161a !important; color: #f4f4f5 !important; } """ DARK_THEME = gr.themes.Soft().set( body_background_fill="#101114", body_background_fill_dark="#101114", body_text_color="#f4f4f5", body_text_color_dark="#f4f4f5", background_fill_primary="#101114", background_fill_primary_dark="#101114", background_fill_secondary="#17181c", background_fill_secondary_dark="#17181c", block_background_fill="#17181c", block_background_fill_dark="#17181c", block_border_color="#2a2d34", block_border_color_dark="#2a2d34", input_background_fill="#14161a", input_background_fill_dark="#14161a", input_border_color="#30333b", input_border_color_dark="#30333b", button_primary_background_fill="#7c3aed", button_primary_background_fill_dark="#7c3aed", button_primary_background_fill_hover="#8b5cf6", button_primary_background_fill_hover_dark="#8b5cf6", button_primary_text_color="#ffffff", button_primary_text_color_dark="#ffffff", ) INSERT_TEXT_JS = """ (script) => { const insertText = INSERT_TEXT_VALUE; const active = document.activeElement; const textarea = active && active.tagName === "TEXTAREA" ? active : document.querySelector('textarea[data-testid="textbox"], textarea'); if (!textarea) { const current = script || ""; return [current + insertText]; } const current = textarea.value || script || ""; const start = textarea.selectionStart ?? current.length; const end = textarea.selectionEnd ?? start; const nextValue = current.slice(0, start) + insertText + current.slice(end); const nextCursor = start + insertText.length; textarea.value = nextValue; textarea.dispatchEvent(new Event("input", { bubbles: true })); requestAnimationFrame(() => { textarea.focus(); textarea.setSelectionRange(nextCursor, nextCursor); }); return [nextValue]; } """ def insert_text_js(insert_text: str) -> str: return INSERT_TEXT_JS.replace("INSERT_TEXT_VALUE", repr(insert_text)) with gr.Blocks(title="Chatterbox Voiceovers") as demo: gr.Markdown( """ # Chatterbox Voiceovers Create short-form voiceovers from reel scripts with Chatterbox Turbo. Add expressive tags inline, choose a bundled voice or upload a voice reference, then generate a WAV. """ ) with gr.Row(): with gr.Column(scale=7): script_input = gr.Textbox( label="Script", placeholder=( "Paste your reel script here. Example: " "This one mistake is costing creators hours every week [chuckle]." ), lines=12, max_lines=18, ) count_output = gr.Markdown(character_count("")) with gr.Group(elem_classes=["tag-row"]): gr.Markdown("Paralinguistic tags") with gr.Row(): tag_buttons = [gr.Button(tag, size="sm") for tag in PARALINGUISTIC_TAGS] with gr.Column(scale=5): default_voice = gr.Dropdown( label="Default voice", choices=DEFAULT_VOICE_CHOICES, value=DEFAULT_VOICE_CHOICES[0], ) reference_audio = gr.Audio( label="Voice reference", sources=["upload", "microphone"], type="filepath", value=DEFAULT_VOICE_PATH_BY_LABEL[DEFAULT_VOICE_CHOICES[0]], ) generate_button = gr.Button("Generate voiceover", variant="primary") output_audio = gr.Audio( label="Generated voiceover", type="filepath", elem_classes=["voiceover-output"], ) download_file = gr.File(label="Download WAV") gr.Examples( examples=[ [ "Stop scrolling [chuckle]. If your reels sound flat, try writing like you speak, then cut every sentence in half." ], [ "I thought this would take all afternoon [sigh], but the whole edit was done before my coffee got cold." ], [ "Here is the simple three-part hook that keeps people watching [chuckle]. Problem, surprise, payoff." ], ], inputs=[script_input], ) script_input.change(character_count, inputs=script_input, outputs=count_output) default_voice.change(select_default_voice, inputs=default_voice, outputs=reference_audio) for tag_button, tag in zip(tag_buttons, PARALINGUISTIC_TAGS): tag_button.click( fn=sync_script_and_count, inputs=script_input, outputs=[script_input, count_output], js=insert_text_js(f"{tag} "), ) generate_button.click( fn=generate_voiceover, inputs=[script_input, reference_audio], outputs=[output_audio, download_file], show_progress="full", ) if __name__ == "__main__": demo.queue(max_size=20, default_concurrency_limit=1).launch( theme=DARK_THEME, css=CSS, share=_env_flag("GRADIO_SHARE", default=False), ssr_mode=False, )