Spaces:
Running on Zero
Running on Zero
| """FireRedTTS3 — unified speech generation & editing demo (ZeroGPU). | |
| Three capabilities of https://huggingface.co/FireRedTeam/FireRedTTS3 : | |
| * Zero-shot voice cloning (FireRedTTS3-Base, 24 languages + 21 ZH dialects) | |
| * Voice design (FireRedTTS3-Instruct, natural-language timbre prompt) | |
| * Speech editing (FireRedTTS3-Instruct, semantic + acoustic) | |
| """ | |
| import functools | |
| import os | |
| import re | |
| import urllib.request | |
| import spaces # noqa: F401 (must precede torch / CUDA imports) | |
| import numpy as np | |
| import soundfile as sf | |
| import torch | |
| import gradio as gr | |
| from huggingface_hub import snapshot_download | |
| HERE = os.path.dirname(os.path.abspath(__file__)) | |
| # --------------------------------------------------------------------------- # | |
| # Text front-end assets | |
| # --------------------------------------------------------------------------- # | |
| # fastText lid.176 powers automatic language routing (see upstream README). | |
| _LID_URL = "https://dl.fbaipublicfiles.com/fasttext/supervised-models/lid.176.ftz" | |
| _LID_PATH = os.path.join(HERE, "fireredtts3", "utils", "llm_tn", "models", "lid.176.ftz") | |
| os.makedirs(os.path.dirname(_LID_PATH), exist_ok=True) | |
| if not os.path.exists(_LID_PATH): | |
| try: | |
| urllib.request.urlretrieve(_LID_URL, _LID_PATH) | |
| print(f"[INFO] fastText lid.176 downloaded to {_LID_PATH}", flush=True) | |
| except Exception as exc: # pragma: no cover | |
| print(f"[WARN] Could not fetch fastText lid.176: {exc}", flush=True) | |
| # The upstream llm_tn TextNormalizer refuses to construct without API creds, and | |
| # the fastText language detector lives on that object. We only use it for | |
| # language *identification* (use_llm_tn=False -> local wetext TN), so give it | |
| # placeholder creds and disable its LLM fallback further below. | |
| os.environ.setdefault("LLM_TN_API_URL", "http://127.0.0.1:1/unused") | |
| os.environ.setdefault("LLM_TN_API_KEY", "unused") | |
| # --------------------------------------------------------------------------- # | |
| # Weights | |
| # --------------------------------------------------------------------------- # | |
| MODEL_REPO = "FireRedTeam/FireRedTTS3" | |
| MODEL_DIR = snapshot_download(MODEL_REPO) | |
| print(f"[INFO] weights at {MODEL_DIR}", flush=True) | |
| from fireredtts3.core import FireRedTTS3, FireRedTTS3Instruct # noqa: E402 | |
| from fireredtts3.redae.redae import RedAE # noqa: E402 | |
| from fireredtts3.utils.llm_tn.text_normalizer import TextNormalizer # noqa: E402 | |
| from fireredtts3.utils.text_tokenizer import ( # noqa: E402 | |
| MULTI_DIALECT_TAGS, | |
| MULTI_LANG_TAGS, | |
| ) | |
| # Base and Instruct each instantiate their own RedAE from the very same | |
| # checkpoint; share one instance instead (~3.8 GB saved, identical numerics). | |
| _redae_real_from_pretrained = RedAE.from_pretrained | |
| _redae_singleton = None | |
| def _shared_redae(*args, **kwargs): | |
| global _redae_singleton | |
| if _redae_singleton is None: | |
| _redae_singleton = _redae_real_from_pretrained(*args, **kwargs) | |
| return _redae_singleton | |
| RedAE.from_pretrained = _shared_redae | |
| tts = FireRedTTS3(MODEL_DIR, use_fasttext=True, use_llm_tn=False, use_wetext=True) | |
| instruct = FireRedTTS3Instruct(MODEL_DIR, use_fasttext=True, use_llm_tn=False, use_wetext=True) | |
| for _pipe in (tts, instruct): | |
| _norm = getattr(_pipe, "_llm_tn", None) | |
| if _norm is not None: | |
| # No API creds here -> never let language ID fall back to an LLM call. | |
| _norm.detect_locale = functools.partial( | |
| TextNormalizer.detect_locale, _norm, use_llm_fallback=False | |
| ) | |
| print("[INFO] FireRedTTS3 Base + Instruct ready", flush=True) | |
| SAMPLE_RATE = tts.redae.sample_rate | |
| # --------------------------------------------------------------------------- # | |
| # Language choices | |
| # --------------------------------------------------------------------------- # | |
| AUTO = "Auto-detect" | |
| LANGUAGES = [t.strip("<|>") for t in MULTI_LANG_TAGS] | |
| DIALECTS = [t.strip("<|>") for t in MULTI_DIALECT_TAGS] | |
| LANG_CHOICES = ( | |
| [AUTO] | |
| + LANGUAGES | |
| + [f"{d} (Chinese dialect)" for d in DIALECTS] | |
| ) | |
| def _resolve_language(choice: str): | |
| if not choice or choice == AUTO: | |
| return None | |
| return choice.split(" (")[0] | |
| # --------------------------------------------------------------------------- # | |
| # Audio helpers | |
| # --------------------------------------------------------------------------- # | |
| MAX_PROMPT_SECONDS = 20.0 | |
| MAX_EDIT_SECONDS = 20.0 | |
| MAX_TEXT_CHARS = 400 | |
| def _load_audio(path: str, max_seconds: float): | |
| if not path: | |
| raise gr.Error("Please provide an audio file first.") | |
| wav, sr = sf.read(path, always_2d=True, dtype="float32") | |
| wav = wav[:, 0] | |
| if wav.shape[0] > int(max_seconds * sr): | |
| wav = wav[: int(max_seconds * sr)] | |
| gr.Info(f"Audio truncated to the first {max_seconds:.0f}s.") | |
| peak = float(np.abs(wav).max()) if wav.size else 0.0 | |
| if peak > 0: | |
| wav = wav / peak * 0.95 | |
| return torch.from_numpy(np.ascontiguousarray(wav)[None, :]), sr | |
| def _to_gradio_audio(audio: torch.Tensor, sr: int): | |
| x = audio.detach().float().cpu().numpy() | |
| if x.ndim > 1: | |
| x = x[0] | |
| x = np.clip(x, -1.0, 1.0) | |
| return sr, (x * 32767.0).astype(np.int16) | |
| _EDIT_MASK_RE = re.compile(r"<\|edit\|>(?:<\|frame_patch\|>)*<\|end_edit\|>") | |
| def _pretty_edit_text(text: str) -> str: | |
| """The model marks the re-synthesized span with edit/frame-patch tokens.""" | |
| text = _EDIT_MASK_RE.sub(" ⟨edited span⟩ ", text or "") | |
| text = re.sub(r"<\|[^|]*\|>", "", text) | |
| return re.sub(r"\s+", " ", text).strip() | |
| def _check_text(text: str, what: str = "Text"): | |
| text = (text or "").strip() | |
| if not text: | |
| raise gr.Error(f"{what} must not be empty.") | |
| if len(text) > MAX_TEXT_CHARS: | |
| gr.Info(f"{what} truncated to {MAX_TEXT_CHARS} characters.") | |
| text = text[:MAX_TEXT_CHARS] | |
| return text | |
| # --------------------------------------------------------------------------- # | |
| # Inference | |
| # --------------------------------------------------------------------------- # | |
| def voice_clone( | |
| prompt_audio, | |
| prompt_text, | |
| text, | |
| language=AUTO, | |
| inference_cfg=2.0, | |
| n_timesteps=10, | |
| seed=1234, | |
| do_tn=True, | |
| ): | |
| """FireRedTTS3-Base zero-shot voice cloning.""" | |
| text = _check_text(text, "Text to synthesize") | |
| prompt_text = (prompt_text or "").strip() | |
| if not prompt_text: | |
| raise gr.Error("Please provide the transcript of the reference audio.") | |
| wav, sr = _load_audio(prompt_audio, MAX_PROMPT_SECONDS) | |
| gen_audio, gen_sr = tts.generate( | |
| text=text, | |
| language=_resolve_language(language), | |
| prompt_text=prompt_text, | |
| prompt_audio=wav, | |
| prompt_audio_sr=sr, | |
| n_timesteps=int(n_timesteps), | |
| inference_cfg=float(inference_cfg), | |
| seed=int(seed), | |
| do_tn=bool(do_tn), | |
| ) | |
| return _to_gradio_audio(gen_audio, gen_sr) | |
| def voice_design( | |
| instruction, | |
| text, | |
| inference_cfg=1.2, | |
| n_timesteps=10, | |
| seed=2, | |
| do_tn=True, | |
| ): | |
| """FireRedTTS3-Instruct voice design (no reference audio).""" | |
| instruction = _check_text(instruction, "Voice description") | |
| text = _check_text(text, "Text to synthesize") | |
| gen_audio, gen_sr, gen_text = instruct.generate_voice_design( | |
| instruction=instruction, | |
| text=text, | |
| n_timesteps=int(n_timesteps), | |
| inference_cfg=float(inference_cfg), | |
| seed=int(seed), | |
| do_tn=bool(do_tn), | |
| ) | |
| return _to_gradio_audio(gen_audio, gen_sr), (gen_text or "").strip() | |
| def semantic_edit( | |
| audio_in, | |
| instruction, | |
| inference_cfg=1.2, | |
| n_timesteps=10, | |
| seed=1234, | |
| ): | |
| """FireRedTTS3-Instruct content editing: insert / delete / substitute.""" | |
| instruction = _check_text(instruction, "Edit instruction") | |
| wav, sr = _load_audio(audio_in, MAX_EDIT_SECONDS) | |
| gen_audio, gen_sr, gen_text = instruct.generate_semantic_edit( | |
| instruction=instruction, | |
| audio_in=wav, | |
| audio_in_sr=sr, | |
| n_timesteps=int(n_timesteps), | |
| inference_cfg=float(inference_cfg), | |
| seed=int(seed), | |
| ) | |
| return _to_gradio_audio(gen_audio, gen_sr), _pretty_edit_text(gen_text) | |
| def compose_acoustic_instruction(attribute: str, value: float) -> str: | |
| """Acoustic edits only accept the templates the model was trained on.""" | |
| if attribute == "Speed": | |
| return f"adjust the speed to {value:.1f}x" | |
| if attribute == "Volume": | |
| return f"adjust the volume to {value:.1f}" | |
| steps = int(round(value)) | |
| return f"shift the pitch by {steps} step{'' if abs(steps) == 1 else 's'}" | |
| def acoustic_edit( | |
| audio_in, | |
| attribute="Speed", | |
| value=0.8, | |
| inference_cfg=1.2, | |
| n_timesteps=10, | |
| seed=1234, | |
| ): | |
| """FireRedTTS3-Instruct acoustic editing: speed / pitch / volume.""" | |
| wav, sr = _load_audio(audio_in, MAX_EDIT_SECONDS) | |
| instruction = compose_acoustic_instruction(attribute, float(value)) | |
| gen_audio, gen_sr = instruct.generate_acoustic_edit( | |
| instruction=instruction, | |
| audio_in=wav, | |
| audio_in_sr=sr, | |
| n_timesteps=int(n_timesteps), | |
| inference_cfg=float(inference_cfg), | |
| seed=int(seed), | |
| ) | |
| return _to_gradio_audio(gen_audio, gen_sr), instruction | |
| # --------------------------------------------------------------------------- # | |
| # UI | |
| # --------------------------------------------------------------------------- # | |
| EN_PROMPT = os.path.join(HERE, "examples", "en_prompt.wav") | |
| ZH_PROMPT = os.path.join(HERE, "examples", "zh_prompt.wav") | |
| EN_PROMPT_TEXT = ( | |
| "Just by listening a few minutes a day, you'll be able to eliminate negative " | |
| "thoughts by conditioning your mind to be more positive." | |
| ) | |
| ZH_PROMPT_TEXT = "比如具体一点的,他觉得最大的一个跟他预想的不一样的是在什么地方。" | |
| CSS = """ | |
| .gradio-container {max-width: 1200px !important; margin: auto !important;} | |
| .dark .gradio-container {color: var(--body-text-color);} | |
| """ | |
| with gr.Blocks(title="FireRedTTS3") as demo: | |
| gr.Markdown( | |
| """ | |
| # 🔥 FireRedTTS3 — Unified Speech Generation & Editing | |
| Zero-shot voice cloning in **24 languages + 21 Chinese dialects**, natural-language | |
| **voice design**, and instruction-driven **speech editing** — all from | |
| [FireRedTeam/FireRedTTS3](https://huggingface.co/FireRedTeam/FireRedTTS3). | |
| """ | |
| ) | |
| with gr.Tabs(): | |
| # ------------------------------------------------------------------ # | |
| with gr.Tab("🎙️ Voice Cloning"): | |
| gr.Markdown( | |
| "Clone any voice from a short reference clip. For best quality the " | |
| "reference should be spoken in the **same language / dialect** as the " | |
| "text you synthesize." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(): | |
| clone_prompt_audio = gr.Audio( | |
| label="Reference audio (5–20 s)", | |
| sources=["upload", "microphone"], | |
| type="filepath", | |
| ) | |
| clone_prompt_text = gr.Textbox( | |
| label="Reference transcript", | |
| placeholder="Exactly what is said in the reference audio…", | |
| lines=2, | |
| ) | |
| clone_text = gr.Textbox( | |
| label="Text to synthesize", | |
| placeholder="Type the text you want spoken in that voice…", | |
| lines=4, | |
| ) | |
| clone_language = gr.Dropdown( | |
| LANG_CHOICES, value=AUTO, label="Language / dialect" | |
| ) | |
| clone_btn = gr.Button("Generate speech", variant="primary") | |
| with gr.Column(): | |
| clone_out = gr.Audio(label="Generated speech", type="numpy") | |
| with gr.Accordion("Advanced options", open=False): | |
| clone_cfg = gr.Slider( | |
| 0.0, 4.0, value=2.0, step=0.1, | |
| label="CFG strength", | |
| info="Higher sticks closer to the reference timbre.", | |
| ) | |
| clone_steps = gr.Slider( | |
| 4, 30, value=10, step=1, label="Flow-matching timesteps" | |
| ) | |
| clone_seed = gr.Number(value=1234, precision=0, label="Seed") | |
| clone_tn = gr.Checkbox( | |
| value=True, | |
| label="Text normalization (numbers, dates, units → words)", | |
| ) | |
| gr.Examples( | |
| examples=[ | |
| [ | |
| EN_PROMPT, | |
| EN_PROMPT_TEXT, | |
| "FireRedTTS3 turns a handful of seconds of speech into a voice " | |
| "that can read anything you write.", | |
| "English", | |
| ], | |
| [ | |
| ZH_PROMPT, | |
| ZH_PROMPT_TEXT, | |
| "法院与不动产登记部门加强沟通,并督促银行提前办理抵押预约登记。", | |
| "Chinese", | |
| ], | |
| [ | |
| EN_PROMPT, | |
| EN_PROMPT_TEXT, | |
| "Le modèle peut aussi parler français avec la même voix de référence.", | |
| "French", | |
| ], | |
| ], | |
| inputs=[clone_prompt_audio, clone_prompt_text, clone_text, clone_language], | |
| outputs=[clone_out], | |
| fn=voice_clone, | |
| cache_examples=True, | |
| cache_mode="lazy", | |
| ) | |
| # ------------------------------------------------------------------ # | |
| with gr.Tab("🎨 Voice Design"): | |
| gr.Markdown( | |
| "Describe a voice in plain language — no reference audio needed. The " | |
| "model first writes a voice-attribute plan, then renders the audio." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(): | |
| design_instruction = gr.Textbox( | |
| label="Voice description", | |
| placeholder="e.g. A young woman with a gentle voice, speaking slowly…", | |
| lines=3, | |
| ) | |
| design_text = gr.Textbox( | |
| label="Text to synthesize", lines=4, | |
| placeholder="Type the text you want spoken…", | |
| ) | |
| design_btn = gr.Button("Design voice", variant="primary") | |
| with gr.Column(): | |
| design_out = gr.Audio(label="Generated speech", type="numpy") | |
| design_plan = gr.Textbox( | |
| label="Voice-attribute plan (model chain-of-thought)", lines=4 | |
| ) | |
| with gr.Accordion("Advanced options", open=False): | |
| design_cfg = gr.Slider( | |
| 0.0, 4.0, value=1.2, step=0.1, label="CFG strength" | |
| ) | |
| design_steps = gr.Slider( | |
| 4, 30, value=10, step=1, label="Flow-matching timesteps" | |
| ) | |
| design_seed = gr.Number(value=2, precision=0, label="Seed") | |
| design_tn = gr.Checkbox(value=True, label="Text normalization") | |
| gr.Examples( | |
| examples=[ | |
| [ | |
| "一个年轻女性的温柔嗓音,语速稍慢,带一点俏皮。", | |
| "今天天气很好,我们一起去公园散步吧。", | |
| ], | |
| [ | |
| "An old sailor with a deep, gravelly voice, speaking slowly and " | |
| "warmly, as if telling a story by the fire.", | |
| "The sea was calm that morning, and every rope on deck was " | |
| "still wet with salt.", | |
| ], | |
| [ | |
| "A bright, energetic young man hosting a sports broadcast, fast " | |
| "paced and excited.", | |
| "And with ten seconds left on the clock, he takes the shot — " | |
| "and it is in!", | |
| ], | |
| ], | |
| inputs=[design_instruction, design_text], | |
| outputs=[design_out, design_plan], | |
| fn=voice_design, | |
| cache_examples=True, | |
| cache_mode="lazy", | |
| ) | |
| # ------------------------------------------------------------------ # | |
| with gr.Tab("✂️ Speech Editing"): | |
| with gr.Tabs(): | |
| with gr.Tab("Semantic (content)"): | |
| gr.Markdown( | |
| "Insert, delete or substitute words in an existing recording " | |
| "while keeping the original voice. The model transcribes the " | |
| "audio itself — just say what to change." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(): | |
| sem_audio = gr.Audio( | |
| label="Input speech (≤ 20 s)", | |
| sources=["upload", "microphone"], | |
| type="filepath", | |
| ) | |
| sem_instruction = gr.Textbox( | |
| label="Edit instruction", | |
| placeholder="e.g. Replace 'positive' with 'optimistic'.", | |
| lines=2, | |
| ) | |
| sem_btn = gr.Button("Apply edit", variant="primary") | |
| with gr.Column(): | |
| sem_out = gr.Audio(label="Edited speech", type="numpy") | |
| sem_text = gr.Textbox(label="Edited transcript", lines=3) | |
| with gr.Accordion("Advanced options", open=False): | |
| sem_cfg = gr.Slider( | |
| 0.0, 4.0, value=1.2, step=0.1, label="CFG strength" | |
| ) | |
| sem_steps = gr.Slider( | |
| 4, 30, value=10, step=1, | |
| label="Flow-matching timesteps", | |
| ) | |
| sem_seed = gr.Number( | |
| value=1234, precision=0, label="Seed" | |
| ) | |
| gr.Examples( | |
| examples=[ | |
| [EN_PROMPT, "Replace 'positive' with 'optimistic'."], | |
| [EN_PROMPT, "Delete the word 'negative'."], | |
| [ZH_PROMPT, "把“最大的”替换成“最有意思的”。"], | |
| ], | |
| inputs=[sem_audio, sem_instruction], | |
| outputs=[sem_out, sem_text], | |
| fn=semantic_edit, | |
| cache_examples=True, | |
| cache_mode="lazy", | |
| ) | |
| with gr.Tab("Acoustic (speed / pitch / volume)"): | |
| gr.Markdown( | |
| "Re-render the same utterance with a different speaking rate, " | |
| "pitch or loudness. These edits follow fixed instruction " | |
| "templates the model was trained on." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(): | |
| aco_audio = gr.Audio( | |
| label="Input speech (≤ 20 s)", | |
| sources=["upload", "microphone"], | |
| type="filepath", | |
| ) | |
| aco_attr = gr.Radio( | |
| ["Speed", "Pitch", "Volume"], | |
| value="Speed", | |
| label="Attribute", | |
| ) | |
| aco_value = gr.Slider( | |
| 0.5, 2.0, value=0.8, step=0.1, | |
| label="Speed (×)", | |
| ) | |
| aco_btn = gr.Button("Apply edit", variant="primary") | |
| with gr.Column(): | |
| aco_out = gr.Audio(label="Edited speech", type="numpy") | |
| aco_instruction = gr.Textbox( | |
| label="Instruction sent to the model", lines=1 | |
| ) | |
| with gr.Accordion("Advanced options", open=False): | |
| aco_cfg = gr.Slider( | |
| 0.0, 4.0, value=1.2, step=0.1, label="CFG strength" | |
| ) | |
| aco_steps = gr.Slider( | |
| 4, 30, value=10, step=1, | |
| label="Flow-matching timesteps", | |
| ) | |
| aco_seed = gr.Number( | |
| value=1234, precision=0, label="Seed" | |
| ) | |
| gr.Examples( | |
| examples=[ | |
| [EN_PROMPT, "Speed", 0.7], | |
| [ZH_PROMPT, "Pitch", 2], | |
| [EN_PROMPT, "Volume", 1.6], | |
| ], | |
| inputs=[aco_audio, aco_attr, aco_value], | |
| outputs=[aco_out, aco_instruction], | |
| fn=acoustic_edit, | |
| cache_examples=True, | |
| cache_mode="lazy", | |
| ) | |
| gr.Markdown( | |
| """ | |
| --- | |
| **Model:** [FireRedTeam/FireRedTTS3](https://huggingface.co/FireRedTeam/FireRedTTS3) | |
| (Apache-2.0) · Base = cloning, Instruct = design + editing. Text normalization | |
| runs locally through *wetext* (Chinese / English); other languages get basic | |
| cleaning only. Voice cloning is provided **for academic research purposes only** — | |
| do not use it for impersonation or any illegal activity. | |
| """ | |
| ) | |
| def _attr_changed(attribute, current): | |
| lo, hi, step, label = { | |
| "Speed": (0.5, 2.0, 0.1, "Speed (×)"), | |
| "Volume": (0.3, 2.0, 0.1, "Volume (×)"), | |
| }.get(attribute, (-6, 6, 1, "Pitch shift (semitone steps)")) | |
| try: | |
| value = min(max(float(current), lo), hi) | |
| except (TypeError, ValueError): | |
| value = lo | |
| if attribute == "Pitch": | |
| value = int(round(value)) or 1 | |
| return gr.update(minimum=lo, maximum=hi, step=step, value=value, label=label) | |
| aco_attr.change(_attr_changed, inputs=[aco_attr, aco_value], outputs=[aco_value]) | |
| clone_btn.click( | |
| voice_clone, | |
| inputs=[clone_prompt_audio, clone_prompt_text, clone_text, clone_language, | |
| clone_cfg, clone_steps, clone_seed, clone_tn], | |
| outputs=[clone_out], | |
| api_name="voice_clone", | |
| ) | |
| design_btn.click( | |
| voice_design, | |
| inputs=[design_instruction, design_text, design_cfg, design_steps, | |
| design_seed, design_tn], | |
| outputs=[design_out, design_plan], | |
| api_name="voice_design", | |
| ) | |
| sem_btn.click( | |
| semantic_edit, | |
| inputs=[sem_audio, sem_instruction, sem_cfg, sem_steps, sem_seed], | |
| outputs=[sem_out, sem_text], | |
| api_name="semantic_edit", | |
| ) | |
| aco_btn.click( | |
| acoustic_edit, | |
| inputs=[aco_audio, aco_attr, aco_value, aco_cfg, aco_steps, aco_seed], | |
| outputs=[aco_out, aco_instruction], | |
| api_name="acoustic_edit", | |
| ) | |
| if __name__ == "__main__": | |
| demo.queue().launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True) | |