minhahwang Copilot commited on
Commit
0d420e4
·
1 Parent(s): fbfbf8d

feat: persist voice profiles to Voice_Profile/ with auto-load on startup

Browse files

Voice profile persistence:
- Profiles saved to Voice_Profile/<id>/profile.pt + metadata.json on clone
- On app startup, most recent profile auto-loaded as default voice
- voice_profile_state initialized with saved profile (or None for stock)
- synthesize_cloned() falls back to disk if not in memory cache
- has_profile() checks both memory cache and disk
- create_voice_profile() now accepts voice_name param for metadata
- Player shows actual cloned voice name (from metadata) instead of generic label

New APIs in voice_clone.py:
- save_profile_to_disk(profile_id, voice_name) -> Path
- load_profile_from_disk(profile_id) -> bool
- list_saved_profiles() -> list[dict]
- load_default_profile() -> str | None

Voice_Profile/ added to .gitignore (user data).
102 tests pass (28 latency/persistence + 74 integration).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

.gitignore CHANGED
@@ -1,2 +1,3 @@
1
  __pycache__/
2
  sample_sounds/
 
 
1
  __pycache__/
2
  sample_sounds/
3
+ Voice_Profile/
app.py CHANGED
@@ -9,6 +9,7 @@ from pathlib import Path
9
 
10
  from tts import split_into_chunks, generate_audio_stream
11
  from inference import transcribe_audio, answer_story_question
 
12
 
13
  # Create directories for sample audio files
14
  os.makedirs("sample_sounds", exist_ok=True)
@@ -337,6 +338,20 @@ def render_cloned_voices_html(voices_list):
337
  return markup
338
 
339
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  # Gradio Application Core setup
341
  with gr.Blocks(title="VoiceBook Gradio Hub") as demo:
342
 
@@ -344,7 +359,7 @@ with gr.Blocks(title="VoiceBook Gradio Hub") as demo:
344
  voices_state = gr.State(mock_voices)
345
  paragraphs_state = gr.State([])
346
  tts_chunks_state = gr.State([])
347
- voice_profile_state = gr.State(None)
348
 
349
  gr.HTML("""
350
  <div style="display: flex; align-items: center; justify-content: space-between; padding: 16px 0; border-bottom: 1px solid #ebdccb; margin-bottom: 24px; flex-wrap: wrap; gap: 16px;">
@@ -589,9 +604,14 @@ with gr.Blocks(title="VoiceBook Gradio Hub") as demo:
589
  selected = next((b for b in current_inventory if b["title"] == title_chosen), current_inventory[0])
590
 
591
  # Show cloned voice name if profile exists
592
- from voice_clone import has_profile
593
  if profile_id and has_profile(profile_id):
594
- narrator_label = "🎙️ Your Cloned Voice"
 
 
 
 
 
595
  narrator_color = "#4ade80"
596
  else:
597
  narrator_label = f"🔊 {selected['voice_name']} (stock)"
@@ -668,7 +688,7 @@ with gr.Blocks(title="VoiceBook Gradio Hub") as demo:
668
  import soundfile as sf
669
 
670
  progress(0.1, desc="Extracting speaker embedding from recording...")
671
- profile_id = create_voice_profile(recorder_data)
672
 
673
  progress(0.7, desc="Generating voice preview...")
674
  try:
 
9
 
10
  from tts import split_into_chunks, generate_audio_stream
11
  from inference import transcribe_audio, answer_story_question
12
+ from voice_clone import load_default_profile, list_saved_profiles
13
 
14
  # Create directories for sample audio files
15
  os.makedirs("sample_sounds", exist_ok=True)
 
338
  return markup
339
 
340
 
341
+ # Load default voice profile from Voice_Profile/ (if any saved from previous sessions)
342
+ _default_profile_id = load_default_profile()
343
+ _saved_profiles = list_saved_profiles()
344
+ if _default_profile_id:
345
+ _default_voice_name = next(
346
+ (p["voice_name"] for p in _saved_profiles if p["profile_id"] == _default_profile_id),
347
+ "Cloned Voice"
348
+ )
349
+ print(f"[VoiceBook] Default voice profile loaded: {_default_profile_id} ({_default_voice_name})")
350
+ else:
351
+ _default_voice_name = None
352
+ print("[VoiceBook] No saved voice profile — using stock voice as default.")
353
+
354
+
355
  # Gradio Application Core setup
356
  with gr.Blocks(title="VoiceBook Gradio Hub") as demo:
357
 
 
359
  voices_state = gr.State(mock_voices)
360
  paragraphs_state = gr.State([])
361
  tts_chunks_state = gr.State([])
362
+ voice_profile_state = gr.State(_default_profile_id)
363
 
364
  gr.HTML("""
365
  <div style="display: flex; align-items: center; justify-content: space-between; padding: 16px 0; border-bottom: 1px solid #ebdccb; margin-bottom: 24px; flex-wrap: wrap; gap: 16px;">
 
604
  selected = next((b for b in current_inventory if b["title"] == title_chosen), current_inventory[0])
605
 
606
  # Show cloned voice name if profile exists
607
+ from voice_clone import has_profile, list_saved_profiles as _list_profiles
608
  if profile_id and has_profile(profile_id):
609
+ saved = _list_profiles()
610
+ voice_label = next(
611
+ (p["voice_name"] for p in saved if p["profile_id"] == profile_id),
612
+ "Cloned Voice"
613
+ )
614
+ narrator_label = f"🎙️ {voice_label}"
615
  narrator_color = "#4ade80"
616
  else:
617
  narrator_label = f"🔊 {selected['voice_name']} (stock)"
 
688
  import soundfile as sf
689
 
690
  progress(0.1, desc="Extracting speaker embedding from recording...")
691
+ profile_id = create_voice_profile(recorder_data, voice_name=v_name.strip())
692
 
693
  progress(0.7, desc="Generating voice preview...")
694
  try:
test_modules/test_latency_optimizations.py CHANGED
@@ -169,6 +169,58 @@ def test_tts_module_imports():
169
  "voice_profile_id" in params, f"params: {params}")
170
 
171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  if __name__ == "__main__":
173
  print("=" * 60)
174
  print("Latency Optimization Tests")
@@ -182,6 +234,8 @@ if __name__ == "__main__":
182
  test_model_mode_env()
183
  test_custom_voice_clone_blocked()
184
  test_tts_module_imports()
 
 
185
 
186
  print("\n" + "=" * 60)
187
  print(f"Results: {PASS} passed, {FAIL} failed")
 
169
  "voice_profile_id" in params, f"params: {params}")
170
 
171
 
172
+ def test_voice_profile_persistence():
173
+ print("\n=== Voice profile persistence ===")
174
+ from voice_clone import (
175
+ VOICE_PROFILE_DIR, list_saved_profiles, load_default_profile,
176
+ has_profile, save_profile_to_disk, load_profile_from_disk,
177
+ _PROFILE_CACHE, _cache_lock,
178
+ )
179
+ import inspect
180
+
181
+ check("VOICE_PROFILE_DIR exists", VOICE_PROFILE_DIR.exists(),
182
+ str(VOICE_PROFILE_DIR))
183
+ check("VOICE_PROFILE_DIR is named Voice_Profile",
184
+ VOICE_PROFILE_DIR.name == "Voice_Profile")
185
+
186
+ # Verify API signatures
187
+ sig_create = inspect.signature(
188
+ __import__('voice_clone').create_voice_profile
189
+ )
190
+ check("create_voice_profile accepts voice_name",
191
+ "voice_name" in sig_create.parameters,
192
+ f"params: {list(sig_create.parameters)}")
193
+
194
+ check("list_saved_profiles returns a list",
195
+ isinstance(list_saved_profiles(), list))
196
+
197
+ # load_default_profile returns None when no profiles saved
198
+ # (since Voice_Profile/ might have profiles from prior runs, just check type)
199
+ result = load_default_profile()
200
+ check("load_default_profile returns str or None",
201
+ result is None or isinstance(result, str), f"got {type(result)}")
202
+
203
+ # has_profile checks disk too
204
+ check("has_profile('nonexistent') is False", has_profile("nonexistent") is False)
205
+
206
+
207
+ def test_app_default_profile():
208
+ print("\n=== App default profile loading ===")
209
+ source = open(
210
+ os.path.join(os.path.dirname(os.path.dirname(__file__)), "app.py"),
211
+ encoding="utf-8"
212
+ ).read()
213
+
214
+ check("App imports load_default_profile",
215
+ "from voice_clone import load_default_profile" in source)
216
+ check("App calls load_default_profile()",
217
+ "_default_profile_id = load_default_profile()" in source)
218
+ check("voice_profile_state initialized with default",
219
+ "voice_profile_state = gr.State(_default_profile_id)" in source)
220
+ check("create_voice_profile passes voice_name",
221
+ "voice_name=v_name.strip()" in source)
222
+
223
+
224
  if __name__ == "__main__":
225
  print("=" * 60)
226
  print("Latency Optimization Tests")
 
234
  test_model_mode_env()
235
  test_custom_voice_clone_blocked()
236
  test_tts_module_imports()
237
+ test_voice_profile_persistence()
238
+ test_app_default_profile()
239
 
240
  print("\n" + "=" * 60)
241
  print(f"Results: {PASS} passed, {FAIL} failed")
voice_clone.py CHANGED
@@ -5,6 +5,11 @@ Supports two backends:
5
  - Base 1.7B: zero-shot voice cloning from reference audio
6
  - CustomVoice 0.6B: fast predefined speakers (no cloning, lower latency)
7
 
 
 
 
 
 
8
  Latency optimizations applied:
9
  - bfloat16 / float16 precision (auto-detected per GPU arch)
10
  - FlashAttention-2 when available
@@ -15,13 +20,15 @@ Latency optimizations applied:
15
  - Reference audio trimmed to 3-5s for faster embedding extraction
16
 
17
  Usage:
18
- profile_id = create_voice_profile(ref_audio_path)
19
  wav, sr = synthesize_cloned(text, profile_id)
20
  """
 
21
  import logging
22
  import os
23
  import uuid
24
  import threading
 
25
 
26
  import numpy as np
27
  import soundfile as sf
@@ -59,6 +66,15 @@ REF_AUDIO_MIN_SEC = 3.0
59
  REF_AUDIO_MAX_SEC = 10.0
60
  REF_AUDIO_TARGET_SR = 24000
61
 
 
 
 
 
 
 
 
 
 
62
  # ---------------------------------------------------------------------------
63
  # Server-side cache: { profile_id -> VoiceClonePromptItem list }
64
  # ---------------------------------------------------------------------------
@@ -163,9 +179,151 @@ def _trim_reference_audio(audio_path: str) -> str:
163
  return audio_path
164
 
165
 
166
- def create_voice_profile(ref_audio_path: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  """
168
- Extract speaker embedding from reference audio and cache it.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  Returns a profile_id string for later synthesis.
170
 
171
  Reference audio is trimmed to 3-10s for optimal latency.
@@ -191,7 +349,13 @@ def create_voice_profile(ref_audio_path: str) -> str:
191
  with _cache_lock:
192
  _PROFILE_CACHE[profile_id] = prompt_items
193
 
194
- logger.info("Voice profile %s created.", profile_id)
 
 
 
 
 
 
195
  return profile_id
196
 
197
 
@@ -202,6 +366,11 @@ def synthesize_cloned(text: str, profile_id: str) -> tuple[np.ndarray, int]:
202
  """
203
  with _cache_lock:
204
  prompt_items = _PROFILE_CACHE.get(profile_id)
 
 
 
 
 
205
  if prompt_items is None:
206
  raise ValueError(f"Voice profile '{profile_id}' not found. Record voice first.")
207
 
@@ -250,11 +419,15 @@ def synthesize_cloned_preview(profile_id: str) -> tuple[np.ndarray, int]:
250
 
251
 
252
  def has_profile(profile_id: str | None) -> bool:
253
- """Check if a voice profile exists in cache."""
254
  if not profile_id:
255
  return False
256
  with _cache_lock:
257
- return profile_id in _PROFILE_CACHE
 
 
 
 
258
 
259
 
260
  def get_model_mode() -> str:
 
5
  - Base 1.7B: zero-shot voice cloning from reference audio
6
  - CustomVoice 0.6B: fast predefined speakers (no cloning, lower latency)
7
 
8
+ Voice profiles are:
9
+ - Cached in-memory for fast access during a session
10
+ - Persisted to Voice_Profile/ folder as .pt files for reuse across restarts
11
+ - Auto-loaded on startup if saved profiles exist
12
+
13
  Latency optimizations applied:
14
  - bfloat16 / float16 precision (auto-detected per GPU arch)
15
  - FlashAttention-2 when available
 
20
  - Reference audio trimmed to 3-5s for faster embedding extraction
21
 
22
  Usage:
23
+ profile_id = create_voice_profile(ref_audio_path, voice_name="Mom")
24
  wav, sr = synthesize_cloned(text, profile_id)
25
  """
26
+ import json
27
  import logging
28
  import os
29
  import uuid
30
  import threading
31
+ from pathlib import Path
32
 
33
  import numpy as np
34
  import soundfile as sf
 
66
  REF_AUDIO_MAX_SEC = 10.0
67
  REF_AUDIO_TARGET_SR = 24000
68
 
69
+ # ---------------------------------------------------------------------------
70
+ # Voice profile persistence
71
+ # ---------------------------------------------------------------------------
72
+ VOICE_PROFILE_DIR = Path(__file__).parent / "Voice_Profile"
73
+ VOICE_PROFILE_DIR.mkdir(exist_ok=True)
74
+
75
+ # Default profile ID — used when no cloned voice exists
76
+ DEFAULT_PROFILE_ID = "__default__"
77
+
78
  # ---------------------------------------------------------------------------
79
  # Server-side cache: { profile_id -> VoiceClonePromptItem list }
80
  # ---------------------------------------------------------------------------
 
179
  return audio_path
180
 
181
 
182
+ # ---------------------------------------------------------------------------
183
+ # Profile persistence (disk ↔ memory)
184
+ # ---------------------------------------------------------------------------
185
+
186
+ def save_profile_to_disk(profile_id: str, voice_name: str = "Cloned Voice") -> Path:
187
+ """
188
+ Save a cached voice profile to Voice_Profile/ as a .pt file + metadata JSON.
189
+ Returns the path to the saved .pt file.
190
+ """
191
+ with _cache_lock:
192
+ prompt_items = _PROFILE_CACHE.get(profile_id)
193
+ if prompt_items is None:
194
+ raise ValueError(f"Profile '{profile_id}' not found in cache.")
195
+
196
+ profile_dir = VOICE_PROFILE_DIR / profile_id
197
+ profile_dir.mkdir(exist_ok=True)
198
+
199
+ # Serialize VoiceClonePromptItem fields
200
+ serializable = []
201
+ for item in prompt_items:
202
+ serializable.append({
203
+ "ref_code": item.ref_code.cpu() if item.ref_code is not None else None,
204
+ "ref_spk_embedding": item.ref_spk_embedding.cpu(),
205
+ "x_vector_only_mode": item.x_vector_only_mode,
206
+ "icl_mode": item.icl_mode,
207
+ "ref_text": item.ref_text,
208
+ })
209
+
210
+ pt_path = profile_dir / "profile.pt"
211
+ torch.save(serializable, pt_path)
212
+
213
+ # Save metadata
214
+ meta = {"profile_id": profile_id, "voice_name": voice_name}
215
+ meta_path = profile_dir / "metadata.json"
216
+ with open(meta_path, "w", encoding="utf-8") as f:
217
+ json.dump(meta, f, indent=2)
218
+
219
+ logger.info("Voice profile '%s' (%s) saved to %s", profile_id, voice_name, profile_dir)
220
+ return pt_path
221
+
222
+
223
+ def load_profile_from_disk(profile_id: str) -> bool:
224
  """
225
+ Load a voice profile from Voice_Profile/<profile_id>/profile.pt into memory cache.
226
+ Returns True if loaded successfully, False otherwise.
227
+ """
228
+ profile_dir = VOICE_PROFILE_DIR / profile_id
229
+ pt_path = profile_dir / "profile.pt"
230
+
231
+ if not pt_path.exists():
232
+ logger.warning("Profile file not found: %s", pt_path)
233
+ return False
234
+
235
+ try:
236
+ from qwen_tts.inference.qwen3_tts_model import VoiceClonePromptItem
237
+
238
+ raw_items = torch.load(pt_path, map_location="cpu", weights_only=False)
239
+ prompt_items = []
240
+ for item_dict in raw_items:
241
+ prompt_items.append(VoiceClonePromptItem(
242
+ ref_code=item_dict["ref_code"],
243
+ ref_spk_embedding=item_dict["ref_spk_embedding"],
244
+ x_vector_only_mode=item_dict["x_vector_only_mode"],
245
+ icl_mode=item_dict["icl_mode"],
246
+ ref_text=item_dict.get("ref_text"),
247
+ ))
248
+
249
+ with _cache_lock:
250
+ _PROFILE_CACHE[profile_id] = prompt_items
251
+
252
+ logger.info("Voice profile '%s' loaded from disk.", profile_id)
253
+ return True
254
+ except Exception as exc:
255
+ logger.exception("Failed to load profile '%s': %s", profile_id, exc)
256
+ return False
257
+
258
+
259
+ def list_saved_profiles() -> list[dict]:
260
+ """
261
+ List all saved voice profiles from Voice_Profile/ directory.
262
+ Returns list of {profile_id, voice_name, path} dicts, newest first.
263
+ """
264
+ profiles = []
265
+ if not VOICE_PROFILE_DIR.exists():
266
+ return profiles
267
+
268
+ for entry in VOICE_PROFILE_DIR.iterdir():
269
+ if not entry.is_dir():
270
+ continue
271
+ pt_path = entry / "profile.pt"
272
+ meta_path = entry / "metadata.json"
273
+ if not pt_path.exists():
274
+ continue
275
+
276
+ voice_name = "Cloned Voice"
277
+ if meta_path.exists():
278
+ try:
279
+ with open(meta_path, encoding="utf-8") as f:
280
+ meta = json.load(f)
281
+ voice_name = meta.get("voice_name", voice_name)
282
+ except Exception:
283
+ pass
284
+
285
+ profiles.append({
286
+ "profile_id": entry.name,
287
+ "voice_name": voice_name,
288
+ "path": str(entry),
289
+ "mtime": pt_path.stat().st_mtime,
290
+ })
291
+
292
+ # Newest first
293
+ profiles.sort(key=lambda p: p["mtime"], reverse=True)
294
+ return profiles
295
+
296
+
297
+ def load_default_profile() -> str | None:
298
+ """
299
+ Load the most recently saved voice profile from Voice_Profile/ into memory.
300
+ Returns the profile_id if found, None if no saved profiles exist.
301
+ This is called on app startup to restore the last cloned voice.
302
+ """
303
+ saved = list_saved_profiles()
304
+ if not saved:
305
+ logger.info("No saved voice profiles found — using stock voice as default.")
306
+ return None
307
+
308
+ newest = saved[0]
309
+ profile_id = newest["profile_id"]
310
+ if load_profile_from_disk(profile_id):
311
+ logger.info(
312
+ "Default voice profile loaded: '%s' (%s)",
313
+ profile_id, newest["voice_name"],
314
+ )
315
+ return profile_id
316
+
317
+ return None
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # Core API
322
+ # ---------------------------------------------------------------------------
323
+
324
+ def create_voice_profile(ref_audio_path: str, voice_name: str = "Cloned Voice") -> str:
325
+ """
326
+ Extract speaker embedding from reference audio, cache it, and save to disk.
327
  Returns a profile_id string for later synthesis.
328
 
329
  Reference audio is trimmed to 3-10s for optimal latency.
 
349
  with _cache_lock:
350
  _PROFILE_CACHE[profile_id] = prompt_items
351
 
352
+ # Persist to disk
353
+ try:
354
+ save_profile_to_disk(profile_id, voice_name=voice_name)
355
+ except Exception as exc:
356
+ logger.warning("Failed to save profile to disk: %s", exc)
357
+
358
+ logger.info("Voice profile %s created and saved.", profile_id)
359
  return profile_id
360
 
361
 
 
366
  """
367
  with _cache_lock:
368
  prompt_items = _PROFILE_CACHE.get(profile_id)
369
+ if prompt_items is None:
370
+ # Try loading from disk if not in memory
371
+ if load_profile_from_disk(profile_id):
372
+ with _cache_lock:
373
+ prompt_items = _PROFILE_CACHE.get(profile_id)
374
  if prompt_items is None:
375
  raise ValueError(f"Voice profile '{profile_id}' not found. Record voice first.")
376
 
 
419
 
420
 
421
  def has_profile(profile_id: str | None) -> bool:
422
+ """Check if a voice profile exists in cache or on disk."""
423
  if not profile_id:
424
  return False
425
  with _cache_lock:
426
+ if profile_id in _PROFILE_CACHE:
427
+ return True
428
+ # Check disk
429
+ pt_path = VOICE_PROFILE_DIR / profile_id / "profile.pt"
430
+ return pt_path.exists()
431
 
432
 
433
  def get_model_mode() -> str: