Spaces:
Running
Running
| """ | |
| Gradio frontend for multilingual voice cloning. | |
| Two tabs: | |
| 1. Clone Voice — single language | |
| 2. Language Tour — same reference, many languages | |
| The language dropdown is populated from `VoiceCloneClient.discover_languages()`, | |
| which queries the backend `/languages` endpoint and filters LANGUAGE_CATALOG. | |
| The language code is passed end-to-end into synthesis so the selection | |
| actually changes the output — fixing the bug shown in the original PDF notes. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| from pathlib import Path | |
| from typing import Any | |
| import gradio as gr | |
| from app.client import VoiceCloneClient | |
| from app.config import LANGUAGE_CATALOG, get_settings | |
| from app.errors import VoiceCloneError | |
| log = logging.getLogger(__name__) | |
| # Pre-baked demo sentences per language. | |
| DEMO_SENTENCES = { | |
| "yue": "大家好,歡迎體驗我哋嘅多語言聲音克隆系統,廣東話都支援到。", | |
| "en": "Hello everyone, welcome to our multilingual voice cloning demonstration.", | |
| "zh": "大家好,欢迎体验我们的多语言语音克隆系统。", | |
| "ja": "皆さん、こんにちは。多言語音声クローニングのデモへようこそ。", | |
| "ko": "안녕하세요, 다국어 음성 복제 데모에 오신 것을 환영합니다.", | |
| "fr": "Bonjour à tous, bienvenue dans notre démo de clonage vocal multilingue.", | |
| "es": "Hola a todos, bienvenidos a nuestra demostración de clonación de voz multilingüe.", | |
| } | |
| def _format_label(code: str, meta: dict[str, str]) -> str: | |
| # Make Cantonese visually prominent. | |
| if code == "yue": | |
| return f"🇭🇰 {meta['native']} — {meta['english']} (yue)" | |
| return f"{meta['native']} — {meta['english']} ({code})" | |
| def _build_label_map(client: VoiceCloneClient) -> tuple[list[str], dict[str, str]]: | |
| """Returns (dropdown_choices, {label: code}).""" | |
| langs = client.discover_languages() | |
| if not langs: | |
| # Fall back to full catalog so the UI still loads if the backend is cold. | |
| langs = dict(LANGUAGE_CATALOG) | |
| # Keep Cantonese pinned to the top regardless of dict order. | |
| ordered = [] | |
| if "yue" in langs: | |
| ordered.append(("yue", langs["yue"])) | |
| for code, meta in langs.items(): | |
| if code != "yue": | |
| ordered.append((code, meta)) | |
| choices = [_format_label(c, m) for c, m in ordered] | |
| label_to_code = {_format_label(c, m): c for c, m in ordered} | |
| return choices, label_to_code | |
| def _format_health(health_map: dict[str, Any]) -> str: | |
| lines = [] | |
| for name, st in health_map.items(): | |
| mark = "✅" if st.reachable else "⚠️" | |
| lines.append(f"{mark} **{name}** — {st.detail}") | |
| return "\n".join(lines) | |
| def build_app(client: VoiceCloneClient | None = None) -> gr.Blocks: | |
| client = client or VoiceCloneClient() | |
| settings = client.settings | |
| choices, label_to_code = _build_label_map(client) | |
| cantonese_present = "yue" in client.discover_languages() | |
| default_label = next((lbl for lbl in choices if "yue" in lbl), choices[0] if choices else "") | |
| # ---- callbacks ----------------------------------------------------- | |
| def on_clone(ref_audio, text, language_label, exaggeration, cfg_weight): | |
| if not ref_audio: | |
| return None, "❌ Please upload reference audio first." | |
| if not language_label: | |
| return None, "❌ Please select a language." | |
| code = label_to_code.get(language_label) | |
| if not code: | |
| return None, f"❌ Unknown language: {language_label}" | |
| try: | |
| res = client.clone_voice( | |
| reference_audio_path=Path(ref_audio), | |
| text=text, | |
| language_code=code, | |
| exaggeration=float(exaggeration), | |
| cfg_weight=float(cfg_weight), | |
| ) | |
| except VoiceCloneError as e: | |
| return None, f"❌ {e}" | |
| status = ( | |
| f"✅ Generated in {res.latency_seconds:.1f}s via **{res.provider_name}** " | |
| f"— language={code} ({language_label}), model={res.model}, " | |
| f"sr={res.sample_rate} Hz" | |
| ) | |
| return str(res.wav_path), status | |
| def on_tour(ref_audio, text, exaggeration, cfg_weight): | |
| if not ref_audio: | |
| return [None] * len(choices) + ["❌ Please upload reference audio first."] | |
| results = client.language_tour( | |
| reference_audio_path=Path(ref_audio), | |
| text=text, | |
| exaggeration=float(exaggeration), | |
| cfg_weight=float(cfg_weight), | |
| language_codes=[label_to_code[lbl] for lbl in choices], | |
| ) | |
| audio_outputs: list[str | None] = [] | |
| lines = [] | |
| for (code, res, err) in results: | |
| meta = LANGUAGE_CATALOG.get(code, {"english": code, "native": code}) | |
| if res: | |
| audio_outputs.append(str(res.wav_path)) | |
| lines.append(f"✅ {meta['english']} ({code}) — {res.latency_seconds:.1f}s via {res.provider_name}") | |
| else: | |
| audio_outputs.append(None) | |
| lines.append(f"❌ {meta['english']} ({code}) — {err}") | |
| return audio_outputs + ["\n".join(lines)] | |
| def on_health(): | |
| return _format_health(client.health()) | |
| def on_language_change(label: str) -> str: | |
| code = label_to_code.get(label or "", "en") | |
| return DEMO_SENTENCES.get(code, "") | |
| # ---- layout -------------------------------------------------------- | |
| with gr.Blocks( | |
| title="ZhouFenMianFan — Multilingual Voice Cloning (Cloud)", | |
| theme=gr.themes.Soft(), | |
| ) as demo: | |
| gr.Markdown( | |
| "# 🎤 粥粉麵飯 — Multilingual Voice Cloning\n" | |
| "Cross-lingual voice cloning for Hong Kong users. Upload a " | |
| "**3–30 second reference clip** and generate speech in 粵語 " | |
| "(Cantonese), English, Mandarin, Japanese, Korean, French, " | |
| "and Spanish. Powered by a Chatterbox-compatible cloud API " | |
| "with a MiniMax adapter for Cantonese.\n" | |
| ) | |
| if not cantonese_present: | |
| gr.Markdown( | |
| "> ⚠️ **Cantonese (yue) is currently unavailable.** " | |
| "Configure `MINIMAX_API_KEY` in your `.env` to enable it." | |
| ) | |
| with gr.Accordion("Backend status", open=False): | |
| health_md = gr.Markdown(_format_health(client.health())) | |
| refresh_btn = gr.Button("Refresh health", size="sm") | |
| refresh_btn.click(on_health, outputs=health_md) | |
| with gr.Tab("Clone Voice"): | |
| with gr.Row(): | |
| with gr.Column(): | |
| ref1 = gr.Audio( | |
| type="filepath", | |
| label=f"Reference voice (WAV, " | |
| f"{settings.min_reference_seconds:.0f}–" | |
| f"{settings.max_reference_seconds:.0f}s)", | |
| sources=["upload", "microphone"], | |
| ) | |
| lang1 = gr.Dropdown( | |
| choices=choices, | |
| value=default_label, | |
| label="Target language", | |
| info="Cantonese (粵語) is routed through the MiniMax adapter.", | |
| ) | |
| text1 = gr.Textbox( | |
| lines=3, | |
| value=DEMO_SENTENCES.get("yue", "") if cantonese_present | |
| else DEMO_SENTENCES.get("en", ""), | |
| label="Text to synthesise", | |
| ) | |
| with gr.Row(): | |
| exag1 = gr.Slider( | |
| settings.exaggeration_range[0], | |
| settings.exaggeration_range[1], | |
| value=settings.default_exaggeration, step=0.05, | |
| label="Exaggeration", | |
| ) | |
| cfg1 = gr.Slider( | |
| settings.cfg_weight_range[0], | |
| settings.cfg_weight_range[1], | |
| value=settings.default_cfg_weight, step=0.05, | |
| label="CFG weight", | |
| ) | |
| go1 = gr.Button("🎙️ Generate", variant="primary") | |
| with gr.Column(): | |
| audio1 = gr.Audio(type="filepath", label="Cloned output", | |
| interactive=False) | |
| status1 = gr.Markdown("") | |
| lang1.change(on_language_change, inputs=lang1, outputs=text1) | |
| go1.click( | |
| on_clone, | |
| inputs=[ref1, text1, lang1, exag1, cfg1], | |
| outputs=[audio1, status1], | |
| ) | |
| with gr.Tab("Language Tour"): | |
| gr.Markdown( | |
| "Clone the same reference voice across every supported language " | |
| "using one shared sentence." | |
| ) | |
| with gr.Row(): | |
| ref2 = gr.Audio(type="filepath", | |
| label="Reference voice (WAV, 3–30s)", | |
| sources=["upload", "microphone"]) | |
| text2 = gr.Textbox( | |
| lines=2, | |
| value="Hello, welcome to our AI voice cloning demonstration.", | |
| label="Shared text (model will synthesise this same " | |
| "sentence in every language)", | |
| ) | |
| with gr.Row(): | |
| exag2 = gr.Slider( | |
| settings.exaggeration_range[0], | |
| settings.exaggeration_range[1], | |
| value=settings.default_exaggeration, step=0.05, | |
| label="Exaggeration", | |
| ) | |
| cfg2 = gr.Slider( | |
| settings.cfg_weight_range[0], | |
| settings.cfg_weight_range[1], | |
| value=settings.default_cfg_weight, step=0.05, | |
| label="CFG weight", | |
| ) | |
| go2 = gr.Button("🌏 Generate language tour", variant="primary") | |
| audio_widgets: list[gr.Audio] = [] | |
| for lbl in choices: | |
| audio_widgets.append(gr.Audio(type="filepath", label=lbl, | |
| interactive=False)) | |
| status2 = gr.Markdown("") | |
| go2.click( | |
| on_tour, | |
| inputs=[ref2, text2, exag2, cfg2], | |
| outputs=audio_widgets + [status2], | |
| ) | |
| gr.Markdown( | |
| "---\n" | |
| "**Guidance**: 10–30 s of clean speech works best. Avoid music, " | |
| "background noise, and clipping. 24 kHz / 32 kHz mono WAV " | |
| "produces the cleanest clones. Long-form texts over 500 characters " | |
| "are split by the backend automatically.\n" | |
| ) | |
| return demo | |
| def main() -> None: | |
| logging.basicConfig( | |
| level=os.getenv("LOG_LEVEL", "INFO"), | |
| format="%(asctime)s %(levelname)s %(name)s: %(message)s", | |
| ) | |
| settings = get_settings() | |
| demo = build_app() | |
| demo.queue().launch( | |
| server_name=settings.gradio_server_name, | |
| server_port=settings.gradio_server_port, | |
| share=False, | |
| show_error=True, | |
| ) | |
| if __name__ == "__main__": | |
| main() | |