File size: 15,406 Bytes
215fff0
 
 
d3a755c
 
 
 
0d420e4
 
 
 
 
d3a755c
 
 
 
 
 
 
 
 
215fff0
0d420e4
215fff0
 
373219a
 
0d420e4
215fff0
d3a755c
215fff0
 
0d420e4
215fff0
 
d3a755c
215fff0
 
397b4ac
 
215fff0
 
d3a755c
 
 
 
 
 
 
 
af03300
d3a755c
 
af03300
 
 
 
 
d3a755c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0d420e4
 
 
 
 
 
 
 
d3a755c
215fff0
d3a755c
215fff0
 
 
 
 
 
d3a755c
 
 
 
 
 
 
 
 
 
 
 
 
c7cbd46
d3a755c
 
 
 
c7cbd46
 
215fff0
 
 
a51c3c3
215fff0
 
 
 
 
 
5a98019
a51c3c3
d3a755c
5a98019
c7cbd46
215fff0
d3a755c
94fb797
c7cbd46
215fff0
c7cbd46
215fff0
 
 
d3a755c
9df2e8c
 
 
 
d3a755c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0d420e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215fff0
0d420e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af03300
0d420e4
 
215fff0
d3a755c
 
215fff0
d3a755c
215fff0
 
 
397b4ac
 
 
 
 
215fff0
af03300
215fff0
 
 
0d420e4
 
 
 
 
 
 
215fff0
 
 
 
 
 
 
 
 
 
0d420e4
 
 
 
 
215fff0
 
 
 
 
397b4ac
 
 
 
 
 
 
 
d3a755c
 
 
 
 
af03300
 
 
 
 
 
44fbd0d
 
 
 
 
af03300
 
fa6ebf7
 
 
 
af03300
fa6ebf7
 
af03300
 
 
 
44fbd0d
 
 
 
fa6ebf7
af03300
397b4ac
af03300
397b4ac
 
af03300
397b4ac
 
 
fa6ebf7
 
 
 
 
 
d3a755c
7cc2412
d3a755c
 
af03300
d3a755c
 
af03300
 
 
 
 
 
 
215fff0
 
 
 
 
 
 
 
 
 
 
0d420e4
215fff0
 
 
0d420e4
 
 
 
 
d3a755c
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
"""
Voice cloning module β€” wraps Qwen3-TTS for zero-shot voice cloning.

Supports two backends:
  - Base 1.7B: zero-shot voice cloning from reference audio
  - CustomVoice 0.6B: fast predefined speakers (no cloning, lower latency)

Voice profiles are:
  - Cached in-memory for fast access during a session
  - Persisted to Voice_Profile/ folder as .pt files for reuse across restarts
  - Auto-loaded on startup if saved profiles exist

Latency optimizations applied:
  - bfloat16 / float16 precision (auto-detected per GPU arch)
  - FlashAttention-2 when available
  - Streaming mode (non_streaming_mode=False)
  - Reduced sampling params (top_k=20, temperature=0.7)
  - Capped max_new_tokens=1024
  - torch.set_float32_matmul_precision('high')
  - Reference audio trimmed to 3-5s for faster embedding extraction

Usage:
    profile_id = create_voice_profile(ref_audio_path, voice_name="Mom")
    wav, sr = synthesize_cloned(text, profile_id)
"""
from __future__ import annotations

import json
import logging
import os
import uuid
import threading
from pathlib import Path

import numpy as np
import soundfile as sf
import torch

from runtime_config import GPU_INFERENCE_LOCK, VOICE_PROFILE_DIR

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Global PyTorch optimizations
# ---------------------------------------------------------------------------
torch.set_float32_matmul_precision("high")

# ---------------------------------------------------------------------------
# Model configuration
# ---------------------------------------------------------------------------
# Base model for zero-shot voice cloning (1.7B) β€” used for BOTH cloned and stock voice
BASE_MODEL_ID = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"

# Stock voice reference audio (pre-generated "vivian" sample)
VIVIAN_REF_PATH = Path(__file__).parent / "assets" / "vivian_reference.wav"

# Profile ID for the built-in stock voice
STOCK_VOICE_PROFILE_ID = "__stock_vivian__"

# Optimized generation parameters (reduced from defaults: top_k=50, temp=0.9, max=2048)
GENERATION_PARAMS = dict(
    top_k=20,
    temperature=0.7,
    subtalker_top_k=20,
    subtalker_temperature=0.7,
    max_new_tokens=1024,
)

# Reference audio limits (seconds) β€” 3-5s is optimal for Qwen3-TTS
REF_AUDIO_MIN_SEC = 3.0
REF_AUDIO_MAX_SEC = 10.0
REF_AUDIO_TARGET_SR = 24000

# ---------------------------------------------------------------------------
# Voice profile persistence
# ---------------------------------------------------------------------------
VOICE_PROFILE_DIR.mkdir(exist_ok=True)

# Default profile ID β€” used when no cloned voice exists
DEFAULT_PROFILE_ID = "__default__"

# ---------------------------------------------------------------------------
# Server-side cache: { profile_id -> VoiceClonePromptItem list }
# ---------------------------------------------------------------------------
_PROFILE_CACHE: dict[str, list] = {}
_cache_lock = threading.Lock()

_qwen_tts_model = None
_model_lock = threading.Lock()


def _select_dtype() -> torch.dtype:
    """Pick optimal dtype based on GPU architecture."""
    if not torch.cuda.is_available():
        return torch.float32
    cap = torch.cuda.get_device_capability()
    # bfloat16 requires compute capability >= 8.0 (Ampere+)
    if cap[0] >= 8:
        return torch.bfloat16
    return torch.float16


def _select_attn_impl() -> str:
    """Use FlashAttention-2 if available, else SDPA (PyTorch native)."""
    try:
        import flash_attn  # noqa: F401
        return "flash_attention_2"
    except ImportError:
        logger.info("flash-attn not installed β€” using SDPA attention.")
        return "sdpa"


def get_qwen_tts():
    """Lazy-load Qwen3-TTS Base 1.7B model for cloning. Thread-safe."""
    global _qwen_tts_model
    if _qwen_tts_model is None:
        with _model_lock:
            if _qwen_tts_model is None:
                from qwen_tts import Qwen3TTSModel

                model_id = BASE_MODEL_ID
                device = "cuda" if torch.cuda.is_available() else "cpu"

                logger.info("Loading %s on %s (cloning model)...", model_id, device)
                attn_impl = _select_attn_impl()
                _qwen_tts_model = Qwen3TTSModel.from_pretrained(
                    model_id,
                    device_map=device,
                    attn_implementation=attn_impl,
                )
                logger.info("Qwen3-TTS Base loaded on %s (attn=%s).", device, attn_impl)
    return _qwen_tts_model


def _try_torch_compile(wrapper):
    """Best-effort torch.compile on model submodules. Disabled for stability."""
    # torch.compile can cause CUDA asserts on some GPU architectures (T4/Turing)
    # Disable for now in favor of stability
    return


def _trim_reference_audio(audio_path: str) -> str:
    """
    Trim reference audio to REF_AUDIO_MAX_SEC seconds if longer.
    Returns path to trimmed file (or original if already short enough).
    """
    try:
        info = sf.info(audio_path)
        duration = info.duration
        if duration <= REF_AUDIO_MAX_SEC:
            return audio_path

        logger.info(
            "Reference audio %.1fs exceeds %.0fs limit β€” trimming.",
            duration, REF_AUDIO_MAX_SEC,
        )
        data, sr = sf.read(audio_path)
        max_samples = int(REF_AUDIO_MAX_SEC * sr)
        trimmed = data[:max_samples]

        trimmed_path = audio_path + ".trimmed.wav"
        sf.write(trimmed_path, trimmed, sr)
        return trimmed_path
    except Exception as exc:
        logger.warning("Could not trim reference audio: %s", exc)
        return audio_path


# ---------------------------------------------------------------------------
# Profile persistence (disk ↔ memory)
# ---------------------------------------------------------------------------

def save_profile_to_disk(profile_id: str, voice_name: str = "Cloned Voice") -> Path:
    """
    Save a cached voice profile to Voice_Profile/ as a .pt file + metadata JSON.
    Returns the path to the saved .pt file.
    """
    with _cache_lock:
        prompt_items = _PROFILE_CACHE.get(profile_id)
    if prompt_items is None:
        raise ValueError(f"Profile '{profile_id}' not found in cache.")

    profile_dir = VOICE_PROFILE_DIR / profile_id
    profile_dir.mkdir(exist_ok=True)

    # Serialize VoiceClonePromptItem fields
    serializable = []
    for item in prompt_items:
        serializable.append({
            "ref_code": item.ref_code.cpu() if item.ref_code is not None else None,
            "ref_spk_embedding": item.ref_spk_embedding.cpu(),
            "x_vector_only_mode": item.x_vector_only_mode,
            "icl_mode": item.icl_mode,
            "ref_text": item.ref_text,
        })

    pt_path = profile_dir / "profile.pt"
    torch.save(serializable, pt_path)

    # Save metadata
    meta = {"profile_id": profile_id, "voice_name": voice_name}
    meta_path = profile_dir / "metadata.json"
    with open(meta_path, "w", encoding="utf-8") as f:
        json.dump(meta, f, indent=2)

    logger.info("Voice profile '%s' (%s) saved to %s", profile_id, voice_name, profile_dir)
    return pt_path


def load_profile_from_disk(profile_id: str) -> bool:
    """
    Load a voice profile from Voice_Profile/<profile_id>/profile.pt into memory cache.
    Returns True if loaded successfully, False otherwise.
    """
    profile_dir = VOICE_PROFILE_DIR / profile_id
    pt_path = profile_dir / "profile.pt"

    if not pt_path.exists():
        logger.warning("Profile file not found: %s", pt_path)
        return False

    try:
        from qwen_tts.inference.qwen3_tts_model import VoiceClonePromptItem

        raw_items = torch.load(pt_path, map_location="cpu", weights_only=False)
        prompt_items = []
        for item_dict in raw_items:
            prompt_items.append(VoiceClonePromptItem(
                ref_code=item_dict["ref_code"],
                ref_spk_embedding=item_dict["ref_spk_embedding"],
                x_vector_only_mode=item_dict["x_vector_only_mode"],
                icl_mode=item_dict["icl_mode"],
                ref_text=item_dict.get("ref_text"),
            ))

        with _cache_lock:
            _PROFILE_CACHE[profile_id] = prompt_items

        logger.info("Voice profile '%s' loaded from disk.", profile_id)
        return True
    except Exception as exc:
        logger.exception("Failed to load profile '%s': %s", profile_id, exc)
        return False


def list_saved_profiles() -> list[dict]:
    """
    List all saved voice profiles from Voice_Profile/ directory.
    Returns list of {profile_id, voice_name, path} dicts, newest first.
    """
    profiles = []
    if not VOICE_PROFILE_DIR.exists():
        return profiles

    for entry in VOICE_PROFILE_DIR.iterdir():
        if not entry.is_dir():
            continue
        pt_path = entry / "profile.pt"
        meta_path = entry / "metadata.json"
        if not pt_path.exists():
            continue

        voice_name = "Cloned Voice"
        if meta_path.exists():
            try:
                with open(meta_path, encoding="utf-8") as f:
                    meta = json.load(f)
                voice_name = meta.get("voice_name", voice_name)
            except Exception:
                pass

        profiles.append({
            "profile_id": entry.name,
            "voice_name": voice_name,
            "path": str(entry),
            "mtime": pt_path.stat().st_mtime,
        })

    # Newest first
    profiles.sort(key=lambda p: p["mtime"], reverse=True)
    return profiles


def load_default_profile() -> str | None:
    """
    Load the most recently saved voice profile from Voice_Profile/ into memory.
    Returns the profile_id if found, None if no saved profiles exist.
    This is called on app startup to restore the last cloned voice.
    """
    saved = list_saved_profiles()
    if not saved:
        logger.info("No saved voice profiles found β€” using stock voice as default.")
        return None

    newest = saved[0]
    profile_id = newest["profile_id"]
    if load_profile_from_disk(profile_id):
        logger.info(
            "Default voice profile loaded: '%s' (%s)",
            profile_id, newest["voice_name"],
        )
        return profile_id

    return None


# ---------------------------------------------------------------------------
# Core API
# ---------------------------------------------------------------------------

def create_voice_profile(ref_audio_path: str, voice_name: str = "Cloned Voice", profile_id_override: str | None = None) -> str:
    """
    Extract speaker embedding from reference audio, cache it, and save to disk.
    Returns a profile_id string for later synthesis.

    Reference audio is trimmed to 3-10s for optimal latency.
    """
    trimmed_path = _trim_reference_audio(ref_audio_path)
    model = get_qwen_tts()

    logger.info("Creating voice profile from %s...", ref_audio_path)
    with GPU_INFERENCE_LOCK:
        prompt_items = model.create_voice_clone_prompt(
            ref_audio=trimmed_path,
            x_vector_only_mode=True,
        )

    profile_id = profile_id_override or uuid.uuid4().hex[:12]
    with _cache_lock:
        _PROFILE_CACHE[profile_id] = prompt_items

    # Persist to disk
    try:
        save_profile_to_disk(profile_id, voice_name=voice_name)
    except Exception as exc:
        logger.warning("Failed to save profile to disk: %s", exc)

    logger.info("Voice profile %s created and saved.", profile_id)
    return profile_id


def synthesize_cloned(text: str, profile_id: str) -> tuple[np.ndarray, int]:
    """
    Synthesize text using a cached voice profile.
    Returns (wav_array, sample_rate).
    """
    with _cache_lock:
        prompt_items = _PROFILE_CACHE.get(profile_id)
    if prompt_items is None:
        # Try loading from disk if not in memory
        if load_profile_from_disk(profile_id):
            with _cache_lock:
                prompt_items = _PROFILE_CACHE.get(profile_id)
    if prompt_items is None:
        raise ValueError(f"Voice profile '{profile_id}' not found. Record voice first.")

    model = get_qwen_tts()

    with GPU_INFERENCE_LOCK:
        audio_list, sample_rate = model.generate_voice_clone(
            text=text,
            language="english",
            voice_clone_prompt=prompt_items,
            non_streaming_mode=False,
            **GENERATION_PARAMS,
        )

    wav = np.concatenate(audio_list) if audio_list else np.zeros(0, dtype=np.float32)
    return wav, sample_rate


def _ensure_stock_voice_profile():
    """Ensure the stock vivian voice profile is loaded (uses Base 1.7B with reference audio)."""
    with _cache_lock:
        if STOCK_VOICE_PROFILE_ID in _PROFILE_CACHE:
            return
    # Create profile from vivian reference audio
    if not VIVIAN_REF_PATH.exists():
        logger.error("Stock voice reference not found at %s", VIVIAN_REF_PATH)
        raise FileNotFoundError(f"Stock voice reference not found: {VIVIAN_REF_PATH}")
    create_voice_profile(str(VIVIAN_REF_PATH), voice_name="Vivian (Stock)", profile_id_override=STOCK_VOICE_PROFILE_ID)
    logger.info("Stock vivian voice profile created from reference audio.")


def synthesize_custom_voice_streaming(
    text: str, speaker: str = "vivian", language: str = "english"
):
    """
    Synthesize text with the Base 1.7B model using stock vivian reference.
    Yields (wav_segment, sample_rate) tuples.
    """
    _ensure_stock_voice_profile()
    with _cache_lock:
        prompt_items = _PROFILE_CACHE.get(STOCK_VOICE_PROFILE_ID)
    if prompt_items is None:
        raise RuntimeError(
            "Stock voice profile could not be created. "
            "Check that assets/vivian_reference.wav exists and the TTS model loaded correctly."
        )

    model = get_qwen_tts()
    with GPU_INFERENCE_LOCK:
        audio_list, sample_rate = model.generate_voice_clone(
            text=text,
            language=language,
            voice_clone_prompt=prompt_items,
            non_streaming_mode=False,
            **GENERATION_PARAMS,
        )

    for segment in audio_list:
        if segment is not None and len(segment) > 0:
            yield segment, sample_rate


def synthesize_custom_voice(
    text: str, speaker: str = "vivian", language: str = "english"
) -> tuple[np.ndarray, int]:
    """
    Synthesize text with the Base 1.7B model using stock vivian reference.
    Returns (wav_array, sample_rate).
    """
    segments = list(synthesize_custom_voice_streaming(text, speaker, language))
    if segments:
        wav = np.concatenate([s for s, _ in segments])
        sr = segments[0][1]
        return wav, sr
    # Fallback: empty audio
    return np.zeros(0, dtype=np.float32), 24000


def synthesize_cloned_preview(profile_id: str) -> tuple[np.ndarray, int]:
    """Short preview sentence to verify the clone sounds right."""
    return synthesize_cloned(
        "Hello! I'm ready to read a bedtime story for you tonight.",
        profile_id,
    )


def has_profile(profile_id: str | None) -> bool:
    """Check if a voice profile exists in cache or on disk."""
    if not profile_id:
        return False
    with _cache_lock:
        if profile_id in _PROFILE_CACHE:
            return True
    # Check disk
    pt_path = VOICE_PROFILE_DIR / profile_id / "profile.pt"
    return pt_path.exists()