techfreakworm commited on
Commit
c3b59db
·
unverified ·
1 Parent(s): 77f7dfd

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.

Files changed (4) hide show
  1. app.py +149 -73
  2. qvs/engine.py +16 -0
  3. qvs/lora.py +74 -48
  4. 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 them together.
 
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 LoraManager, load_speaker_embedding
18
- from qvs.memory import snapshot
19
  from qvs.registry import ModelRegistry
20
  from qvs.ui import theme
21
 
22
  REG = ModelRegistry()
23
- LORA = LoraManager()
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
- if os.environ.get("QVS_MEMGUARD"):
29
- from qvs.memory import MemoryGuard
30
- MemoryGuard(hard_gb=float(os.environ.get("QVS_MEMGUARD", "80"))).start()
31
 
32
 
33
  # ---- helpers -----------------------------------------------------------------
34
  def meter_html() -> str:
35
  snap = snapshot()
36
- lora = f' · LoRA <b>{LORA.state.source.split("/")[-1]}</b>' if LORA.state else ""
 
 
 
 
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{lora}</div>'
 
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
- # ---- callbacks ---------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
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
- def do_clone(ref_audio, ref_text, xvec, text, language, longform, *adv):
 
101
  if not (text or "").strip():
102
- return None, status_line("Enter text to synthesize in the cloned voice.", hot=True), meter_html()
103
- ref = qaudio.ref_from_gradio(ref_audio)
104
- if ref is None:
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
- wav, sr = engine.synth_clone(model, text.strip(), config.LANGUAGES[language], gp(adv),
111
- ref_audio=ref, ref_text=(ref_text or None), x_vector_only=bool(xvec), longform=bool(longform))
 
 
 
 
 
 
 
 
 
 
112
  return qaudio.to_gradio(wav, sr), _done(t0, wav), meter_html()
113
 
114
 
115
- def do_save_voice(name, ref_audio, ref_text, xvec):
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
- st = LORA.apply(REG.to_device("base"), source.strip(), merge=True)
146
- except Exception as e: # surface load errors in the UI voice
147
- return status_line(f"Couldn't load adapter: {type(e).__name__}: {e}", hot=True), meter_html()
148
- emb = " · ships a speaker voice" if st.has_speaker_embedding else ""
149
- return status_line(f"applied {st.n_modules} LoRA modules from {source.split('/')[-1]}{emb}"), meter_html()
 
 
 
 
150
 
151
 
152
- def do_remove_lora():
153
- if not LORA.state:
154
- return status_line("No adapter is applied."), meter_html()
155
- REG.reload("base")
156
- LORA.clear()
157
- return status_line("removed adapter — Base restored"), meter_html()
 
 
 
 
 
 
 
 
 
 
 
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 gr.update(), status_line("This adapter ships no speaker embedding.", hot=True)
164
- nm = (name or "lora_voice").strip()
165
- voices.save_voice_from_embedding(nm, emb, note=f"from {source}")
166
- return gr.update(choices=voices.list_voices(), value=nm), status_line(f'saved "{nm}" to library')
 
 
 
 
 
 
 
 
 
 
 
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 audio says (improves fidelity).")
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, autoplay=False)
191
  c_status = gr.HTML(status_line("Ready."))
192
- c_btn.click(do_clone, [c_ref, c_reftext, c_xvec, c_text, c_lang, c_long, *c_adv], [c_out, c_status, meter])
 
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> &nbsp;load a fine-tuned adapter onto the base voice</div>')
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 adapter", variant="primary", elem_classes="qvs-generate")
 
241
  l_remove = gr.Button("Remove", variant="secondary")
242
- gr.HTML('<div class="qvs-eyebrow">save the adapter\'s shipped voice to your library</div>')
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.Markdown("After applying, use the **Clone** or **Voice Library** tab to generate — the adapter reshapes the base voice model.")
249
- l_apply.click(do_apply_lora, [l_src], [l_status, meter])
250
- l_remove.click(do_remove_lora, None, [l_status, meter])
 
 
 
 
 
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
- v_pick = gr.Dropdown(voices.list_voices(), label="Saved voices")
266
- v_refresh = gr.Button("↻ refresh", variant="secondary", scale=0)
 
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
- v_save.click(do_save_voice, [v_name, v_ref, v_reftext, v_xvec], [v_pick, v_status])
275
- v_refresh.click(lambda: gr.update(choices=voices.list_voices()), None, v_pick)
 
 
 
 
 
 
 
 
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
- timer = gr.Timer(4.0)
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=int(os.environ.get("QVS_CONCURRENCY", "4")))
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> &nbsp;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
- attaching there injects 224 modules and demonstrably changes output. The official
6
  finetune is *full* FT, so PEFT-on-talker is what "LoRA support" means here.
7
 
8
- Adapters can live at the repo root or in a ``talker_lora/`` subfolder, and may
9
- ship a ``speaker_embedding.pt`` describing the voice they were trained for.
 
 
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
- """Return a local directory containing ``adapter_config.json`` for *source*
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
- """If the adapter ships a ``speaker_embedding.pt``, return the raw embedding."""
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
- if torch.is_tensor(v):
 
 
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 LoraState:
73
  source: str
 
 
 
 
 
74
  n_modules: int
75
- merged: bool
76
  has_speaker_embedding: bool
 
 
77
 
78
 
79
- class LoraManager:
80
- """Applies at most one adapter to a Base model at a time.
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.state: Optional[LoraState] = None
89
- self._attr: Optional[str] = None # which submodule we swapped
 
 
 
90
 
91
- def apply(self, base_model, source: str, merge: bool = True) -> LoraState:
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, new_module)
107
  else:
108
- base_model.model.talker = new_module
109
-
110
- self._attr = attr
111
- self.state = LoraState(
112
- source=source,
113
- n_modules=n,
114
- merged=merge,
115
- has_speaker_embedding=load_speaker_embedding(source) is not None,
 
116
  )
117
- return self.state
118
 
119
  def set_enabled(self, enabled: bool) -> None:
120
- """Toggle a non-merged adapter on/off (no-op if merged/absent)."""
121
- if not self.state or self.state.merged:
122
  return
123
- talker_sub = None # PeftModel currently swapped in
124
- # nothing to do for merged; wrapper toggling handled by caller via model ref
 
 
 
 
125
 
126
- def clear(self) -> None:
127
- self.state = None
128
- self._attr = None
 
 
 
 
 
 
 
 
 
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 = 80.0, soft_gb: float | None = 76.0, interval: float = 0.5):
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