"""Qwen Voice Studio — Gradio app (runs on local MPS and Hugging Face ZeroGPU).
Five channels over the three Qwen3-TTS-12Hz-1.7B checkpoints: Clone, Preset
Voices, Voice Design, LoRA Lab (management only), and a Voice Library that ties
them together. One codebase, two platforms; see docs/DESIGN.md.
"""
from __future__ import annotations
import os
import time
import warnings
# Quiet benign deprecation noise (gradio's Starlette 422 warning logs once per
# request; transformers' "generation flags" note) so real issues stand out.
warnings.filterwarnings("ignore")
os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
import gradio as gr
import numpy as np
from qvs import audio as qaudio
from qvs import config, engine, voices
from qvs.device import get_attn_impl, gpu, on_zerogpu, target_device
from qvs.lora import AdapterManager, load_speaker_embedding
from qvs.memory import MemoryGuard, snapshot
from qvs.registry import ModelRegistry
from qvs.ui import theme
REG = ModelRegistry()
# LoRA is applied per-generation *inside* the @spaces.GPU fork: ZeroGPU forks do
# not persist in-place model mutations across requests, so "apply once, use later"
# can't work there. We track only the selected adapter here (a plain string).
SELECTED_ADAPTER = {"source": ""}
def _apply_adapter(model, source: str):
"""Attach the adapter to a fresh manager (caller must .unload after gen)."""
if not (source or "").strip():
return None
mgr = AdapterManager()
mgr.apply(model, source.strip())
return mgr
# ZeroGPU: warm all three checkpoints into the PARENT process (fork_move residency
# keeps them resident on CPU) in a background thread, so the server boots instantly
# for the health check but each @spaces.GPU request is then just a fast CPU→GPU move
# + generate — NOT a per-request ~4.5 GB reload. Loading them at module level before
# launch() instead overran the startup window (RUNTIME_ERROR); the thread avoids that
# while still making requests fast after a ~20 s warmup.
if on_zerogpu():
import threading
threading.Thread(target=REG.preload_all, daemon=True, name="qvs-warmup").start()
NONE_VOICE = "— none —"
LANG_CHOICES = list(config.LANGUAGES.keys())
SPEAKER_CHOICES = [(f"{s.display} — {s.description.rstrip('.')} ({s.language})", s.key) for s in config.SPEAKERS]
# RAM watchdog protects the local macOS gate (DESIGN §6). On ZeroGPU the
# constraint is the 48 GB card, not container RAM (where psutil misreports),
# so the RAM guard is disabled there.
if not on_zerogpu():
MemoryGuard(hard_gb=float(os.environ.get("QVS_MEMGUARD_HARD", "76")),
soft_gb=float(os.environ.get("QVS_MEMGUARD_SOFT", "72"))).start()
# ---- helpers -----------------------------------------------------------------
def meter_html() -> str:
if SELECTED_ADAPTER["source"]:
lora = f' · LoRA {SELECTED_ADAPTER["source"].split("/")[-1]}'
else:
lora = ""
if on_zerogpu():
mem = "MEM ZeroGPU" # container RAM is not the constraint here
else:
snap = snapshot()
mem = f"MEM {snap.committed:.0f}/{snap.total:.0f} GB"
return (
f'
DEVICE {target_device()} · DTYPE bf16 · '
f'ATTN {get_attn_impl()} · {mem} · '
f'RESIDENT {len(REG.loaded)}/3{lora}
'
)
def gp(a) -> engine.GenParams:
return engine.GenParams(
temperature=float(a[0]), top_p=float(a[1]), top_k=int(a[2]), repetition_penalty=float(a[3]),
subtalker_dosample=bool(a[4]),
subtalker_temperature=float(a[5]), subtalker_top_p=float(a[6]), subtalker_top_k=int(a[7]),
max_new_tokens=int(a[8]), seed=int(a[9]),
)
def advanced_controls():
d = config.GEN_DEFAULTS
with gr.Accordion("Advanced — sampling & sub-talker", open=False):
with gr.Row():
temperature = gr.Slider(0.0, 1.5, d.temperature, step=0.05, label="Temperature")
top_p = gr.Slider(0.0, 1.0, d.top_p, step=0.05, label="Top-p")
top_k = gr.Slider(0, 100, d.top_k, step=1, label="Top-k")
repetition_penalty = gr.Slider(1.0, 2.0, d.repetition_penalty, step=0.01, label="Repetition penalty")
with gr.Row():
st_dosample = gr.Checkbox(True, label="Sub-talker sampling")
st_temp = gr.Slider(0.0, 1.5, d.subtalker_temperature, step=0.05, label="Sub-talker temp")
st_top_p = gr.Slider(0.0, 1.0, d.subtalker_top_p, step=0.05, label="Sub-talker top-p")
st_top_k = gr.Slider(0, 100, d.subtalker_top_k, step=1, label="Sub-talker top-k")
with gr.Row():
max_new = gr.Slider(128, 4096, d.max_new_tokens, step=64, label="Max new tokens")
seed = gr.Number(d.seed, precision=0, label="Seed (-1 = random)")
return [temperature, top_p, top_k, repetition_penalty, st_dosample, st_temp, st_top_p, st_top_k, max_new, seed]
def status_line(msg: str, hot: bool = False) -> str:
return f'{msg}
'
def _done(t0: float, wav) -> str:
return status_line(f"done · {len(wav)/config.OUTPUT_SAMPLE_RATE:.1f}s audio in {time.time()-t0:.1f}s")
def _adapter_report(info) -> str:
warn = ' · ⚠ base mismatch' if info.base_mismatch else ""
emb = " · ships a voice" if info.has_speaker_embedding else ""
return status_line(
f"attached {info.source.split('/')[-1]} · r={info.r} α={info.alpha} · "
f"{info.n_modules} modules on {', '.join(t.replace('_proj','') for t in (info.target_modules or []))}{emb}{warn}"
)
# ---- callbacks (decorated for ZeroGPU; no-op locally) ------------------------
@gpu(duration=120)
def do_preset(text, speaker, instruct, language, longform, *adv):
if not (text or "").strip():
return None, status_line("Enter some text to speak.", hot=True), meter_html()
t0 = time.time()
model = REG.to_device("custom_voice")
wav, sr = engine.synth_custom_voice(model, text.strip(), speaker, instruct, config.LANGUAGES[language], gp(adv), bool(longform))
return qaudio.to_gradio(wav, sr), _done(t0, wav), meter_html()
@gpu(duration=120)
def do_design(text, instruct, language, longform, *adv):
if not (text or "").strip():
return None, status_line("Enter some text to speak.", hot=True), meter_html()
if not (instruct or "").strip():
return None, status_line("Describe the voice you want to design.", hot=True), meter_html()
t0 = time.time()
model = REG.to_device("voice_design")
wav, sr = engine.synth_voice_design(model, text.strip(), instruct.strip(), config.LANGUAGES[language], gp(adv), bool(longform))
return qaudio.to_gradio(wav, sr), _done(t0, wav), meter_html()
@gpu(duration=120)
def do_clone(ref_audio, ref_text, xvec, voice_pick, adapter_source, text, language, longform, *adv):
if not (text or "").strip():
return None, status_line("Enter text to synthesize.", hot=True), meter_html()
t0 = time.time()
model = REG.to_device("base")
lora = None
try:
if (adapter_source or "").strip():
try:
lora = _apply_adapter(model, adapter_source)
except Exception as e:
return None, status_line(f"adapter error: {type(e).__name__}: {e}", hot=True), meter_html()
if voice_pick and voice_pick != NONE_VOICE:
items = voices.load_voice(voice_pick)
wav, sr = engine.synth_clone(model, text.strip(), config.LANGUAGES[language], gp(adv),
voice_clone_prompt=items, longform=bool(longform))
else:
ref = qaudio.ref_from_gradio(ref_audio)
if ref is None:
return None, status_line("Upload reference audio or pick a saved voice.", hot=True), meter_html()
if not xvec and not (ref_text or "").strip():
return None, status_line("Add the reference transcript, or enable x-vector-only.", hot=True), meter_html()
wav, sr = engine.synth_clone(model, text.strip(), config.LANGUAGES[language], gp(adv),
ref_audio=ref, ref_text=(ref_text or None), x_vector_only=bool(xvec), longform=bool(longform))
finally:
if lora is not None:
try:
lora.unload(model)
except Exception:
pass
return qaudio.to_gradio(wav, sr), _done(t0, wav), meter_html()
@gpu(duration=120)
def do_library_gen(voice_name, text, language, longform, *adv):
if not voice_name or voice_name == NONE_VOICE:
return None, status_line("Pick a saved voice.", hot=True), meter_html()
if not (text or "").strip():
return None, status_line("Enter text to speak.", hot=True), meter_html()
t0 = time.time()
items = voices.load_voice(voice_name)
model = REG.to_device("base")
wav, sr = engine.synth_clone(model, text.strip(), config.LANGUAGES[language], gp(adv),
voice_clone_prompt=items, longform=bool(longform))
return qaudio.to_gradio(wav, sr), _done(t0, wav), meter_html()
@gpu(duration=120)
def do_lora_quicktest(source, sentence):
src = (source or "").strip()
if not src:
return None, status_line("Enter an adapter (repo id or path) above first.", hot=True)
emb = load_speaker_embedding(src)
if emb is None:
return None, status_line("This adapter ships no voice — use it in the Clone tab with your own reference.", hot=True)
import torch
from qwen_tts import VoiceClonePromptItem
model = REG.to_device("base")
lora = None
try:
lora = _apply_adapter(model, src)
item = VoiceClonePromptItem(ref_code=None,
ref_spk_embedding=torch.as_tensor(emb).to(model.device).to(torch.bfloat16),
x_vector_only_mode=True, icl_mode=False, ref_text=None)
wav, sr = engine.synth_clone(model, sentence.strip() or "Hello from the adapter.", "Auto",
engine.GenParams(max_new_tokens=512), voice_clone_prompt=[item], longform=False)
return qaudio.to_gradio(wav, sr), status_line("quick test done")
except Exception as e:
return None, status_line(f"quick test failed: {type(e).__name__}: {e}", hot=True)
finally:
if lora is not None:
try:
lora.unload(model)
except Exception:
pass
# management callbacks — validate/inspect only (adapter is applied per generation)
def do_apply_lora(source):
src = (source or "").strip()
if not src:
return status_line("Enter a Hugging Face repo id or local path.", hot=True), meter_html()
try:
from qvs.lora import read_adapter_config, resolve_adapter
cfg = read_adapter_config(resolve_adapter(src))
except Exception as e:
return status_line(f"Couldn't load adapter: {type(e).__name__}: {e}", hot=True), meter_html()
SELECTED_ADAPTER["source"] = src
has_emb = load_speaker_embedding(src) is not None
targets = ", ".join(t.replace("_proj", "") for t in (cfg.get("target_modules") or []))
emb = " · ships a voice" if has_emb else ""
return (status_line(f"selected {src.split('/')[-1]} · r={cfg.get('r')} α={cfg.get('lora_alpha')} · "
f"{targets}{emb} — applied per generation (Clone tab or Quick test)"), meter_html())
def do_unload_lora():
SELECTED_ADAPTER["source"] = ""
return status_line("adapter cleared."), meter_html()
@gpu(duration=90)
def do_save_voice(name, ref_audio, ref_text, xvec):
if not (name or "").strip():
return status_line("Give the voice a name.", hot=True)
ref = qaudio.ref_from_gradio(ref_audio)
if ref is None:
return status_line("Upload reference audio to save.", hot=True)
if not xvec and not (ref_text or "").strip():
return status_line("Reference transcript required (or enable x-vector-only).", hot=True)
voices.save_voice(REG.to_device("base"), name.strip(), ref, (ref_text or None), bool(xvec))
return status_line(f'saved voice "{name.strip()}"')
def do_lora_voice_to_library(source, name):
emb = load_speaker_embedding((source or "").strip()) if source else None
if emb is None:
return status_line("This adapter ships no speaker embedding.", hot=True)
voices.save_voice_from_embedding((name or "lora_voice").strip(), emb, note=f"from {source}")
return status_line(f'saved "{(name or "lora_voice").strip()}" to library')
@gpu(duration=90)
def do_design_to_library(design_audio, design_text, name):
if design_audio is None:
return status_line("Generate a designed voice first.", hot=True)
if not (name or "").strip():
return status_line("Name the voice to save it.", hot=True)
sr, data = design_audio
ref = (np.asarray(data, dtype=np.float32), int(sr))
voices.save_voice(REG.to_device("base"), name.strip(), ref, (design_text or None), x_vector_only=False,
note="from Voice Design")
return status_line(f'saved designed voice "{name.strip()}" — use it in Clone or Voice Library')
# ---- UI ----------------------------------------------------------------------
def build() -> gr.Blocks:
with gr.Blocks(title="Qwen Voice Studio", analytics_enabled=False) as demo:
gr.HTML(theme.banner_html())
gr.HTML(theme.header_html())
meter = gr.HTML(meter_html())
voice_pickers: list = [] # refreshed together on save
with gr.Tabs():
# ---- Clone ----
with gr.Tab("Clone"):
gr.HTML('01 clone a voice from a few seconds of audio
')
with gr.Row():
with gr.Column():
c_ref = gr.Audio(label="Reference audio", type="numpy", sources=["upload", "microphone"])
c_reftext = gr.Textbox(label="Reference transcript", lines=2, placeholder="What the reference says (improves fidelity).")
c_xvec = gr.Checkbox(False, label="x-vector only (skip transcript, lower fidelity)")
c_voice = gr.Dropdown([NONE_VOICE] + voices.list_voices(), value=NONE_VOICE, label="…or use a saved voice")
c_adapter = gr.Textbox(label="LoRA adapter (optional — HF repo id)", placeholder="e.g. loubna1101/Qwen3-TTS-Darija-LoRa")
c_text = gr.Textbox(label="Text to speak", lines=4, placeholder="Type what the cloned voice should say…")
c_lang = gr.Dropdown(LANG_CHOICES, value="Auto (detect)", label="Language")
c_long = gr.Checkbox(True, label="Long-form chunking")
c_adv = advanced_controls()
c_btn = gr.Button("Clone & Speak", variant="primary", elem_classes="qvs-generate")
with gr.Column():
c_out = gr.Audio(label="Output", type="numpy", interactive=False)
c_status = gr.HTML(status_line("Ready."))
voice_pickers.append(c_voice)
c_btn.click(do_clone, [c_ref, c_reftext, c_xvec, c_voice, c_adapter, c_text, c_lang, c_long, *c_adv], [c_out, c_status, meter])
# ---- Preset Voices ----
with gr.Tab("Preset Voices"):
gr.HTML('02 nine studio voices, directed by plain language
')
with gr.Row():
with gr.Column():
p_text = gr.Textbox(label="Text to speak", lines=4, placeholder="Type what to say…")
with gr.Row():
p_speaker = gr.Dropdown(SPEAKER_CHOICES, value="Ryan", label="Voice")
p_lang = gr.Dropdown(LANG_CHOICES, value="Auto (detect)", label="Language")
p_instruct = gr.Textbox(label="Direction (optional)", lines=2, placeholder="e.g. Very happy · Whisper softly · Angry and forceful")
p_examples = gr.Dropdown(["—"] + config.EMOTION_PRESETS, value="—", label="Quick directions")
p_long = gr.Checkbox(True, label="Long-form chunking")
p_adv = advanced_controls()
p_btn = gr.Button("Speak", variant="primary", elem_classes="qvs-generate")
with gr.Column():
p_out = gr.Audio(label="Output", type="numpy", interactive=False)
p_status = gr.HTML(status_line("Ready."))
p_examples.change(lambda x: "" if x == "—" else x, p_examples, p_instruct)
p_btn.click(do_preset, [p_text, p_speaker, p_instruct, p_lang, p_long, *p_adv], [p_out, p_status, meter])
# ---- Voice Design ----
with gr.Tab("Voice Design"):
gr.HTML('03 invent a voice from a written description
')
with gr.Row():
with gr.Column():
d_text = gr.Textbox(label="Text to speak", lines=4, value="It's in the top drawer… wait, it's empty? No way, that's impossible!")
d_instruct = gr.Textbox(label="Voice description", lines=3, placeholder="Describe the timbre, age, emotion, pace…")
d_examples = gr.Dropdown(["—"] + config.VOICE_DESIGN_EXAMPLES, value="—", label="Example descriptions")
d_lang = gr.Dropdown(LANG_CHOICES, value="Auto (detect)", label="Language")
d_long = gr.Checkbox(True, label="Long-form chunking")
d_adv = advanced_controls()
d_btn = gr.Button("Design & Speak", variant="primary", elem_classes="qvs-generate")
with gr.Column():
d_out = gr.Audio(label="Output", type="numpy", interactive=False)
d_status = gr.HTML(status_line("Ready."))
gr.HTML('Design → Clone bridge — lock this voice in for reuse
')
with gr.Row():
d_savename = gr.Textbox(label="Save designed voice as", scale=2, placeholder="e.g. narrator")
d_save = gr.Button("Send to Library", variant="secondary", scale=1)
d_examples.change(lambda x: "" if x == "—" else x, d_examples, d_instruct)
d_btn.click(do_design, [d_text, d_instruct, d_lang, d_long, *d_adv], [d_out, d_status, meter])
# ---- LoRA Lab (management only) ----
with gr.Tab("LoRA Lab"):
gr.HTML('04 load a fine-tuned adapter onto the Base voice
')
with gr.Row():
with gr.Column():
l_src = gr.Textbox(label="Adapter (HF repo id or local path)", value="loubna1101/Qwen3-TTS-Darija-LoRa")
with gr.Row():
l_apply = gr.Button("Load & inspect", variant="primary", elem_classes="qvs-generate")
l_remove = gr.Button("Clear", variant="secondary")
gr.HTML('save the adapter\'s bundled voice to your library
')
with gr.Row():
l_vname = gr.Textbox(label="Save voice as", value="darija_voice", scale=2)
l_save = gr.Button("Save voice", variant="secondary", scale=1)
with gr.Column():
l_status = gr.HTML(status_line("No adapter applied. Base is clean."))
gr.HTML('quick test (uses the adapter\'s bundled voice)
')
l_testtext = gr.Textbox(label="Test sentence", value="Salam, hada ikhtibar dyal les voix.", lines=2)
l_testbtn = gr.Button("Quick test", variant="secondary")
l_testout = gr.Audio(label="Quick test output", type="numpy", interactive=False)
l_apply.click(do_apply_lora, [l_src], [l_status, meter])
l_remove.click(do_unload_lora, None, [l_status, meter])
l_testbtn.click(do_lora_quicktest, [l_src, l_testtext], [l_testout, l_status])
# ---- Voice Library ----
with gr.Tab("Voice Library"):
gr.HTML('05 save voices once, reuse them everywhere
')
with gr.Row():
with gr.Column():
gr.HTML('save a new voice from reference audio
')
v_name = gr.Textbox(label="Voice name", placeholder="e.g. narrator")
v_ref = gr.Audio(label="Reference audio", type="numpy", sources=["upload", "microphone"])
v_reftext = gr.Textbox(label="Reference transcript", lines=2)
v_xvec = gr.Checkbox(False, label="x-vector only")
v_save = gr.Button("Save to library", variant="secondary")
with gr.Column():
gr.HTML('speak with a saved voice
')
with gr.Row():
v_pick = gr.Dropdown([NONE_VOICE] + voices.list_voices(), value=NONE_VOICE, label="Saved voices", scale=3)
v_refresh = gr.Button("↻", variant="secondary", scale=1)
v_text = gr.Textbox(label="Text to speak", lines=3)
v_lang = gr.Dropdown(LANG_CHOICES, value="Auto (detect)", label="Language")
v_long = gr.Checkbox(True, label="Long-form chunking")
v_adv = advanced_controls()
v_btn = gr.Button("Speak", variant="primary", elem_classes="qvs-generate")
v_out = gr.Audio(label="Output", type="numpy", interactive=False)
v_status = gr.HTML(status_line("Ready."))
voice_pickers.append(v_pick)
# wire saves to refresh every voice picker (Clone + Library)
v_save.click(do_save_voice, [v_name, v_ref, v_reftext, v_xvec], [v_status]).then(
lambda: [gr.update(choices=[NONE_VOICE] + voices.list_voices()) for _ in voice_pickers], None, voice_pickers)
d_save.click(do_design_to_library, [d_out, d_text, d_savename], [d_status]).then(
lambda: [gr.update(choices=[NONE_VOICE] + voices.list_voices()) for _ in voice_pickers], None, voice_pickers)
l_save.click(do_lora_voice_to_library, [l_src, l_vname], [l_status]).then(
lambda: [gr.update(choices=[NONE_VOICE] + voices.list_voices()) for _ in voice_pickers], None, voice_pickers)
v_refresh.click(lambda: gr.update(choices=[NONE_VOICE] + voices.list_voices()), None, v_pick)
v_btn.click(do_library_gen, [v_pick, v_text, v_lang, v_long, *v_adv], [v_out, v_status, meter])
gr.HTML(theme.footer_html())
gr.Timer(4.0).tick(meter_html, None, meter)
return demo
if __name__ == "__main__":
demo = build()
demo.queue(default_concurrency_limit=1) # one model, one device — serialize (DESIGN §6)
launch_kwargs = dict(theme=theme.studio_theme(), css=theme.CSS, show_error=True, ssr_mode=False)
if on_zerogpu():
launch_kwargs["server_name"] = "0.0.0.0" # HF health check must reach the app
else:
launch_kwargs["server_name"] = os.environ.get("QVS_HOST", "127.0.0.1")
launch_kwargs["server_port"] = int(os.environ.get("QVS_PORT", "7860"))
demo.launch(**launch_kwargs)