File size: 21,645 Bytes
77f7dfd c3b59db 77f7dfd f357e40 c3b59db 77f7dfd c3b59db 77f7dfd f357e40 c3b59db 77f7dfd c8552a7 77f7dfd c3b59db c8552a7 77f7dfd c8552a7 c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd c3b59db 77f7dfd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 | """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 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()
MGR = AdapterManager()
# ZeroGPU: place all three checkpoints on CUDA at module level (emulation mode),
# per the ZeroGPU guidance — never lazy-move inside @spaces.GPU.
if on_zerogpu():
REG.preload_all()
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 MGR.info:
state = "on" if MGR.info.enabled else "off"
lora = f' · LoRA <b>{MGR.info.source.split("/")[-1]}</b> ({state})'
else:
lora = ""
if on_zerogpu():
mem = "MEM <b>ZeroGPU</b>" # container RAM is not the constraint here
else:
snap = snapshot()
mem = f"MEM <b>{snap.committed:.0f}</b>/{snap.total:.0f} GB"
return (
f'<div class="qvs-meter">DEVICE <b>{target_device()}</b> · DTYPE <b>bf16</b> · '
f'ATTN <b>{get_attn_impl()}</b> · {mem} · '
f'RESIDENT <b>{len(REG.loaded)}</b>/3{lora}</div>'
)
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_temperature=float(a[4]), subtalker_top_p=float(a[5]), subtalker_top_k=int(a[6]),
max_new_tokens=int(a[7]), seed=int(a[8]),
)
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_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_temp, st_top_p, st_top_k, max_new, seed]
def status_line(msg: str, hot: bool = False) -> str:
return f'<div class="qvs-status {"on" if hot else ""}">{msg}</div>'
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 = ' · <span style="color:#FF6B4A">⚠ base mismatch</span>' if info.base_mismatch else ""
emb = " · ships a voice" if info.has_speaker_embedding else ""
return status_line(
f"attached <b>{info.source.split('/')[-1]}</b> · 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, use_adapter, text, language, longform, *adv):
if not (text or "").strip():
return None, status_line("Enter text to synthesize.", hot=True), meter_html()
if MGR.info is not None:
MGR.set_enabled(bool(use_adapter))
t0 = time.time()
model = REG.to_device("base")
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))
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=90)
def do_lora_quicktest(sentence):
if not MGR.info:
return None, status_line("Apply an adapter first.", hot=True)
emb = load_speaker_embedding(MGR.info.source)
if emb is None:
return None, status_line("This adapter ships no voice — test it from the Clone tab with your own reference.", hot=True)
import torch
from qwen_tts import VoiceClonePromptItem
model = REG.to_device("base")
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")
# non-GPU management callbacks
def do_apply_lora(source):
if not (source or "").strip():
return status_line("Enter a Hugging Face repo id or local path.", hot=True), meter_html(), gr.update()
try:
info = MGR.apply(REG.to_device("base"), source.strip())
except Exception as e:
return status_line(f"Couldn't load adapter: {type(e).__name__}: {e}", hot=True), meter_html(), gr.update()
return _adapter_report(info), meter_html(), gr.update(value=True, interactive=True)
def do_toggle_lora(enabled):
MGR.set_enabled(bool(enabled))
return meter_html()
def do_unload_lora():
if not MGR.info:
return status_line("No adapter applied."), meter_html(), gr.update(value=False)
MGR.unload(REG.get("base"))
return status_line("removed adapter — Base restored"), meter_html(), gr.update(value=False)
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')
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.header_html())
meter = gr.HTML(meter_html())
voice_pickers: list = [] # refreshed together on save
with gr.Tabs():
# ---- Clone ----
with gr.Tab("Clone"):
gr.HTML('<div class="qvs-eyebrow"><span class="num">01</span> clone a voice from a few seconds of audio</div>')
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_useadapter = gr.Checkbox(False, label="Apply active LoRA adapter (manage in LoRA Lab)")
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_useadapter, c_text, c_lang, c_long, *c_adv], [c_out, c_status, meter])
# ---- Preset Voices ----
with gr.Tab("Preset Voices"):
gr.HTML('<div class="qvs-eyebrow"><span class="num">02</span> nine studio voices, directed by plain language</div>')
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('<div class="qvs-eyebrow"><span class="num">03</span> invent a voice from a written description</div>')
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('<div class="qvs-eyebrow">Design → Clone bridge — lock this voice in for reuse</div>')
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('<div class="qvs-eyebrow"><span class="num">04</span> load a fine-tuned adapter onto the Base voice</div>')
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("Apply", variant="primary", elem_classes="qvs-generate")
l_toggle = gr.Checkbox(False, label="Adapter on", interactive=False)
l_remove = gr.Button("Remove", variant="secondary")
gr.HTML('<div class="qvs-eyebrow">save the adapter\'s bundled voice to your library</div>')
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('<div class="qvs-eyebrow">quick test (uses the adapter\'s bundled voice)</div>')
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_toggle])
l_toggle.change(do_toggle_lora, [l_toggle], [meter])
l_remove.click(do_unload_lora, None, [l_status, meter, l_toggle])
l_testbtn.click(do_lora_quicktest, [l_testtext], [l_testout, l_status])
# ---- Voice Library ----
with gr.Tab("Voice Library"):
gr.HTML('<div class="qvs-eyebrow"><span class="num">05</span> save voices once, reuse them everywhere</div>')
with gr.Row():
with gr.Column():
gr.HTML('<div class="qvs-eyebrow">save a new voice from reference audio</div>')
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('<div class="qvs-eyebrow">speak with a saved voice</div>')
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)
demo.launch(
theme=theme.studio_theme(),
css=theme.CSS,
server_name=os.environ.get("QVS_HOST", "127.0.0.1"),
server_port=int(os.environ.get("QVS_PORT", "7860")),
show_error=True,
)
|