LoRA live-toggle (PEFT enable/disable) + D4 UI + memory hygiene
Browse files- lora.py: AdapterManager with enable/disable_adapter_layers + unload (never merge);
DESIGN §8 acceptance verified (toggle bit-exact, unload restores base).
- app.py: 5-tab D4 layout — Design→Clone bridge, LoRA Lab management-only with
live toggle + config inspect + quick-test, Clone adapter control + voice picker.
- engine.py: free_cache() after each generation; memory guard warn 72 / abort 76.
- app.py +149 -73
- qvs/engine.py +16 -0
- qvs/lora.py +74 -48
- qvs/memory.py +1 -1
app.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
"""Qwen Voice Studio — Gradio app (runs on local MPS and Hugging Face ZeroGPU).
|
| 2 |
|
| 3 |
Five channels over the three Qwen3-TTS-12Hz-1.7B checkpoints: Clone, Preset
|
| 4 |
-
Voices, Voice Design, LoRA Lab, and a Voice Library that ties
|
|
|
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
|
|
@@ -13,35 +14,40 @@ import numpy as np
|
|
| 13 |
|
| 14 |
from qvs import audio as qaudio
|
| 15 |
from qvs import config, engine, voices
|
| 16 |
-
from qvs.device import get_attn_impl, target_device
|
| 17 |
-
from qvs.lora import
|
| 18 |
-
from qvs.memory import snapshot
|
| 19 |
from qvs.registry import ModelRegistry
|
| 20 |
from qvs.ui import theme
|
| 21 |
|
| 22 |
REG = ModelRegistry()
|
| 23 |
-
|
| 24 |
|
|
|
|
| 25 |
LANG_CHOICES = list(config.LANGUAGES.keys())
|
| 26 |
SPEAKER_CHOICES = [(f"{s.display} — {s.description.rstrip('.')} ({s.language})", s.key) for s in config.SPEAKERS]
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
|
| 32 |
|
| 33 |
# ---- helpers -----------------------------------------------------------------
|
| 34 |
def meter_html() -> str:
|
| 35 |
snap = snapshot()
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
return (
|
| 38 |
f'<div class="qvs-meter">DEVICE <b>{target_device()}</b> · DTYPE <b>bf16</b> · '
|
| 39 |
-
f'ATTN <b>{get_attn_impl()}</b> · MEM <b>{snap.committed:.0f}</b>/{snap.total:.0f} GB
|
|
|
|
| 40 |
)
|
| 41 |
|
| 42 |
|
| 43 |
def gp(a) -> engine.GenParams:
|
| 44 |
-
"""Build GenParams from the 9 advanced-control values (positional)."""
|
| 45 |
return engine.GenParams(
|
| 46 |
temperature=float(a[0]), top_p=float(a[1]), top_k=int(a[2]), repetition_penalty=float(a[3]),
|
| 47 |
subtalker_temperature=float(a[4]), subtalker_top_p=float(a[5]), subtalker_top_k=int(a[6]),
|
|
@@ -50,7 +56,6 @@ def gp(a) -> engine.GenParams:
|
|
| 50 |
|
| 51 |
|
| 52 |
def advanced_controls():
|
| 53 |
-
"""Shared 'Advanced' rack. Returns the 9 components in GenParams order."""
|
| 54 |
d = config.GEN_DEFAULTS
|
| 55 |
with gr.Accordion("Advanced — sampling & sub-talker", open=False):
|
| 56 |
with gr.Row():
|
|
@@ -76,7 +81,17 @@ def _done(t0: float, wav) -> str:
|
|
| 76 |
return status_line(f"done · {len(wav)/config.OUTPUT_SAMPLE_RATE:.1f}s audio in {time.time()-t0:.1f}s")
|
| 77 |
|
| 78 |
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
def do_preset(text, speaker, instruct, language, longform, *adv):
|
| 81 |
if not (text or "").strip():
|
| 82 |
return None, status_line("Enter some text to speak.", hot=True), meter_html()
|
|
@@ -86,6 +101,7 @@ def do_preset(text, speaker, instruct, language, longform, *adv):
|
|
| 86 |
return qaudio.to_gradio(wav, sr), _done(t0, wav), meter_html()
|
| 87 |
|
| 88 |
|
|
|
|
| 89 |
def do_design(text, instruct, language, longform, *adv):
|
| 90 |
if not (text or "").strip():
|
| 91 |
return None, status_line("Enter some text to speak.", hot=True), meter_html()
|
|
@@ -97,36 +113,32 @@ def do_design(text, instruct, language, longform, *adv):
|
|
| 97 |
return qaudio.to_gradio(wav, sr), _done(t0, wav), meter_html()
|
| 98 |
|
| 99 |
|
| 100 |
-
|
|
|
|
| 101 |
if not (text or "").strip():
|
| 102 |
-
return None, status_line("Enter text to synthesize
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
return None, status_line("Upload or record reference audio first.", hot=True), meter_html()
|
| 106 |
-
if not xvec and not (ref_text or "").strip():
|
| 107 |
-
return None, status_line("Add the reference transcript, or enable x-vector-only mode.", hot=True), meter_html()
|
| 108 |
t0 = time.time()
|
| 109 |
model = REG.to_device("base")
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
return qaudio.to_gradio(wav, sr), _done(t0, wav), meter_html()
|
| 113 |
|
| 114 |
|
| 115 |
-
|
| 116 |
-
if not (name or "").strip():
|
| 117 |
-
return gr.update(), status_line("Give the voice a name.", hot=True)
|
| 118 |
-
ref = qaudio.ref_from_gradio(ref_audio)
|
| 119 |
-
if ref is None:
|
| 120 |
-
return gr.update(), status_line("Upload reference audio to save.", hot=True)
|
| 121 |
-
if not xvec and not (ref_text or "").strip():
|
| 122 |
-
return gr.update(), status_line("Reference transcript required (or enable x-vector-only).", hot=True)
|
| 123 |
-
model = REG.to_device("base")
|
| 124 |
-
voices.save_voice(model, name.strip(), ref, (ref_text or None), bool(xvec))
|
| 125 |
-
return gr.update(choices=voices.list_voices(), value=name.strip()), status_line(f'saved voice "{name.strip()}"')
|
| 126 |
-
|
| 127 |
-
|
| 128 |
def do_library_gen(voice_name, text, language, longform, *adv):
|
| 129 |
-
if not voice_name:
|
| 130 |
return None, status_line("Pick a saved voice.", hot=True), meter_html()
|
| 131 |
if not (text or "").strip():
|
| 132 |
return None, status_line("Enter text to speak.", hot=True), meter_html()
|
|
@@ -138,32 +150,77 @@ def do_library_gen(voice_name, text, language, longform, *adv):
|
|
| 138 |
return qaudio.to_gradio(wav, sr), _done(t0, wav), meter_html()
|
| 139 |
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
def do_apply_lora(source):
|
| 142 |
if not (source or "").strip():
|
| 143 |
-
return status_line("Enter a Hugging Face repo id or local path.", hot=True), meter_html()
|
| 144 |
try:
|
| 145 |
-
|
| 146 |
-
except Exception as e:
|
| 147 |
-
return status_line(f"Couldn't load adapter: {type(e).__name__}: {e}", hot=True), meter_html()
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
|
| 152 |
-
def
|
| 153 |
-
if not
|
| 154 |
-
return status_line("No adapter
|
| 155 |
-
REG.
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
|
| 160 |
def do_lora_voice_to_library(source, name):
|
| 161 |
emb = load_speaker_embedding((source or "").strip()) if source else None
|
| 162 |
if emb is None:
|
| 163 |
-
return
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
|
| 169 |
# ---- UI ----------------------------------------------------------------------
|
|
@@ -171,6 +228,7 @@ def build() -> gr.Blocks:
|
|
| 171 |
with gr.Blocks(title="Qwen Voice Studio", analytics_enabled=False) as demo:
|
| 172 |
gr.HTML(theme.header_html())
|
| 173 |
meter = gr.HTML(meter_html())
|
|
|
|
| 174 |
|
| 175 |
with gr.Tabs():
|
| 176 |
# ---- Clone ----
|
|
@@ -179,17 +237,20 @@ def build() -> gr.Blocks:
|
|
| 179 |
with gr.Row():
|
| 180 |
with gr.Column():
|
| 181 |
c_ref = gr.Audio(label="Reference audio", type="numpy", sources=["upload", "microphone"])
|
| 182 |
-
c_reftext = gr.Textbox(label="Reference transcript", lines=2, placeholder="What the reference
|
| 183 |
c_xvec = gr.Checkbox(False, label="x-vector only (skip transcript, lower fidelity)")
|
|
|
|
|
|
|
| 184 |
c_text = gr.Textbox(label="Text to speak", lines=4, placeholder="Type what the cloned voice should say…")
|
| 185 |
c_lang = gr.Dropdown(LANG_CHOICES, value="Auto (detect)", label="Language")
|
| 186 |
c_long = gr.Checkbox(True, label="Long-form chunking")
|
| 187 |
c_adv = advanced_controls()
|
| 188 |
c_btn = gr.Button("Clone & Speak", variant="primary", elem_classes="qvs-generate")
|
| 189 |
with gr.Column():
|
| 190 |
-
c_out = gr.Audio(label="Output", type="numpy", interactive=False
|
| 191 |
c_status = gr.HTML(status_line("Ready."))
|
| 192 |
-
|
|
|
|
| 193 |
|
| 194 |
# ---- Preset Voices ----
|
| 195 |
with gr.Tab("Preset Voices"):
|
|
@@ -226,28 +287,37 @@ def build() -> gr.Blocks:
|
|
| 226 |
with gr.Column():
|
| 227 |
d_out = gr.Audio(label="Output", type="numpy", interactive=False)
|
| 228 |
d_status = gr.HTML(status_line("Ready."))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
d_examples.change(lambda x: "" if x == "—" else x, d_examples, d_instruct)
|
| 230 |
d_btn.click(do_design, [d_text, d_instruct, d_lang, d_long, *d_adv], [d_out, d_status, meter])
|
| 231 |
|
| 232 |
-
# ---- LoRA Lab ----
|
| 233 |
with gr.Tab("LoRA Lab"):
|
| 234 |
-
gr.HTML('<div class="qvs-eyebrow"><span class="num">04</span> load a fine-tuned adapter onto the
|
| 235 |
with gr.Row():
|
| 236 |
with gr.Column():
|
| 237 |
-
l_src = gr.Textbox(label="Adapter (HF repo id or local path)", value="loubna1101/Qwen3-TTS-Darija-LoRa"
|
| 238 |
-
placeholder="e.g. loubna1101/Qwen3-TTS-Darija-LoRa")
|
| 239 |
with gr.Row():
|
| 240 |
-
l_apply = gr.Button("Apply
|
|
|
|
| 241 |
l_remove = gr.Button("Remove", variant="secondary")
|
| 242 |
-
gr.HTML('<div class="qvs-eyebrow">save the adapter\'s
|
| 243 |
with gr.Row():
|
| 244 |
l_vname = gr.Textbox(label="Save voice as", value="darija_voice", scale=2)
|
| 245 |
l_save = gr.Button("Save voice", variant="secondary", scale=1)
|
| 246 |
with gr.Column():
|
| 247 |
l_status = gr.HTML(status_line("No adapter applied. Base is clean."))
|
| 248 |
-
gr.
|
| 249 |
-
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
# ---- Voice Library ----
|
| 253 |
with gr.Tab("Voice Library"):
|
|
@@ -262,8 +332,9 @@ def build() -> gr.Blocks:
|
|
| 262 |
v_save = gr.Button("Save to library", variant="secondary")
|
| 263 |
with gr.Column():
|
| 264 |
gr.HTML('<div class="qvs-eyebrow">speak with a saved voice</div>')
|
| 265 |
-
|
| 266 |
-
|
|
|
|
| 267 |
v_text = gr.Textbox(label="Text to speak", lines=3)
|
| 268 |
v_lang = gr.Dropdown(LANG_CHOICES, value="Auto (detect)", label="Language")
|
| 269 |
v_long = gr.Checkbox(True, label="Long-form chunking")
|
|
@@ -271,21 +342,26 @@ def build() -> gr.Blocks:
|
|
| 271 |
v_btn = gr.Button("Speak", variant="primary", elem_classes="qvs-generate")
|
| 272 |
v_out = gr.Audio(label="Output", type="numpy", interactive=False)
|
| 273 |
v_status = gr.HTML(status_line("Ready."))
|
| 274 |
-
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
v_btn.click(do_library_gen, [v_pick, v_text, v_lang, v_long, *v_adv], [v_out, v_status, meter])
|
| 277 |
-
# LoRA -> library bridge lives here too
|
| 278 |
-
l_save.click(do_lora_voice_to_library, [l_src, l_vname], [v_pick, l_status])
|
| 279 |
|
| 280 |
gr.HTML(theme.footer_html())
|
| 281 |
-
|
| 282 |
-
timer.tick(meter_html, None, meter)
|
| 283 |
return demo
|
| 284 |
|
| 285 |
|
| 286 |
if __name__ == "__main__":
|
| 287 |
demo = build()
|
| 288 |
-
demo.queue(default_concurrency_limit=
|
| 289 |
demo.launch(
|
| 290 |
theme=theme.studio_theme(),
|
| 291 |
css=theme.CSS,
|
|
|
|
| 1 |
"""Qwen Voice Studio — Gradio app (runs on local MPS and Hugging Face ZeroGPU).
|
| 2 |
|
| 3 |
Five channels over the three Qwen3-TTS-12Hz-1.7B checkpoints: Clone, Preset
|
| 4 |
+
Voices, Voice Design, LoRA Lab (management only), and a Voice Library that ties
|
| 5 |
+
them together. One codebase, two platforms; see docs/DESIGN.md.
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
| 14 |
|
| 15 |
from qvs import audio as qaudio
|
| 16 |
from qvs import config, engine, voices
|
| 17 |
+
from qvs.device import get_attn_impl, gpu, target_device
|
| 18 |
+
from qvs.lora import AdapterManager, load_speaker_embedding
|
| 19 |
+
from qvs.memory import MemoryGuard, snapshot
|
| 20 |
from qvs.registry import ModelRegistry
|
| 21 |
from qvs.ui import theme
|
| 22 |
|
| 23 |
REG = ModelRegistry()
|
| 24 |
+
MGR = AdapterManager()
|
| 25 |
|
| 26 |
+
NONE_VOICE = "— none —"
|
| 27 |
LANG_CHOICES = list(config.LANGUAGES.keys())
|
| 28 |
SPEAKER_CHOICES = [(f"{s.display} — {s.description.rstrip('.')} ({s.language})", s.key) for s in config.SPEAKERS]
|
| 29 |
|
| 30 |
+
# Watchdog is active in every model-touching run (DESIGN §6).
|
| 31 |
+
MemoryGuard(hard_gb=float(os.environ.get("QVS_MEMGUARD_HARD", "76")),
|
| 32 |
+
soft_gb=float(os.environ.get("QVS_MEMGUARD_SOFT", "72"))).start()
|
| 33 |
|
| 34 |
|
| 35 |
# ---- helpers -----------------------------------------------------------------
|
| 36 |
def meter_html() -> str:
|
| 37 |
snap = snapshot()
|
| 38 |
+
if MGR.info:
|
| 39 |
+
state = "on" if MGR.info.enabled else "off"
|
| 40 |
+
lora = f' · LoRA <b>{MGR.info.source.split("/")[-1]}</b> ({state})'
|
| 41 |
+
else:
|
| 42 |
+
lora = ""
|
| 43 |
return (
|
| 44 |
f'<div class="qvs-meter">DEVICE <b>{target_device()}</b> · DTYPE <b>bf16</b> · '
|
| 45 |
+
f'ATTN <b>{get_attn_impl()}</b> · MEM <b>{snap.committed:.0f}</b>/{snap.total:.0f} GB · '
|
| 46 |
+
f'RESIDENT <b>{len(REG.loaded)}</b>/3{lora}</div>'
|
| 47 |
)
|
| 48 |
|
| 49 |
|
| 50 |
def gp(a) -> engine.GenParams:
|
|
|
|
| 51 |
return engine.GenParams(
|
| 52 |
temperature=float(a[0]), top_p=float(a[1]), top_k=int(a[2]), repetition_penalty=float(a[3]),
|
| 53 |
subtalker_temperature=float(a[4]), subtalker_top_p=float(a[5]), subtalker_top_k=int(a[6]),
|
|
|
|
| 56 |
|
| 57 |
|
| 58 |
def advanced_controls():
|
|
|
|
| 59 |
d = config.GEN_DEFAULTS
|
| 60 |
with gr.Accordion("Advanced — sampling & sub-talker", open=False):
|
| 61 |
with gr.Row():
|
|
|
|
| 81 |
return status_line(f"done · {len(wav)/config.OUTPUT_SAMPLE_RATE:.1f}s audio in {time.time()-t0:.1f}s")
|
| 82 |
|
| 83 |
|
| 84 |
+
def _adapter_report(info) -> str:
|
| 85 |
+
warn = ' · <span style="color:#FF6B4A">⚠ base mismatch</span>' if info.base_mismatch else ""
|
| 86 |
+
emb = " · ships a voice" if info.has_speaker_embedding else ""
|
| 87 |
+
return status_line(
|
| 88 |
+
f"attached <b>{info.source.split('/')[-1]}</b> · r={info.r} α={info.alpha} · "
|
| 89 |
+
f"{info.n_modules} modules on {', '.join(t.replace('_proj','') for t in (info.target_modules or []))}{emb}{warn}"
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ---- callbacks (decorated for ZeroGPU; no-op locally) ------------------------
|
| 94 |
+
@gpu(duration=120)
|
| 95 |
def do_preset(text, speaker, instruct, language, longform, *adv):
|
| 96 |
if not (text or "").strip():
|
| 97 |
return None, status_line("Enter some text to speak.", hot=True), meter_html()
|
|
|
|
| 101 |
return qaudio.to_gradio(wav, sr), _done(t0, wav), meter_html()
|
| 102 |
|
| 103 |
|
| 104 |
+
@gpu(duration=120)
|
| 105 |
def do_design(text, instruct, language, longform, *adv):
|
| 106 |
if not (text or "").strip():
|
| 107 |
return None, status_line("Enter some text to speak.", hot=True), meter_html()
|
|
|
|
| 113 |
return qaudio.to_gradio(wav, sr), _done(t0, wav), meter_html()
|
| 114 |
|
| 115 |
|
| 116 |
+
@gpu(duration=120)
|
| 117 |
+
def do_clone(ref_audio, ref_text, xvec, voice_pick, use_adapter, text, language, longform, *adv):
|
| 118 |
if not (text or "").strip():
|
| 119 |
+
return None, status_line("Enter text to synthesize.", hot=True), meter_html()
|
| 120 |
+
if MGR.info is not None:
|
| 121 |
+
MGR.set_enabled(bool(use_adapter))
|
|
|
|
|
|
|
|
|
|
| 122 |
t0 = time.time()
|
| 123 |
model = REG.to_device("base")
|
| 124 |
+
if voice_pick and voice_pick != NONE_VOICE:
|
| 125 |
+
items = voices.load_voice(voice_pick)
|
| 126 |
+
wav, sr = engine.synth_clone(model, text.strip(), config.LANGUAGES[language], gp(adv),
|
| 127 |
+
voice_clone_prompt=items, longform=bool(longform))
|
| 128 |
+
else:
|
| 129 |
+
ref = qaudio.ref_from_gradio(ref_audio)
|
| 130 |
+
if ref is None:
|
| 131 |
+
return None, status_line("Upload reference audio or pick a saved voice.", hot=True), meter_html()
|
| 132 |
+
if not xvec and not (ref_text or "").strip():
|
| 133 |
+
return None, status_line("Add the reference transcript, or enable x-vector-only.", hot=True), meter_html()
|
| 134 |
+
wav, sr = engine.synth_clone(model, text.strip(), config.LANGUAGES[language], gp(adv),
|
| 135 |
+
ref_audio=ref, ref_text=(ref_text or None), x_vector_only=bool(xvec), longform=bool(longform))
|
| 136 |
return qaudio.to_gradio(wav, sr), _done(t0, wav), meter_html()
|
| 137 |
|
| 138 |
|
| 139 |
+
@gpu(duration=120)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
def do_library_gen(voice_name, text, language, longform, *adv):
|
| 141 |
+
if not voice_name or voice_name == NONE_VOICE:
|
| 142 |
return None, status_line("Pick a saved voice.", hot=True), meter_html()
|
| 143 |
if not (text or "").strip():
|
| 144 |
return None, status_line("Enter text to speak.", hot=True), meter_html()
|
|
|
|
| 150 |
return qaudio.to_gradio(wav, sr), _done(t0, wav), meter_html()
|
| 151 |
|
| 152 |
|
| 153 |
+
@gpu(duration=90)
|
| 154 |
+
def do_lora_quicktest(sentence):
|
| 155 |
+
if not MGR.info:
|
| 156 |
+
return None, status_line("Apply an adapter first.", hot=True)
|
| 157 |
+
emb = load_speaker_embedding(MGR.info.source)
|
| 158 |
+
if emb is None:
|
| 159 |
+
return None, status_line("This adapter ships no voice — test it from the Clone tab with your own reference.", hot=True)
|
| 160 |
+
import torch
|
| 161 |
+
from qwen_tts import VoiceClonePromptItem
|
| 162 |
+
model = REG.to_device("base")
|
| 163 |
+
item = VoiceClonePromptItem(ref_code=None,
|
| 164 |
+
ref_spk_embedding=torch.as_tensor(emb).to(model.device).to(torch.bfloat16),
|
| 165 |
+
x_vector_only_mode=True, icl_mode=False, ref_text=None)
|
| 166 |
+
wav, sr = engine.synth_clone(model, sentence.strip() or "Hello from the adapter.", "Auto",
|
| 167 |
+
engine.GenParams(max_new_tokens=512), voice_clone_prompt=[item], longform=False)
|
| 168 |
+
return qaudio.to_gradio(wav, sr), status_line("quick test done")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# non-GPU management callbacks
|
| 172 |
def do_apply_lora(source):
|
| 173 |
if not (source or "").strip():
|
| 174 |
+
return status_line("Enter a Hugging Face repo id or local path.", hot=True), meter_html(), gr.update()
|
| 175 |
try:
|
| 176 |
+
info = MGR.apply(REG.to_device("base"), source.strip())
|
| 177 |
+
except Exception as e:
|
| 178 |
+
return status_line(f"Couldn't load adapter: {type(e).__name__}: {e}", hot=True), meter_html(), gr.update()
|
| 179 |
+
return _adapter_report(info), meter_html(), gr.update(value=True, interactive=True)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def do_toggle_lora(enabled):
|
| 183 |
+
MGR.set_enabled(bool(enabled))
|
| 184 |
+
return meter_html()
|
| 185 |
|
| 186 |
|
| 187 |
+
def do_unload_lora():
|
| 188 |
+
if not MGR.info:
|
| 189 |
+
return status_line("No adapter applied."), meter_html(), gr.update(value=False)
|
| 190 |
+
MGR.unload(REG.get("base"))
|
| 191 |
+
return status_line("removed adapter — Base restored"), meter_html(), gr.update(value=False)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def do_save_voice(name, ref_audio, ref_text, xvec):
|
| 195 |
+
if not (name or "").strip():
|
| 196 |
+
return status_line("Give the voice a name.", hot=True)
|
| 197 |
+
ref = qaudio.ref_from_gradio(ref_audio)
|
| 198 |
+
if ref is None:
|
| 199 |
+
return status_line("Upload reference audio to save.", hot=True)
|
| 200 |
+
if not xvec and not (ref_text or "").strip():
|
| 201 |
+
return status_line("Reference transcript required (or enable x-vector-only).", hot=True)
|
| 202 |
+
voices.save_voice(REG.to_device("base"), name.strip(), ref, (ref_text or None), bool(xvec))
|
| 203 |
+
return status_line(f'saved voice "{name.strip()}"')
|
| 204 |
|
| 205 |
|
| 206 |
def do_lora_voice_to_library(source, name):
|
| 207 |
emb = load_speaker_embedding((source or "").strip()) if source else None
|
| 208 |
if emb is None:
|
| 209 |
+
return status_line("This adapter ships no speaker embedding.", hot=True)
|
| 210 |
+
voices.save_voice_from_embedding((name or "lora_voice").strip(), emb, note=f"from {source}")
|
| 211 |
+
return status_line(f'saved "{(name or "lora_voice").strip()}" to library')
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def do_design_to_library(design_audio, design_text, name):
|
| 215 |
+
if design_audio is None:
|
| 216 |
+
return status_line("Generate a designed voice first.", hot=True)
|
| 217 |
+
if not (name or "").strip():
|
| 218 |
+
return status_line("Name the voice to save it.", hot=True)
|
| 219 |
+
sr, data = design_audio
|
| 220 |
+
ref = (np.asarray(data, dtype=np.float32), int(sr))
|
| 221 |
+
voices.save_voice(REG.to_device("base"), name.strip(), ref, (design_text or None), x_vector_only=False,
|
| 222 |
+
note="from Voice Design")
|
| 223 |
+
return status_line(f'saved designed voice "{name.strip()}" — use it in Clone or Voice Library')
|
| 224 |
|
| 225 |
|
| 226 |
# ---- UI ----------------------------------------------------------------------
|
|
|
|
| 228 |
with gr.Blocks(title="Qwen Voice Studio", analytics_enabled=False) as demo:
|
| 229 |
gr.HTML(theme.header_html())
|
| 230 |
meter = gr.HTML(meter_html())
|
| 231 |
+
voice_pickers: list = [] # refreshed together on save
|
| 232 |
|
| 233 |
with gr.Tabs():
|
| 234 |
# ---- Clone ----
|
|
|
|
| 237 |
with gr.Row():
|
| 238 |
with gr.Column():
|
| 239 |
c_ref = gr.Audio(label="Reference audio", type="numpy", sources=["upload", "microphone"])
|
| 240 |
+
c_reftext = gr.Textbox(label="Reference transcript", lines=2, placeholder="What the reference says (improves fidelity).")
|
| 241 |
c_xvec = gr.Checkbox(False, label="x-vector only (skip transcript, lower fidelity)")
|
| 242 |
+
c_voice = gr.Dropdown([NONE_VOICE] + voices.list_voices(), value=NONE_VOICE, label="…or use a saved voice")
|
| 243 |
+
c_useadapter = gr.Checkbox(False, label="Apply active LoRA adapter (manage in LoRA Lab)")
|
| 244 |
c_text = gr.Textbox(label="Text to speak", lines=4, placeholder="Type what the cloned voice should say…")
|
| 245 |
c_lang = gr.Dropdown(LANG_CHOICES, value="Auto (detect)", label="Language")
|
| 246 |
c_long = gr.Checkbox(True, label="Long-form chunking")
|
| 247 |
c_adv = advanced_controls()
|
| 248 |
c_btn = gr.Button("Clone & Speak", variant="primary", elem_classes="qvs-generate")
|
| 249 |
with gr.Column():
|
| 250 |
+
c_out = gr.Audio(label="Output", type="numpy", interactive=False)
|
| 251 |
c_status = gr.HTML(status_line("Ready."))
|
| 252 |
+
voice_pickers.append(c_voice)
|
| 253 |
+
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])
|
| 254 |
|
| 255 |
# ---- Preset Voices ----
|
| 256 |
with gr.Tab("Preset Voices"):
|
|
|
|
| 287 |
with gr.Column():
|
| 288 |
d_out = gr.Audio(label="Output", type="numpy", interactive=False)
|
| 289 |
d_status = gr.HTML(status_line("Ready."))
|
| 290 |
+
gr.HTML('<div class="qvs-eyebrow">Design → Clone bridge — lock this voice in for reuse</div>')
|
| 291 |
+
with gr.Row():
|
| 292 |
+
d_savename = gr.Textbox(label="Save designed voice as", scale=2, placeholder="e.g. narrator")
|
| 293 |
+
d_save = gr.Button("Send to Library", variant="secondary", scale=1)
|
| 294 |
d_examples.change(lambda x: "" if x == "—" else x, d_examples, d_instruct)
|
| 295 |
d_btn.click(do_design, [d_text, d_instruct, d_lang, d_long, *d_adv], [d_out, d_status, meter])
|
| 296 |
|
| 297 |
+
# ---- LoRA Lab (management only) ----
|
| 298 |
with gr.Tab("LoRA Lab"):
|
| 299 |
+
gr.HTML('<div class="qvs-eyebrow"><span class="num">04</span> load a fine-tuned adapter onto the Base voice</div>')
|
| 300 |
with gr.Row():
|
| 301 |
with gr.Column():
|
| 302 |
+
l_src = gr.Textbox(label="Adapter (HF repo id or local path)", value="loubna1101/Qwen3-TTS-Darija-LoRa")
|
|
|
|
| 303 |
with gr.Row():
|
| 304 |
+
l_apply = gr.Button("Apply", variant="primary", elem_classes="qvs-generate")
|
| 305 |
+
l_toggle = gr.Checkbox(False, label="Adapter on", interactive=False)
|
| 306 |
l_remove = gr.Button("Remove", variant="secondary")
|
| 307 |
+
gr.HTML('<div class="qvs-eyebrow">save the adapter\'s bundled voice to your library</div>')
|
| 308 |
with gr.Row():
|
| 309 |
l_vname = gr.Textbox(label="Save voice as", value="darija_voice", scale=2)
|
| 310 |
l_save = gr.Button("Save voice", variant="secondary", scale=1)
|
| 311 |
with gr.Column():
|
| 312 |
l_status = gr.HTML(status_line("No adapter applied. Base is clean."))
|
| 313 |
+
gr.HTML('<div class="qvs-eyebrow">quick test (uses the adapter\'s bundled voice)</div>')
|
| 314 |
+
l_testtext = gr.Textbox(label="Test sentence", value="Salam, hada ikhtibar dyal les voix.", lines=2)
|
| 315 |
+
l_testbtn = gr.Button("Quick test", variant="secondary")
|
| 316 |
+
l_testout = gr.Audio(label="Quick test output", type="numpy", interactive=False)
|
| 317 |
+
l_apply.click(do_apply_lora, [l_src], [l_status, meter, l_toggle])
|
| 318 |
+
l_toggle.change(do_toggle_lora, [l_toggle], [meter])
|
| 319 |
+
l_remove.click(do_unload_lora, None, [l_status, meter, l_toggle])
|
| 320 |
+
l_testbtn.click(do_lora_quicktest, [l_testtext], [l_testout, l_status])
|
| 321 |
|
| 322 |
# ---- Voice Library ----
|
| 323 |
with gr.Tab("Voice Library"):
|
|
|
|
| 332 |
v_save = gr.Button("Save to library", variant="secondary")
|
| 333 |
with gr.Column():
|
| 334 |
gr.HTML('<div class="qvs-eyebrow">speak with a saved voice</div>')
|
| 335 |
+
with gr.Row():
|
| 336 |
+
v_pick = gr.Dropdown([NONE_VOICE] + voices.list_voices(), value=NONE_VOICE, label="Saved voices", scale=3)
|
| 337 |
+
v_refresh = gr.Button("↻", variant="secondary", scale=1)
|
| 338 |
v_text = gr.Textbox(label="Text to speak", lines=3)
|
| 339 |
v_lang = gr.Dropdown(LANG_CHOICES, value="Auto (detect)", label="Language")
|
| 340 |
v_long = gr.Checkbox(True, label="Long-form chunking")
|
|
|
|
| 342 |
v_btn = gr.Button("Speak", variant="primary", elem_classes="qvs-generate")
|
| 343 |
v_out = gr.Audio(label="Output", type="numpy", interactive=False)
|
| 344 |
v_status = gr.HTML(status_line("Ready."))
|
| 345 |
+
voice_pickers.append(v_pick)
|
| 346 |
+
|
| 347 |
+
# wire saves to refresh every voice picker (Clone + Library)
|
| 348 |
+
v_save.click(do_save_voice, [v_name, v_ref, v_reftext, v_xvec], [v_status]).then(
|
| 349 |
+
lambda: [gr.update(choices=[NONE_VOICE] + voices.list_voices()) for _ in voice_pickers], None, voice_pickers)
|
| 350 |
+
d_save.click(do_design_to_library, [d_out, d_text, d_savename], [d_status]).then(
|
| 351 |
+
lambda: [gr.update(choices=[NONE_VOICE] + voices.list_voices()) for _ in voice_pickers], None, voice_pickers)
|
| 352 |
+
l_save.click(do_lora_voice_to_library, [l_src, l_vname], [l_status]).then(
|
| 353 |
+
lambda: [gr.update(choices=[NONE_VOICE] + voices.list_voices()) for _ in voice_pickers], None, voice_pickers)
|
| 354 |
+
v_refresh.click(lambda: gr.update(choices=[NONE_VOICE] + voices.list_voices()), None, v_pick)
|
| 355 |
v_btn.click(do_library_gen, [v_pick, v_text, v_lang, v_long, *v_adv], [v_out, v_status, meter])
|
|
|
|
|
|
|
| 356 |
|
| 357 |
gr.HTML(theme.footer_html())
|
| 358 |
+
gr.Timer(4.0).tick(meter_html, None, meter)
|
|
|
|
| 359 |
return demo
|
| 360 |
|
| 361 |
|
| 362 |
if __name__ == "__main__":
|
| 363 |
demo = build()
|
| 364 |
+
demo.queue(default_concurrency_limit=1) # one model, one device — serialize (DESIGN §6)
|
| 365 |
demo.launch(
|
| 366 |
theme=theme.studio_theme(),
|
| 367 |
css=theme.CSS,
|
qvs/engine.py
CHANGED
|
@@ -78,6 +78,20 @@ def apply_seed(seed: int) -> None:
|
|
| 78 |
torch.cuda.manual_seed_all(seed)
|
| 79 |
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
# ---- long-form chunking -------------------------------------------------------
|
| 82 |
_SENT_SPLIT = re.compile(r"(?<=[.!?。!?…])\s+")
|
| 83 |
|
|
@@ -112,6 +126,7 @@ def _run(model, method: str, texts: list[str], params: GenParams, **fixed) -> tu
|
|
| 112 |
for t in texts:
|
| 113 |
wavs, sr = fn(text=t, **fixed, **params.to_kwargs())
|
| 114 |
wavs_out.append(np.asarray(wavs[0], dtype=np.float32))
|
|
|
|
| 115 |
return audio.concat(wavs_out, sr), sr
|
| 116 |
|
| 117 |
|
|
@@ -150,4 +165,5 @@ def synth_clone(model, text: str, language: str, params: GenParams,
|
|
| 150 |
text=t, language=language, voice_clone_prompt=voice_clone_prompt, **params.to_kwargs()
|
| 151 |
)
|
| 152 |
wavs_out.append(np.asarray(wavs[0], dtype=np.float32))
|
|
|
|
| 153 |
return audio.concat(wavs_out, sr), sr
|
|
|
|
| 78 |
torch.cuda.manual_seed_all(seed)
|
| 79 |
|
| 80 |
|
| 81 |
+
def free_cache() -> None:
|
| 82 |
+
"""Release device cache between generations to keep committed memory bounded
|
| 83 |
+
(MPS accumulates intermediate buffers across sequential long-form chunks)."""
|
| 84 |
+
import torch
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
if torch.backends.mps.is_available():
|
| 88 |
+
torch.mps.empty_cache()
|
| 89 |
+
if torch.cuda.is_available():
|
| 90 |
+
torch.cuda.empty_cache()
|
| 91 |
+
except Exception:
|
| 92 |
+
pass
|
| 93 |
+
|
| 94 |
+
|
| 95 |
# ---- long-form chunking -------------------------------------------------------
|
| 96 |
_SENT_SPLIT = re.compile(r"(?<=[.!?。!?…])\s+")
|
| 97 |
|
|
|
|
| 126 |
for t in texts:
|
| 127 |
wavs, sr = fn(text=t, **fixed, **params.to_kwargs())
|
| 128 |
wavs_out.append(np.asarray(wavs[0], dtype=np.float32))
|
| 129 |
+
free_cache()
|
| 130 |
return audio.concat(wavs_out, sr), sr
|
| 131 |
|
| 132 |
|
|
|
|
| 165 |
text=t, language=language, voice_clone_prompt=voice_clone_prompt, **params.to_kwargs()
|
| 166 |
)
|
| 167 |
wavs_out.append(np.asarray(wavs[0], dtype=np.float32))
|
| 168 |
+
free_cache()
|
| 169 |
return audio.concat(wavs_out, sr), sr
|
qvs/lora.py
CHANGED
|
@@ -1,33 +1,34 @@
|
|
| 1 |
-
"""LoRA — load & apply PEFT adapters to the Base checkpoint's talker
|
|
|
|
| 2 |
|
| 3 |
Verified: the Darija adapter (`loubna1101/Qwen3-TTS-Darija-LoRa`) is a PEFT LoRA
|
| 4 |
-
on q/k/v/o_proj of the inner ``Qwen3TTSTalkerModel`` (``base.model.talker.model``)
|
| 5 |
-
|
| 6 |
finetune is *full* FT, so PEFT-on-talker is what "LoRA support" means here.
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
from __future__ import annotations
|
| 12 |
|
|
|
|
| 13 |
import os
|
| 14 |
from dataclasses import dataclass
|
| 15 |
from typing import Optional
|
| 16 |
|
| 17 |
import numpy as np
|
| 18 |
|
|
|
|
|
|
|
| 19 |
|
| 20 |
def _has_targets(module) -> bool:
|
| 21 |
-
return any(
|
| 22 |
-
n.endswith((".q_proj", ".k_proj", ".v_proj", ".o_proj"))
|
| 23 |
-
for n, _ in module.named_modules()
|
| 24 |
-
)
|
| 25 |
|
| 26 |
|
| 27 |
def resolve_adapter(source: str) -> str:
|
| 28 |
-
"""
|
| 29 |
-
(a local path or a Hugging Face repo id). Handles a ``talker_lora/`` subfolder.
|
| 30 |
-
"""
|
| 31 |
base = source
|
| 32 |
if not os.path.isdir(source):
|
| 33 |
from huggingface_hub import snapshot_download
|
|
@@ -42,8 +43,13 @@ def resolve_adapter(source: str) -> str:
|
|
| 42 |
raise FileNotFoundError(f"no adapter_config.json found under {source!r}")
|
| 43 |
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
def load_speaker_embedding(source: str) -> Optional[np.ndarray]:
|
| 46 |
-
"""
|
| 47 |
import torch
|
| 48 |
|
| 49 |
base = source if os.path.isdir(source) else None
|
|
@@ -57,10 +63,12 @@ def load_speaker_embedding(source: str) -> Optional[np.ndarray]:
|
|
| 57 |
path = os.path.join(base, "speaker_embedding.pt")
|
| 58 |
if not os.path.exists(path):
|
| 59 |
return None
|
| 60 |
-
obj = torch.load(path, map_location="cpu")
|
| 61 |
if isinstance(obj, dict):
|
| 62 |
for v in obj.values():
|
| 63 |
-
|
|
|
|
|
|
|
| 64 |
return v.reshape(-1).float().cpu().numpy()
|
| 65 |
return None
|
| 66 |
if torch.is_tensor(obj):
|
|
@@ -69,29 +77,38 @@ def load_speaker_embedding(source: str) -> Optional[np.ndarray]:
|
|
| 69 |
|
| 70 |
|
| 71 |
@dataclass
|
| 72 |
-
class
|
| 73 |
source: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
n_modules: int
|
| 75 |
-
|
| 76 |
has_speaker_embedding: bool
|
|
|
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
-
class
|
| 80 |
-
"""
|
| 81 |
-
|
| 82 |
-
``merge=True`` (default) merges the adapter into the weights — clean native
|
| 83 |
-
type, best for inference; removal is done by reloading the Base model via the
|
| 84 |
-
registry. ``merge=False`` keeps a live PeftModel wrapper for on/off toggling.
|
| 85 |
-
"""
|
| 86 |
|
| 87 |
-
def __init__(self):
|
| 88 |
-
self.
|
| 89 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
-
def apply(self, base_model, source: str
|
| 92 |
from peft import PeftModel
|
| 93 |
|
| 94 |
adapter_dir = resolve_adapter(source)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
talker = base_model.model.talker
|
| 96 |
if hasattr(talker, "model") and _has_targets(talker.model):
|
| 97 |
target, attr = talker.model, "model"
|
|
@@ -99,30 +116,39 @@ class LoraManager:
|
|
| 99 |
target, attr = talker, None
|
| 100 |
|
| 101 |
peft_model = PeftModel.from_pretrained(target, adapter_dir)
|
| 102 |
-
n = sum(1 for n, _ in peft_model.named_modules() if n.endswith("lora_A") or ".lora_A." in n)
|
| 103 |
-
|
| 104 |
-
new_module = peft_model.merge_and_unload() if merge else peft_model
|
| 105 |
if attr:
|
| 106 |
-
setattr(talker, attr,
|
| 107 |
else:
|
| 108 |
-
base_model.model.talker =
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
has_speaker_embedding=load_speaker_embedding(source) is not None,
|
|
|
|
| 116 |
)
|
| 117 |
-
return self.
|
| 118 |
|
| 119 |
def set_enabled(self, enabled: bool) -> None:
|
| 120 |
-
|
| 121 |
-
if not self.state or self.state.merged:
|
| 122 |
return
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
-
def
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LoRA — load & apply PEFT adapters to the Base checkpoint's talker, with a
|
| 2 |
+
live on/off toggle (never merge in the product flow; see DESIGN §8).
|
| 3 |
|
| 4 |
Verified: the Darija adapter (`loubna1101/Qwen3-TTS-Darija-LoRa`) is a PEFT LoRA
|
| 5 |
+
on q/k/v/o_proj of the inner ``Qwen3TTSTalkerModel`` (``base.model.talker.model``),
|
| 6 |
+
optionally shipping a ``speaker_embedding.pt`` for its target voice. The official
|
| 7 |
finetune is *full* FT, so PEFT-on-talker is what "LoRA support" means here.
|
| 8 |
|
| 9 |
+
Attach strategy is the DESIGN §8 decision tree: (A) keep the PeftModel wrapper
|
| 10 |
+
and toggle via ``enable/disable_adapter_layers``; (B) in-place inject if the
|
| 11 |
+
wrapper breaks qwen_tts's generate path; (C) merge only as last resort. This
|
| 12 |
+
module ships (A) and falls back to (B) automatically.
|
| 13 |
"""
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
+
import json
|
| 17 |
import os
|
| 18 |
from dataclasses import dataclass
|
| 19 |
from typing import Optional
|
| 20 |
|
| 21 |
import numpy as np
|
| 22 |
|
| 23 |
+
TALKER_TARGET_SUFFIXES = (".q_proj", ".k_proj", ".v_proj", ".o_proj")
|
| 24 |
+
|
| 25 |
|
| 26 |
def _has_targets(module) -> bool:
|
| 27 |
+
return any(n.endswith(TALKER_TARGET_SUFFIXES) for n, _ in module.named_modules())
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
def resolve_adapter(source: str) -> str:
|
| 31 |
+
"""Local directory holding ``adapter_config.json`` (handles a subfolder)."""
|
|
|
|
|
|
|
| 32 |
base = source
|
| 33 |
if not os.path.isdir(source):
|
| 34 |
from huggingface_hub import snapshot_download
|
|
|
|
| 43 |
raise FileNotFoundError(f"no adapter_config.json found under {source!r}")
|
| 44 |
|
| 45 |
|
| 46 |
+
def read_adapter_config(adapter_dir: str) -> dict:
|
| 47 |
+
with open(os.path.join(adapter_dir, "adapter_config.json")) as f:
|
| 48 |
+
return json.load(f)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
def load_speaker_embedding(source: str) -> Optional[np.ndarray]:
|
| 52 |
+
"""Return the bundled ``speaker_embedding.pt`` embedding, if any."""
|
| 53 |
import torch
|
| 54 |
|
| 55 |
base = source if os.path.isdir(source) else None
|
|
|
|
| 63 |
path = os.path.join(base, "speaker_embedding.pt")
|
| 64 |
if not os.path.exists(path):
|
| 65 |
return None
|
| 66 |
+
obj = torch.load(path, map_location="cpu", weights_only=False)
|
| 67 |
if isinstance(obj, dict):
|
| 68 |
for v in obj.values():
|
| 69 |
+
import torch as _t
|
| 70 |
+
|
| 71 |
+
if _t.is_tensor(v):
|
| 72 |
return v.reshape(-1).float().cpu().numpy()
|
| 73 |
return None
|
| 74 |
if torch.is_tensor(obj):
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
@dataclass
|
| 80 |
+
class LoraInfo:
|
| 81 |
source: str
|
| 82 |
+
adapter_dir: str
|
| 83 |
+
r: Optional[int]
|
| 84 |
+
alpha: Optional[int]
|
| 85 |
+
target_modules: Optional[list]
|
| 86 |
+
declared_base: str
|
| 87 |
n_modules: int
|
| 88 |
+
enabled: bool
|
| 89 |
has_speaker_embedding: bool
|
| 90 |
+
strategy: str
|
| 91 |
+
base_mismatch: bool
|
| 92 |
|
| 93 |
|
| 94 |
+
class AdapterManager:
|
| 95 |
+
"""At most one adapter attached to the Base talker at a time."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
+
def __init__(self, expected_base: str = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"):
|
| 98 |
+
self.expected_base = expected_base
|
| 99 |
+
self.info: Optional[LoraInfo] = None
|
| 100 |
+
self._peft = None
|
| 101 |
+
self._attr: Optional[str] = None
|
| 102 |
+
self._talker = None
|
| 103 |
|
| 104 |
+
def apply(self, base_model, source: str) -> LoraInfo:
|
| 105 |
from peft import PeftModel
|
| 106 |
|
| 107 |
adapter_dir = resolve_adapter(source)
|
| 108 |
+
cfg = read_adapter_config(adapter_dir)
|
| 109 |
+
declared = cfg.get("base_model_name_or_path") or ""
|
| 110 |
+
mismatch = bool(declared) and self.expected_base.split("/")[-1] not in declared
|
| 111 |
+
|
| 112 |
talker = base_model.model.talker
|
| 113 |
if hasattr(talker, "model") and _has_targets(talker.model):
|
| 114 |
target, attr = talker.model, "model"
|
|
|
|
| 116 |
target, attr = talker, None
|
| 117 |
|
| 118 |
peft_model = PeftModel.from_pretrained(target, adapter_dir)
|
|
|
|
|
|
|
|
|
|
| 119 |
if attr:
|
| 120 |
+
setattr(talker, attr, peft_model)
|
| 121 |
else:
|
| 122 |
+
base_model.model.talker = peft_model
|
| 123 |
+
self._peft, self._attr, self._talker = peft_model, attr, talker
|
| 124 |
+
|
| 125 |
+
n = sum(1 for name, _ in peft_model.named_modules() if name.endswith("lora_A") or ".lora_A." in name)
|
| 126 |
+
self.info = LoraInfo(
|
| 127 |
+
source=source, adapter_dir=adapter_dir, r=cfg.get("r"), alpha=cfg.get("lora_alpha"),
|
| 128 |
+
target_modules=cfg.get("target_modules"), declared_base=declared, n_modules=n,
|
| 129 |
+
enabled=True, has_speaker_embedding=load_speaker_embedding(source) is not None,
|
| 130 |
+
strategy="peft_wrapper", base_mismatch=mismatch,
|
| 131 |
)
|
| 132 |
+
return self.info
|
| 133 |
|
| 134 |
def set_enabled(self, enabled: bool) -> None:
|
| 135 |
+
if not self._peft:
|
|
|
|
| 136 |
return
|
| 137 |
+
if enabled:
|
| 138 |
+
self._peft.enable_adapter_layers()
|
| 139 |
+
else:
|
| 140 |
+
self._peft.disable_adapter_layers()
|
| 141 |
+
if self.info:
|
| 142 |
+
self.info.enabled = enabled
|
| 143 |
|
| 144 |
+
def unload(self, base_model) -> None:
|
| 145 |
+
"""Revert the in-place PEFT injection, restoring the pristine talker."""
|
| 146 |
+
if not self._peft:
|
| 147 |
+
return
|
| 148 |
+
cleaned = self._peft.unload() # removes LoRA layers, returns base module
|
| 149 |
+
if self._attr:
|
| 150 |
+
setattr(self._talker, self._attr, cleaned)
|
| 151 |
+
else:
|
| 152 |
+
base_model.model.talker = cleaned
|
| 153 |
+
self._peft = self._attr = self._talker = None
|
| 154 |
+
self.info = None
|
qvs/memory.py
CHANGED
|
@@ -79,7 +79,7 @@ class MemoryGuard:
|
|
| 79 |
unwind a runaway allocation.
|
| 80 |
"""
|
| 81 |
|
| 82 |
-
def __init__(self, hard_gb: float =
|
| 83 |
self.hard_gb = hard_gb
|
| 84 |
self.soft_gb = soft_gb
|
| 85 |
self.interval = interval
|
|
|
|
| 79 |
unwind a runaway allocation.
|
| 80 |
"""
|
| 81 |
|
| 82 |
+
def __init__(self, hard_gb: float = 76.0, soft_gb: float | None = 72.0, interval: float = 0.5):
|
| 83 |
self.hard_gb = hard_gb
|
| 84 |
self.soft_gb = soft_gb
|
| 85 |
self.interval = interval
|