LoRA per-request application (ZeroGPU forks don't persist model mutations)
Browse filesAdapter is applied inside each generation fork and unloaded after, instead of a
global apply+toggle (which crashed on ZeroGPU with CUDA-init-outside-fork and
wouldn't persist across forks anyway). Clone gets an optional adapter field;
LoRA Lab = inspect + self-contained quick-test; save-voice/design-bridge are now
GPU-decorated too.
app.py
CHANGED
|
@@ -21,7 +21,19 @@ from qvs.registry import ModelRegistry
|
|
| 21 |
from qvs.ui import theme
|
| 22 |
|
| 23 |
REG = ModelRegistry()
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
# NOTE: on ZeroGPU we deliberately do NOT preload at module level. A 14 GB
|
| 27 |
# download + all-3 load + tensor-packing at import overran the Space startup
|
|
@@ -43,9 +55,8 @@ if not on_zerogpu():
|
|
| 43 |
|
| 44 |
# ---- helpers -----------------------------------------------------------------
|
| 45 |
def meter_html() -> str:
|
| 46 |
-
if
|
| 47 |
-
|
| 48 |
-
lora = f' · LoRA <b>{MGR.info.source.split("/")[-1]}</b> ({state})'
|
| 49 |
else:
|
| 50 |
lora = ""
|
| 51 |
if on_zerogpu():
|
|
@@ -127,25 +138,36 @@ def do_design(text, instruct, language, longform, *adv):
|
|
| 127 |
|
| 128 |
|
| 129 |
@gpu(duration=120)
|
| 130 |
-
def do_clone(ref_audio, ref_text, xvec, voice_pick,
|
| 131 |
if not (text or "").strip():
|
| 132 |
return None, status_line("Enter text to synthesize.", hot=True), meter_html()
|
| 133 |
-
if MGR.info is not None:
|
| 134 |
-
MGR.set_enabled(bool(use_adapter))
|
| 135 |
t0 = time.time()
|
| 136 |
model = REG.to_device("base")
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
return qaudio.to_gradio(wav, sr), _done(t0, wav), meter_html()
|
| 150 |
|
| 151 |
|
|
@@ -163,47 +185,60 @@ def do_library_gen(voice_name, text, language, longform, *adv):
|
|
| 163 |
return qaudio.to_gradio(wav, sr), _done(t0, wav), meter_html()
|
| 164 |
|
| 165 |
|
| 166 |
-
@gpu(duration=
|
| 167 |
-
def do_lora_quicktest(sentence):
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
| 171 |
if emb is None:
|
| 172 |
-
return None, status_line("This adapter ships no voice —
|
| 173 |
import torch
|
| 174 |
from qwen_tts import VoiceClonePromptItem
|
| 175 |
model = REG.to_device("base")
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
|
| 184 |
-
#
|
| 185 |
def do_apply_lora(source):
|
| 186 |
-
|
| 187 |
-
|
|
|
|
| 188 |
try:
|
| 189 |
-
|
|
|
|
| 190 |
except Exception as e:
|
| 191 |
-
return status_line(f"Couldn't load adapter: {type(e).__name__}: {e}", hot=True), meter_html()
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
|
| 199 |
|
| 200 |
def do_unload_lora():
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
MGR.unload(REG.get("base"))
|
| 204 |
-
return status_line("removed adapter — Base restored"), meter_html(), gr.update(value=False)
|
| 205 |
|
| 206 |
|
|
|
|
| 207 |
def do_save_voice(name, ref_audio, ref_text, xvec):
|
| 208 |
if not (name or "").strip():
|
| 209 |
return status_line("Give the voice a name.", hot=True)
|
|
@@ -224,6 +259,7 @@ def do_lora_voice_to_library(source, name):
|
|
| 224 |
return status_line(f'saved "{(name or "lora_voice").strip()}" to library')
|
| 225 |
|
| 226 |
|
|
|
|
| 227 |
def do_design_to_library(design_audio, design_text, name):
|
| 228 |
if design_audio is None:
|
| 229 |
return status_line("Generate a designed voice first.", hot=True)
|
|
@@ -253,7 +289,7 @@ def build() -> gr.Blocks:
|
|
| 253 |
c_reftext = gr.Textbox(label="Reference transcript", lines=2, placeholder="What the reference says (improves fidelity).")
|
| 254 |
c_xvec = gr.Checkbox(False, label="x-vector only (skip transcript, lower fidelity)")
|
| 255 |
c_voice = gr.Dropdown([NONE_VOICE] + voices.list_voices(), value=NONE_VOICE, label="…or use a saved voice")
|
| 256 |
-
|
| 257 |
c_text = gr.Textbox(label="Text to speak", lines=4, placeholder="Type what the cloned voice should say…")
|
| 258 |
c_lang = gr.Dropdown(LANG_CHOICES, value="Auto (detect)", label="Language")
|
| 259 |
c_long = gr.Checkbox(True, label="Long-form chunking")
|
|
@@ -263,7 +299,7 @@ def build() -> gr.Blocks:
|
|
| 263 |
c_out = gr.Audio(label="Output", type="numpy", interactive=False)
|
| 264 |
c_status = gr.HTML(status_line("Ready."))
|
| 265 |
voice_pickers.append(c_voice)
|
| 266 |
-
c_btn.click(do_clone, [c_ref, c_reftext, c_xvec, c_voice,
|
| 267 |
|
| 268 |
# ---- Preset Voices ----
|
| 269 |
with gr.Tab("Preset Voices"):
|
|
@@ -314,9 +350,8 @@ def build() -> gr.Blocks:
|
|
| 314 |
with gr.Column():
|
| 315 |
l_src = gr.Textbox(label="Adapter (HF repo id or local path)", value="loubna1101/Qwen3-TTS-Darija-LoRa")
|
| 316 |
with gr.Row():
|
| 317 |
-
l_apply = gr.Button("
|
| 318 |
-
|
| 319 |
-
l_remove = gr.Button("Remove", variant="secondary")
|
| 320 |
gr.HTML('<div class="qvs-eyebrow">save the adapter\'s bundled voice to your library</div>')
|
| 321 |
with gr.Row():
|
| 322 |
l_vname = gr.Textbox(label="Save voice as", value="darija_voice", scale=2)
|
|
@@ -327,10 +362,9 @@ def build() -> gr.Blocks:
|
|
| 327 |
l_testtext = gr.Textbox(label="Test sentence", value="Salam, hada ikhtibar dyal les voix.", lines=2)
|
| 328 |
l_testbtn = gr.Button("Quick test", variant="secondary")
|
| 329 |
l_testout = gr.Audio(label="Quick test output", type="numpy", interactive=False)
|
| 330 |
-
l_apply.click(do_apply_lora, [l_src], [l_status, meter
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
l_testbtn.click(do_lora_quicktest, [l_testtext], [l_testout, l_status])
|
| 334 |
|
| 335 |
# ---- Voice Library ----
|
| 336 |
with gr.Tab("Voice Library"):
|
|
|
|
| 21 |
from qvs.ui import theme
|
| 22 |
|
| 23 |
REG = ModelRegistry()
|
| 24 |
+
# LoRA is applied per-generation *inside* the @spaces.GPU fork: ZeroGPU forks do
|
| 25 |
+
# not persist in-place model mutations across requests, so "apply once, use later"
|
| 26 |
+
# can't work there. We track only the selected adapter here (a plain string).
|
| 27 |
+
SELECTED_ADAPTER = {"source": ""}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _apply_adapter(model, source: str):
|
| 31 |
+
"""Attach the adapter to a fresh manager (caller must .unload after gen)."""
|
| 32 |
+
if not (source or "").strip():
|
| 33 |
+
return None
|
| 34 |
+
mgr = AdapterManager()
|
| 35 |
+
mgr.apply(model, source.strip())
|
| 36 |
+
return mgr
|
| 37 |
|
| 38 |
# NOTE: on ZeroGPU we deliberately do NOT preload at module level. A 14 GB
|
| 39 |
# download + all-3 load + tensor-packing at import overran the Space startup
|
|
|
|
| 55 |
|
| 56 |
# ---- helpers -----------------------------------------------------------------
|
| 57 |
def meter_html() -> str:
|
| 58 |
+
if SELECTED_ADAPTER["source"]:
|
| 59 |
+
lora = f' · LoRA <b>{SELECTED_ADAPTER["source"].split("/")[-1]}</b>'
|
|
|
|
| 60 |
else:
|
| 61 |
lora = ""
|
| 62 |
if on_zerogpu():
|
|
|
|
| 138 |
|
| 139 |
|
| 140 |
@gpu(duration=120)
|
| 141 |
+
def do_clone(ref_audio, ref_text, xvec, voice_pick, adapter_source, text, language, longform, *adv):
|
| 142 |
if not (text or "").strip():
|
| 143 |
return None, status_line("Enter text to synthesize.", hot=True), meter_html()
|
|
|
|
|
|
|
| 144 |
t0 = time.time()
|
| 145 |
model = REG.to_device("base")
|
| 146 |
+
lora = None
|
| 147 |
+
try:
|
| 148 |
+
if (adapter_source or "").strip():
|
| 149 |
+
try:
|
| 150 |
+
lora = _apply_adapter(model, adapter_source)
|
| 151 |
+
except Exception as e:
|
| 152 |
+
return None, status_line(f"adapter error: {type(e).__name__}: {e}", hot=True), meter_html()
|
| 153 |
+
if voice_pick and voice_pick != NONE_VOICE:
|
| 154 |
+
items = voices.load_voice(voice_pick)
|
| 155 |
+
wav, sr = engine.synth_clone(model, text.strip(), config.LANGUAGES[language], gp(adv),
|
| 156 |
+
voice_clone_prompt=items, longform=bool(longform))
|
| 157 |
+
else:
|
| 158 |
+
ref = qaudio.ref_from_gradio(ref_audio)
|
| 159 |
+
if ref is None:
|
| 160 |
+
return None, status_line("Upload reference audio or pick a saved voice.", hot=True), meter_html()
|
| 161 |
+
if not xvec and not (ref_text or "").strip():
|
| 162 |
+
return None, status_line("Add the reference transcript, or enable x-vector-only.", hot=True), meter_html()
|
| 163 |
+
wav, sr = engine.synth_clone(model, text.strip(), config.LANGUAGES[language], gp(adv),
|
| 164 |
+
ref_audio=ref, ref_text=(ref_text or None), x_vector_only=bool(xvec), longform=bool(longform))
|
| 165 |
+
finally:
|
| 166 |
+
if lora is not None:
|
| 167 |
+
try:
|
| 168 |
+
lora.unload(model)
|
| 169 |
+
except Exception:
|
| 170 |
+
pass
|
| 171 |
return qaudio.to_gradio(wav, sr), _done(t0, wav), meter_html()
|
| 172 |
|
| 173 |
|
|
|
|
| 185 |
return qaudio.to_gradio(wav, sr), _done(t0, wav), meter_html()
|
| 186 |
|
| 187 |
|
| 188 |
+
@gpu(duration=120)
|
| 189 |
+
def do_lora_quicktest(source, sentence):
|
| 190 |
+
src = (source or "").strip()
|
| 191 |
+
if not src:
|
| 192 |
+
return None, status_line("Enter an adapter (repo id or path) above first.", hot=True)
|
| 193 |
+
emb = load_speaker_embedding(src)
|
| 194 |
if emb is None:
|
| 195 |
+
return None, status_line("This adapter ships no voice — use it in the Clone tab with your own reference.", hot=True)
|
| 196 |
import torch
|
| 197 |
from qwen_tts import VoiceClonePromptItem
|
| 198 |
model = REG.to_device("base")
|
| 199 |
+
lora = None
|
| 200 |
+
try:
|
| 201 |
+
lora = _apply_adapter(model, src)
|
| 202 |
+
item = VoiceClonePromptItem(ref_code=None,
|
| 203 |
+
ref_spk_embedding=torch.as_tensor(emb).to(model.device).to(torch.bfloat16),
|
| 204 |
+
x_vector_only_mode=True, icl_mode=False, ref_text=None)
|
| 205 |
+
wav, sr = engine.synth_clone(model, sentence.strip() or "Hello from the adapter.", "Auto",
|
| 206 |
+
engine.GenParams(max_new_tokens=512), voice_clone_prompt=[item], longform=False)
|
| 207 |
+
return qaudio.to_gradio(wav, sr), status_line("quick test done")
|
| 208 |
+
except Exception as e:
|
| 209 |
+
return None, status_line(f"quick test failed: {type(e).__name__}: {e}", hot=True)
|
| 210 |
+
finally:
|
| 211 |
+
if lora is not None:
|
| 212 |
+
try:
|
| 213 |
+
lora.unload(model)
|
| 214 |
+
except Exception:
|
| 215 |
+
pass
|
| 216 |
|
| 217 |
|
| 218 |
+
# management callbacks — validate/inspect only (adapter is applied per generation)
|
| 219 |
def do_apply_lora(source):
|
| 220 |
+
src = (source or "").strip()
|
| 221 |
+
if not src:
|
| 222 |
+
return status_line("Enter a Hugging Face repo id or local path.", hot=True), meter_html()
|
| 223 |
try:
|
| 224 |
+
from qvs.lora import read_adapter_config, resolve_adapter
|
| 225 |
+
cfg = read_adapter_config(resolve_adapter(src))
|
| 226 |
except Exception as e:
|
| 227 |
+
return status_line(f"Couldn't load adapter: {type(e).__name__}: {e}", hot=True), meter_html()
|
| 228 |
+
SELECTED_ADAPTER["source"] = src
|
| 229 |
+
has_emb = load_speaker_embedding(src) is not None
|
| 230 |
+
targets = ", ".join(t.replace("_proj", "") for t in (cfg.get("target_modules") or []))
|
| 231 |
+
emb = " · ships a voice" if has_emb else ""
|
| 232 |
+
return (status_line(f"selected <b>{src.split('/')[-1]}</b> · r={cfg.get('r')} α={cfg.get('lora_alpha')} · "
|
| 233 |
+
f"{targets}{emb} — applied per generation (Clone tab or Quick test)"), meter_html())
|
| 234 |
|
| 235 |
|
| 236 |
def do_unload_lora():
|
| 237 |
+
SELECTED_ADAPTER["source"] = ""
|
| 238 |
+
return status_line("adapter cleared."), meter_html()
|
|
|
|
|
|
|
| 239 |
|
| 240 |
|
| 241 |
+
@gpu(duration=90)
|
| 242 |
def do_save_voice(name, ref_audio, ref_text, xvec):
|
| 243 |
if not (name or "").strip():
|
| 244 |
return status_line("Give the voice a name.", hot=True)
|
|
|
|
| 259 |
return status_line(f'saved "{(name or "lora_voice").strip()}" to library')
|
| 260 |
|
| 261 |
|
| 262 |
+
@gpu(duration=90)
|
| 263 |
def do_design_to_library(design_audio, design_text, name):
|
| 264 |
if design_audio is None:
|
| 265 |
return status_line("Generate a designed voice first.", hot=True)
|
|
|
|
| 289 |
c_reftext = gr.Textbox(label="Reference transcript", lines=2, placeholder="What the reference says (improves fidelity).")
|
| 290 |
c_xvec = gr.Checkbox(False, label="x-vector only (skip transcript, lower fidelity)")
|
| 291 |
c_voice = gr.Dropdown([NONE_VOICE] + voices.list_voices(), value=NONE_VOICE, label="…or use a saved voice")
|
| 292 |
+
c_adapter = gr.Textbox(label="LoRA adapter (optional — HF repo id)", placeholder="e.g. loubna1101/Qwen3-TTS-Darija-LoRa")
|
| 293 |
c_text = gr.Textbox(label="Text to speak", lines=4, placeholder="Type what the cloned voice should say…")
|
| 294 |
c_lang = gr.Dropdown(LANG_CHOICES, value="Auto (detect)", label="Language")
|
| 295 |
c_long = gr.Checkbox(True, label="Long-form chunking")
|
|
|
|
| 299 |
c_out = gr.Audio(label="Output", type="numpy", interactive=False)
|
| 300 |
c_status = gr.HTML(status_line("Ready."))
|
| 301 |
voice_pickers.append(c_voice)
|
| 302 |
+
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])
|
| 303 |
|
| 304 |
# ---- Preset Voices ----
|
| 305 |
with gr.Tab("Preset Voices"):
|
|
|
|
| 350 |
with gr.Column():
|
| 351 |
l_src = gr.Textbox(label="Adapter (HF repo id or local path)", value="loubna1101/Qwen3-TTS-Darija-LoRa")
|
| 352 |
with gr.Row():
|
| 353 |
+
l_apply = gr.Button("Load & inspect", variant="primary", elem_classes="qvs-generate")
|
| 354 |
+
l_remove = gr.Button("Clear", variant="secondary")
|
|
|
|
| 355 |
gr.HTML('<div class="qvs-eyebrow">save the adapter\'s bundled voice to your library</div>')
|
| 356 |
with gr.Row():
|
| 357 |
l_vname = gr.Textbox(label="Save voice as", value="darija_voice", scale=2)
|
|
|
|
| 362 |
l_testtext = gr.Textbox(label="Test sentence", value="Salam, hada ikhtibar dyal les voix.", lines=2)
|
| 363 |
l_testbtn = gr.Button("Quick test", variant="secondary")
|
| 364 |
l_testout = gr.Audio(label="Quick test output", type="numpy", interactive=False)
|
| 365 |
+
l_apply.click(do_apply_lora, [l_src], [l_status, meter])
|
| 366 |
+
l_remove.click(do_unload_lora, None, [l_status, meter])
|
| 367 |
+
l_testbtn.click(do_lora_quicktest, [l_src, l_testtext], [l_testout, l_status])
|
|
|
|
| 368 |
|
| 369 |
# ---- Voice Library ----
|
| 370 |
with gr.Tab("Voice Library"):
|