Spaces:
Runtime error
feat: persist voice profiles to Voice_Profile/ with auto-load on startup
Browse filesVoice 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 +1 -0
- app.py +24 -4
- test_modules/test_latency_optimizations.py +54 -0
- voice_clone.py +179 -6
|
@@ -1,2 +1,3 @@
|
|
| 1 |
__pycache__/
|
| 2 |
sample_sounds/
|
|
|
|
|
|
| 1 |
__pycache__/
|
| 2 |
sample_sounds/
|
| 3 |
+
Voice_Profile/
|
|
@@ -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(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
@@ -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")
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
"""
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|