TTS / app.py
Rashid087's picture
Upload 7 files
f22d82b verified
Raw
History Blame Contribute Delete
9.77 kB
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,
)