-
Notifications
You must be signed in to change notification settings - Fork 64
Expand file tree
/
Copy pathengine.py
More file actions
1582 lines (1351 loc) · 62.2 KB
/
Copy pathengine.py
File metadata and controls
1582 lines (1351 loc) · 62.2 KB
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
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# engine.py
# Core TTS model loading and speech generation logic.
# Supports Dia 1.6B (original) and Dia 2 models (1B, 2B) with hot-swappable model switching.
import gc
import logging
import time
import os
import threading
import torch
import torchaudio
import numpy as np
from typing import Optional, Tuple, List, Dict, Any
from huggingface_hub import hf_hub_download
from tqdm import tqdm
# --- Defensive Imports: Dia 1 (original) ---
try:
from dia.model import (
Dia as Dia1Model,
ComputeDtype,
DEFAULT_SAMPLE_RATE as DIA1_SAMPLE_RATE,
)
from dia.config import DiaConfig
DIA1_AVAILABLE = True
except ImportError:
Dia1Model = None
ComputeDtype = None
DiaConfig = None
DIA1_SAMPLE_RATE = 44100
DIA1_AVAILABLE = False
logging.info("Dia 1 (dia) package not available. Dia 1.6B model will be unavailable.")
# --- Defensive Imports: Dia 2 ---
try:
from dia2 import Dia2, GenerationConfig, SamplingConfig, PrefixConfig, GenerationResult
DIA2_AVAILABLE = True
except ImportError:
Dia2 = None
GenerationConfig = None
SamplingConfig = None
PrefixConfig = None
GenerationResult = None
DIA2_AVAILABLE = False
logging.info("Dia 2 (dia2) package not available. Dia 2 models will be unavailable.")
# Import configuration getters from our project's config.py
from config import (
get_model_repo_id,
get_model_cache_path,
get_reference_audio_path,
get_model_config_filename,
get_model_weights_filename,
get_gen_default_seed,
get_whisper_model_name,
)
# Import text splitting utility and other helpers
from utils import (
chunk_text_by_sentences,
PerformanceMonitor,
trim_lead_trail_silence,
fix_internal_silence,
remove_long_unvoiced_segments,
_generate_transcript_with_whisper,
)
logger = logging.getLogger(__name__)
# --- Model Registry ---
# Maps friendly selector names to model metadata.
MODEL_REGISTRY: Dict[str, Dict[str, Any]] = {
"dia-1.6b": {
"repo_id": "ttj/dia-1.6b-safetensors",
"display_name": "Dia 1.6B (Original)",
"params": "1.6B",
"model_type": "dia1",
"sample_rate": 44100,
"voices": ["dialogue", "single_s1", "single_s2", "clone", "predefined"],
"default_voice": "predefined",
"supports_cloning": True,
"cloning_method": "audio_prompt",
},
"dia2-1b": {
"repo_id": "nari-labs/Dia2-1B",
"display_name": "Dia 2 — 1B (Streaming)",
"params": "1B",
"model_type": "dia2",
"sample_rate": None, # Read from model at runtime
"voices": ["dialogue", "single_s1", "single_s2", "clone", "predefined"],
"default_voice": "predefined",
"supports_cloning": True,
"cloning_method": "prefix_speaker",
},
"dia2-2b": {
"repo_id": "nari-labs/Dia2-2B",
"display_name": "Dia 2 — 2B (High Quality)",
"params": "2B",
"model_type": "dia2",
"sample_rate": None, # Read from model at runtime
"voices": ["dialogue", "single_s1", "single_s2", "clone", "predefined"],
"default_voice": "predefined",
"supports_cloning": True,
"cloning_method": "prefix_speaker",
},
}
# Build reverse lookup: repo_id -> selector key
_REPO_TO_SELECTOR: Dict[str, str] = {}
for _sel, _info in MODEL_REGISTRY.items():
_REPO_TO_SELECTOR[_info["repo_id"]] = _sel
def resolve_selector(config_value: str) -> str:
"""
Resolves a config model.repo_id value to a registry selector key.
Accepts either a selector key directly or a full HuggingFace repo_id.
Returns the selector key, or falls back to 'dia-1.6b' if unknown.
"""
if config_value in MODEL_REGISTRY:
return config_value
if config_value in _REPO_TO_SELECTOR:
return _REPO_TO_SELECTOR[config_value]
lower = config_value.lower().strip()
for sel in MODEL_REGISTRY:
if sel.lower() == lower:
return sel
for repo_id, sel in _REPO_TO_SELECTOR.items():
if repo_id.lower() == lower:
return sel
logger.warning(
f"Unknown model selector '{config_value}'. "
f"Valid selectors: {list(MODEL_REGISTRY.keys())}. "
f"Defaulting to 'dia-1.6b'."
)
return "dia-1.6b"
# --- Global Module Variables ---
dia_model: Optional[Any] = None # Dia1Model or Dia2 instance
model_config_instance: Optional[Any] = None
model_device: Optional[torch.device] = None
MODEL_LOADED: bool = False
EXPECTED_SAMPLE_RATE: int = 44100
# Track which model is loaded
loaded_model_selector: Optional[str] = None
loaded_model_type: Optional[str] = None # "dia1" or "dia2"
# --- Async Loading & Cancellation ---
_load_lock = threading.Lock()
_cancel_event = threading.Event()
_load_thread: Optional[threading.Thread] = None
# Download progress tracking for UI status modal
_download_status: Dict[str, Any] = {
"active": False,
"phase": "",
"detail": "",
"progress_pct": 0,
"error": None,
}
def _check_cancelled():
"""Raises RuntimeError if model loading has been cancelled."""
if _cancel_event.is_set():
raise RuntimeError("Model loading cancelled by user.")
def _update_download_status(phase: str, detail: str = "", progress_pct: int = 0, error: str = None):
"""Update the download status for the UI to poll."""
global _download_status
_download_status = {
"active": error is None and phase != "complete",
"phase": phase,
"detail": detail,
"progress_pct": progress_pct,
"error": error,
}
def get_download_status() -> Dict[str, Any]:
"""Returns the current download/loading status for UI polling."""
return dict(_download_status)
def is_loading() -> bool:
"""Returns True if a model is currently being loaded in the background."""
return _load_thread is not None and _load_thread.is_alive()
def get_model_info() -> Dict[str, Any]:
"""Returns information about the currently loaded model."""
if loaded_model_selector and loaded_model_selector in MODEL_REGISTRY:
reg = MODEL_REGISTRY[loaded_model_selector]
return {
"loaded": MODEL_LOADED,
"selector": loaded_model_selector,
"repo_id": reg["repo_id"],
"display_name": reg["display_name"],
"params": reg["params"],
"model_type": reg["model_type"],
"sample_rate": EXPECTED_SAMPLE_RATE,
"voices": reg["voices"],
"default_voice": reg["default_voice"],
"supports_cloning": reg["supports_cloning"],
"cloning_method": reg["cloning_method"],
"device": str(model_device) if model_device else None,
}
return {
"loaded": MODEL_LOADED,
"selector": loaded_model_selector,
"repo_id": None,
"display_name": None,
"params": None,
"model_type": loaded_model_type,
"sample_rate": EXPECTED_SAMPLE_RATE,
"voices": [],
"default_voice": None,
"supports_cloning": False,
"cloning_method": None,
"device": str(model_device) if model_device else None,
}
def get_model_registry() -> Dict[str, Dict[str, Any]]:
"""Returns the full model registry for the UI dropdown. All models are always selectable."""
result = {}
for k, v in MODEL_REGISTRY.items():
installed = True
if v["model_type"] == "dia1" and not DIA1_AVAILABLE:
installed = False
if v["model_type"] == "dia2" and not DIA2_AVAILABLE:
installed = False
result[k] = {
"display_name": v["display_name"],
"params": v["params"],
"model_type": v["model_type"],
"voices": v["voices"],
"default_voice": v["default_voice"],
"supports_cloning": v["supports_cloning"],
"cloning_method": v["cloning_method"],
"available": True, # Always selectable — will download/install on demand
"installed": installed,
}
return result
# --- Model Loading ---
def get_device() -> torch.device:
"""Determines the optimal torch device (CUDA > MPS > CPU)."""
if torch.cuda.is_available():
logger.info("CUDA is available, using GPU.")
torch.cuda.empty_cache()
return torch.device("cuda")
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
# MPS support check might need refinement based on PyTorch version
try:
# Simple check if MPS device can be created and used
test_tensor = torch.tensor([1.0]).to("mps")
if test_tensor.device.type == "mps":
logger.info("MPS is available, using Apple Silicon GPU.")
return torch.device("mps")
else:
raise RuntimeError("MPS device creation failed.")
except Exception as e:
logger.info(f"MPS not fully available ({e}), falling back to CPU.")
return torch.device("cpu")
else:
logger.info("CUDA and MPS not available, using CPU.")
return torch.device("cpu")
def get_compute_dtype(device: torch.device, weights_filename: str) -> str:
"""
Determines the recommended compute dtype string based on device capabilities
AND the type of model weights indicated by the filename.
Args:
device: The torch.device the model will run on (e.g., torch.device('cuda')).
weights_filename: The filename of the model weights being loaded (e.g., "dia-v0_1_bf16.safetensors").
Returns:
A string representing the compute dtype (e.g., "bfloat16", "float16", "float32").
"""
# Determine if the filename suggests a BF16 model by checking for 'bf16' substring
is_bf16_model = "bf16" in weights_filename.lower()
logger.info(
f"Analyzing weights filename '{weights_filename}': Detected model type appears to be {'BF16' if is_bf16_model else 'Non-BF16 (likely FP32/FP16)'}"
)
# Check if ComputeDtype enum was successfully imported
if ComputeDtype is None:
logger.warning(
"ComputeDtype enum not available from dia.model (import failed?). Defaulting compute dtype selection to basic fallback."
)
# --- Fallback logic without the enum ---
# This provides basic functionality if the enum import fails.
if device.type == "cuda":
# Check hardware capabilities directly
supports_bf16_fallback = torch.cuda.is_bf16_supported()
compute_capability_fallback = torch.cuda.get_device_capability(device)
supports_fp16_fallback = (
compute_capability_fallback[0] >= 7
) # Tensor Cores needed for efficient FP16
if is_bf16_model:
if supports_bf16_fallback:
logger.info(
"Fallback: BF16 model and BF16 supported. Using 'bfloat16'."
)
return "bfloat16"
elif supports_fp16_fallback:
logger.warning(
"Fallback: BF16 model, BF16 not supported. Using 'float16'."
)
return "float16"
else:
logger.warning(
"Fallback: BF16 model, BF16/FP16 not supported. Using 'float32'."
)
return "float32"
else: # Non-BF16 model
logger.info(
"Fallback: Non-BF16 model. Using 'float32' for compatibility."
)
return "float32" # Prioritize FP32 for non-BF16 models
elif device.type == "mps":
logger.info("Fallback: MPS device. Using 'float16'.")
return "float16" # FP16 is generally preferred on MPS
else: # CPU
logger.info("Fallback: CPU device. Using 'float32'.")
return "float32" # FP32 is standard for CPU
# --- Preferred logic using the ComputeDtype enum ---
if device.type == "cuda":
supports_bf16 = torch.cuda.is_bf16_supported()
# Check device capability for FP16 (requires compute capability >= 7.0 for efficient use)
compute_capability = torch.cuda.get_device_capability(device)
supports_fp16 = compute_capability[0] >= 7
if is_bf16_model:
if supports_bf16:
logger.info(
"BF16 model detected and CUDA device supports bfloat16. Using BF16 compute dtype."
)
return ComputeDtype.BFLOAT16.value # Return string value "bfloat16"
elif supports_fp16:
logger.warning(
"BF16 model detected, but CUDA device does NOT support bfloat16. Falling back to FP16 compute dtype."
)
return ComputeDtype.FLOAT16.value # Return string value "float16"
else:
logger.warning(
"BF16 model detected, but CUDA device supports neither bfloat16 nor efficient FP16. Falling back to FP32 compute dtype."
)
return ComputeDtype.FLOAT32.value # Return string value "float32"
else: # Not a BF16 model (e.g., dia-v0_1.safetensors or dia-v0_1.pth)
# ** FIX: If the model isn't explicitly BF16, prioritize FP32 for accuracy, **
# ** regardless of hardware support for lower precisions. **
logger.info(
"Non-BF16 model weights detected. Using FP32 compute dtype on CUDA for maximum compatibility and accuracy."
)
return ComputeDtype.FLOAT32.value # Return string value "float32"
elif device.type == "mps":
# MPS generally works best with FP16 or FP32. Defaulting to FP16 for potential performance benefits.
# If issues arise with non-BF16 models on MPS, consider changing this to FP32.
logger.info(
"MPS device detected. Using FP16 compute dtype as a general recommendation."
)
return ComputeDtype.FLOAT16.value # Return string value "float16"
else: # CPU
logger.info("CPU device detected. Using FP32 compute dtype.")
return ComputeDtype.FLOAT32.value # Return string value "float32"
def _auto_install_dia2():
"""
Attempts to install the dia2 package from GitHub at runtime.
dia2 is NOT on PyPI — must be cloned and installed in editable mode
because the pyproject.toml has a packaging bug (missing subpackages).
"""
global DIA2_AVAILABLE, Dia2, GenerationConfig, SamplingConfig, PrefixConfig, GenerationResult
import subprocess
import sys
DIA2_REPO_URL = "https://github.com/nari-labs/dia2.git"
dia2_src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dia2_src")
try:
_check_cancelled()
# Step 1: Clone the dia2 repo if not already present
if not os.path.isdir(dia2_src_dir):
logger.info(f"Cloning dia2 repository from {DIA2_REPO_URL}...")
_update_download_status("downloading", "Cloning dia2 repository from GitHub...", 10)
result = subprocess.run(
["git", "clone", DIA2_REPO_URL, dia2_src_dir],
capture_output=True, text=True, timeout=300,
)
if result.returncode != 0:
raise RuntimeError(f"git clone failed:\n{result.stderr}")
logger.info("dia2 repository cloned successfully.")
else:
logger.info(f"dia2 source already exists at {dia2_src_dir}, pulling latest...")
_update_download_status("downloading", "Updating dia2 repository...", 10)
subprocess.run(
["git", "-C", dia2_src_dir, "pull", "--ff-only"],
capture_output=True, text=True, timeout=60,
)
_check_cancelled()
# Step 2: Install in editable mode with --no-deps to avoid breaking other packages
logger.info("Installing dia2 package (editable mode, no-deps)...")
_update_download_status("installing", "Installing dia2 package...", 25)
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "-e", dia2_src_dir, "--no-deps"],
capture_output=True, text=True, timeout=300,
)
if result.returncode != 0:
raise RuntimeError(f"pip install -e dia2 failed:\n{result.stderr}")
logger.info("dia2 package installed successfully. Importing...")
from dia2 import Dia2 as _Dia2, GenerationConfig as _GC, SamplingConfig as _SC, PrefixConfig as _PC, GenerationResult as _GR
Dia2 = _Dia2
GenerationConfig = _GC
SamplingConfig = _SC
PrefixConfig = _PC
GenerationResult = _GR
DIA2_AVAILABLE = True
logger.info("dia2 package imported successfully after auto-install.")
except Exception as e:
logger.error(f"Failed to auto-install dia2: {e}", exc_info=True)
raise ImportError(
f"dia2 package could not be installed automatically: {e}. "
"Please install manually: git clone https://github.com/nari-labs/dia2.git dia2_src && pip install -e dia2_src --no-deps"
)
def load_model():
"""
Loads a TTS model based on the current config selector.
Supports both Dia 1 (original) and Dia 2 (1B/2B) models.
Downloads model files from HuggingFace if not cached.
"""
global dia_model, model_config_instance, model_device, MODEL_LOADED, EXPECTED_SAMPLE_RATE
global loaded_model_selector, loaded_model_type
if MODEL_LOADED:
logger.info("Model already loaded.")
_update_download_status("complete", "Model already loaded.", 100)
return True
try:
# Resolve the model selector from config
config_value = get_model_repo_id()
selector = resolve_selector(config_value)
reg = MODEL_REGISTRY[selector]
model_type = reg["model_type"]
repo_id = reg["repo_id"]
# Check availability — auto-install if missing
if model_type == "dia1" and not DIA1_AVAILABLE:
raise ImportError(
"Dia 1 (dia) package is not installed. "
"The local 'dia/' directory with model code is required. "
"Please check that the repository was cloned completely."
)
if model_type == "dia2" and not DIA2_AVAILABLE:
logger.info("dia2 package not found. Attempting to install it automatically...")
_update_download_status("installing", "Installing dia2 package (pip install dia2)...", 5)
_auto_install_dia2()
cache_path = get_model_cache_path()
model_device = get_device()
logger.info(f"Loading model: {reg['display_name']} ({repo_id})")
logger.info(f" Model type: {model_type}")
logger.info(f" Cache directory: {cache_path}")
logger.info(f" Target device: {model_device}")
os.makedirs(cache_path, exist_ok=True)
start_time = time.time()
if model_type == "dia1":
_load_dia1_model(repo_id, cache_path)
elif model_type == "dia2":
_load_dia2_model(repo_id, cache_path)
else:
raise ValueError(f"Unknown model type: {model_type}")
loaded_model_selector = selector
loaded_model_type = model_type
MODEL_LOADED = True
end_time = time.time()
logger.info(f"Model loaded successfully in {end_time - start_time:.2f} seconds.")
_update_download_status("complete", f"{reg['display_name']} loaded successfully.", 100)
return True
except RuntimeError as e:
if "cancelled" in str(e).lower():
logger.info(f"Model loading cancelled: {e}")
_update_download_status("cancelled", "Model loading was cancelled.", 0)
else:
logger.error(f"Model loading error: {e}", exc_info=True)
_update_download_status("error", "", 0, str(e))
MODEL_LOADED = False
return False
except ImportError as e:
logger.critical(f"Missing package: {e}", exc_info=True)
_update_download_status("error", "", 0, str(e))
MODEL_LOADED = False
return False
except Exception as e:
logger.error(f"Error loading model: {e}", exc_info=True)
_update_download_status("error", "", 0, str(e))
dia_model = None
model_config_instance = None
MODEL_LOADED = False
return False
def _load_dia1_model(repo_id: str, cache_path: str):
"""Loads a Dia 1 (original) model."""
global dia_model, model_config_instance, model_device, EXPECTED_SAMPLE_RATE
config_filename = get_model_config_filename()
weights_filename = get_model_weights_filename()
compute_dtype_str = get_compute_dtype(model_device, weights_filename)
logger.info(f" Config file: {config_filename}")
logger.info(f" Weights file: {weights_filename}")
logger.info(f" Compute dtype: {compute_dtype_str}")
# Phase 1: Download config
_check_cancelled()
_update_download_status("downloading", f"Downloading config from {repo_id}...", 10)
local_config_path = hf_hub_download(
repo_id=repo_id,
filename=config_filename,
cache_dir=cache_path,
)
logger.info(f"Configuration file path: {local_config_path}")
# Phase 2: Download weights
_check_cancelled()
_update_download_status("downloading", f"Downloading weights ({weights_filename})...", 30)
local_weights_path = hf_hub_download(
repo_id=repo_id,
filename=weights_filename,
cache_dir=cache_path,
)
logger.info(f"Weights file path: {local_weights_path}")
# Phase 3: Load model
_check_cancelled()
_update_download_status("loading", "Loading Dia 1 model...", 60)
config = DiaConfig.load(local_config_path)
if config is None:
raise FileNotFoundError(f"Config file failed to load from {local_config_path}")
dia_instance = Dia1Model(config, compute_dtype=compute_dtype_str, device=model_device)
# Load weights
logger.info(f"Loading weights from: {local_weights_path}")
map_location = torch.device("cpu")
if local_weights_path.endswith(".safetensors"):
from safetensors.torch import load_file
state_dict = load_file(local_weights_path, device=str(map_location))
elif local_weights_path.endswith(".pth"):
state_dict = torch.load(local_weights_path, map_location=map_location)
else:
raise ValueError(f"Unsupported weights file format: {weights_filename}")
_check_cancelled()
_update_download_status("loading", "Applying weights...", 75)
dia_instance.model.load_state_dict(state_dict)
dia_instance.model.to(model_device)
dia_instance.model.eval()
# Phase 4: Load DAC model
_check_cancelled()
_update_download_status("loading", "Loading DAC audio codec...", 85)
dia_instance._load_dac_model()
dia_model = dia_instance
model_config_instance = dia_model.config
EXPECTED_SAMPLE_RATE = DIA1_SAMPLE_RATE
_update_download_status("loading", "Dia 1 model ready.", 95)
def _load_dia2_model(repo_id: str, cache_path: str):
"""Loads a Dia 2 model using Dia2.from_repo()."""
global dia_model, model_config_instance, model_device, EXPECTED_SAMPLE_RATE
device_str = str(model_device) if model_device else "cuda"
# Determine dtype based on device
if model_device and model_device.type == "cuda":
dtype_str = "bfloat16" if torch.cuda.is_bf16_supported() else "float16"
elif model_device and model_device.type == "mps":
dtype_str = "float16"
else:
dtype_str = "float32"
logger.info(f" Dia 2 dtype: {dtype_str}")
# Phase 1: Download and load (Dia2.from_repo handles this)
_check_cancelled()
_update_download_status("downloading", f"Downloading {repo_id}...", 20)
# Dia2.from_repo downloads from HF and loads the model
_check_cancelled()
_update_download_status("loading", f"Loading Dia 2 model ({repo_id})...", 50)
dia2_instance = Dia2.from_repo(
repo_id,
device=device_str,
dtype=dtype_str,
)
_check_cancelled()
_update_download_status("loading", "Dia 2 model ready.", 95)
dia_model = dia2_instance
model_config_instance = None # Dia 2 doesn't expose config the same way
EXPECTED_SAMPLE_RATE = dia2_instance.sample_rate
logger.info(f"Dia 2 sample rate: {EXPECTED_SAMPLE_RATE}")
def unload_model() -> bool:
"""
Unloads the current model and releases resources.
Does NOT reload - use reload_model_async() for hot-swap.
"""
global dia_model, model_config_instance, MODEL_LOADED
global loaded_model_selector, loaded_model_type, model_device
logger.info("Initiating model unload sequence...")
if dia_model is not None:
# Try to close Dia2 model gracefully
if loaded_model_type == "dia2" and hasattr(dia_model, "close"):
try:
dia_model.close()
except Exception as e:
logger.warning(f"Error closing Dia2 model: {e}")
del dia_model
dia_model = None
if model_config_instance is not None:
del model_config_instance
model_config_instance = None
MODEL_LOADED = False
loaded_model_selector = None
loaded_model_type = None
# Force garbage collection and clear GPU cache
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
logger.info("CUDA cache cleared after model unload.")
logger.info("Model unloaded successfully.")
return True
def _reload_model_worker():
"""
Background worker that performs the actual model reload.
Runs in a separate thread so the FastAPI server stays responsive.
"""
try:
_update_download_status("unloading", "Unloading current model...", 5)
unload_model()
_check_cancelled()
logger.info("Resources cleared. Loading model from updated config...")
load_model()
except RuntimeError as e:
if "cancelled" in str(e).lower():
logger.info(f"Reload worker cancelled: {e}")
_update_download_status("cancelled", "Model loading was cancelled.", 0)
else:
logger.error(f"Reload worker error: {e}", exc_info=True)
_update_download_status("error", "", 0, str(e))
except Exception as e:
logger.error(f"Reload worker error: {e}", exc_info=True)
_update_download_status("error", "", 0, str(e))
def reload_model_async():
"""
Initiates a model hot-swap in a background thread.
Returns immediately so the server can continue serving status polls.
If a load is already in progress, cancels it first.
"""
global _load_thread
logger.info("Initiating async model hot-swap/reload sequence...")
# Cancel any in-progress load
if is_loading():
logger.info("Cancelling in-progress model load...")
_cancel_event.set()
_load_thread.join(timeout=15)
logger.info("Previous load thread finished.")
# Reset cancel flag for new load
_cancel_event.clear()
# Start new background load
_load_thread = threading.Thread(target=_reload_model_worker, daemon=True)
_load_thread.start()
logger.info("Background model reload thread started.")
def cancel_loading():
"""Cancels any in-progress model loading."""
if is_loading():
logger.info("Cancelling model loading by user request...")
_cancel_event.set()
_update_download_status("cancelling", "Cancelling model load...", 0)
return True
return False
def reload_model() -> bool:
"""
Synchronous reload (used for startup only).
Unloads current model, clears resources, reloads from config.
"""
logger.info("Initiating synchronous model reload...")
_cancel_event.clear()
_update_download_status("unloading", "Unloading current model...", 5)
unload_model()
logger.info("Resources cleared. Loading model from updated config...")
return load_model()
# --- Cloning Preparation Helper ---
def _prepare_cloning_inputs(
clone_reference_filename: str,
reference_audio_base_path: str,
max_ref_duration_sec: float,
whisper_model_name: str,
whisper_cache_path: str,
transcript: Optional[str] = None,
) -> Tuple[Optional[str], Optional[str]]: # MODIFIED return type
"""
Prepares inputs for voice cloning: loads/processes audio, gets transcript.
Args:
clone_reference_filename: Filename of the reference audio.
reference_audio_base_path: Base path where reference files are stored.
max_ref_duration_sec: Maximum duration for the reference audio prompt.
whisper_model_name: Name of the Whisper model to use if needed.
whisper_cache_path: Path to Whisper model cache.
transcript: Optional explicit transcript text to override file/Whisper.
Returns:
Tuple of (audio_prompt_tensor, reference_transcript_text, error_message).
On success, error_message is None. On failure, text and tensor are None.
"""
global dia_model # Need access to the loaded Dia model for DAC
reference_audio_path = os.path.join(
reference_audio_base_path, clone_reference_filename
)
if not os.path.isfile(reference_audio_path):
return None, None, f"Reference audio file not found: {reference_audio_path}"
# --- 1. Load and Process Audio (CPU only) ---
try:
logger.info(f"Loading reference audio: {reference_audio_path}")
# Load on CPU without moving to device
audio_tensor, sr = torchaudio.load(reference_audio_path)
# Ensure correct sample rate
if sr != EXPECTED_SAMPLE_RATE:
logger.warning(
f"Reference audio SR ({sr}Hz) differs from expected ({EXPECTED_SAMPLE_RATE}Hz). Resampling..."
)
resampled_tensor = torchaudio.functional.resample(
audio_tensor, sr, EXPECTED_SAMPLE_RATE
)
del audio_tensor # Free the original tensor
audio_tensor = resampled_tensor
# Ensure mono
if audio_tensor.shape[0] > 1:
logger.warning(
f"Reference audio '{clone_reference_filename}' is stereo. Converting to mono."
)
mono_tensor = torch.mean(audio_tensor, dim=0, keepdim=True)
del audio_tensor # Free the stereo tensor
audio_tensor = mono_tensor
# Truncate if necessary
num_samples = audio_tensor.shape[1]
duration_sec = num_samples / EXPECTED_SAMPLE_RATE
if duration_sec > max_ref_duration_sec:
logger.warning(
f"Reference audio duration ({duration_sec:.2f}s) exceeds max ({max_ref_duration_sec:.2f}s). Truncating..."
)
target_samples = int(max_ref_duration_sec * EXPECTED_SAMPLE_RATE)
truncated_tensor = audio_tensor[:, :target_samples].clone()
del audio_tensor # Free the full audio tensor
audio_tensor = truncated_tensor
new_duration = audio_tensor.shape[1] / EXPECTED_SAMPLE_RATE
logger.info(f"Truncated reference audio to {new_duration:.2f}s.")
else:
logger.info(
f"Reference audio duration ({duration_sec:.2f}s) is within limit."
)
# Convert processed tensor to NumPy array (float32) for potential Whisper use
processed_audio_np = audio_tensor.squeeze(0).numpy().astype(np.float32)
# Clear tensors when done with them
del audio_tensor
except Exception as e:
logger.error(
f"Error loading/processing reference audio '{reference_audio_path}': {e}",
exc_info=True,
)
return None, None, f"Failed to load/process reference audio: {e}"
# --- 2. Get Transcript ---
transcript_text: Optional[str] = None
error_message: Optional[str] = None
transcript_source: str = "unknown"
if transcript is not None:
logger.info("Using provided transcript override for cloning.")
transcript_text = transcript.strip()
transcript_source = "explicit"
# Check and prepend [S1] or [S2] if needed (assuming clone target is usually S1)
if not transcript_text.startswith(("[S1]", "[S2]")):
logger.debug("Prepending '[S1] ' to explicit transcript.")
transcript_text = "[S1] " + transcript_text
else:
# Try loading local .txt file
base_name, _ = os.path.splitext(clone_reference_filename)
transcript_filename = base_name + ".txt"
transcript_filepath = os.path.join(
reference_audio_base_path, transcript_filename
)
logger.info(f"Checking for local transcript: {transcript_filepath}")
if os.path.isfile(transcript_filepath):
try:
with open(transcript_filepath, "r", encoding="utf-8") as f:
transcript_text = f.read().strip()
logger.info(f"Loaded transcript from local file: {transcript_filepath}")
transcript_source = "file"
# Assume file is correctly formatted (includes speaker tags)
# Ensure tag exists just in case file is malformed
if not transcript_text.startswith(("[S1]", "[S2]")):
logger.warning(
f"Local transcript file '{transcript_filepath}' missing speaker tag. Prepending '[S1]'."
)
transcript_text = "[S1] " + transcript_text
except Exception as e:
logger.warning(
f"Failed to read local transcript file '{transcript_filepath}': {e}. Will attempt Whisper.",
exc_info=True,
)
transcript_text = None # Ensure it's None so Whisper runs
if transcript_text is None:
# Try Whisper
logger.info(
"Local transcript not found or failed to load. Attempting Whisper generation..."
)
generated_transcript = _generate_transcript_with_whisper(
processed_audio_np, whisper_model_name, whisper_cache_path
)
if generated_transcript is not None:
transcript_text = "[S1] " + generated_transcript.strip() # Prepend [S1]
transcript_source = "whisper"
logger.info("Whisper transcription successful.")
# Save the generated transcript
try:
with open(transcript_filepath, "w", encoding="utf-8") as f:
f.write(transcript_text) # Save with the [S1] tag
logger.info(f"Saved Whisper transcript to: {transcript_filepath}")
except Exception as e:
logger.warning(
f"Failed to save generated transcript to '{transcript_filepath}': {e}",
exc_info=True,
)
else:
logger.error("Whisper transcription failed.")
error_message = "Reference transcript file not found and automatic transcription failed."
transcript_source = "failed"
# --- 3. Check if Transcript was Obtained ---
if transcript_text is None:
# Free resources before returning
del processed_audio_np
if torch.cuda.is_available():
torch.cuda.empty_cache()
return None, None, error_message or "Failed to obtain reference transcript."
# Free resources before returning
del processed_audio_np
if torch.cuda.is_available():
torch.cuda.empty_cache()
# Return without audio_prompt_tensor (step 4 deleted)
return None, transcript_text, None
# --- Helper Function for Robust File Finding ---
def _find_reference_file(filename_input: str, base_path: str) -> Optional[str]:
"""
Finds a reference audio file (.wav or .mp3) in the base_path,
handling case-insensitivity, missing extensions, and extra whitespace/paths.
Args:
filename_input: The potentially "dirty" filename provided by the user/API.
base_path: The directory path where reference files are stored.
Returns:
The actual filename (with correct casing) as it exists on the filesystem,
or None if no suitable match is found.
"""
if not filename_input or not base_path or not os.path.isdir(base_path):
logger.warning(
f"_find_reference_file: Invalid input filename ('{filename_input}') or base_path ('{base_path}')."
)
return None
# 1. Normalize input: remove path components and whitespace
target_basename = os.path.basename(filename_input).strip()
if not target_basename:
logger.warning(
f"_find_reference_file: Input '{filename_input}' resulted in empty basename after normalization."
)
return None
# 2. Get the base name (without extension) and lowercase it for comparison
target_base, target_ext = os.path.splitext(target_basename)
target_base_lower = target_base.lower()
logger.debug(
f"_find_reference_file: Normalized input to base='{target_base_lower}', original_ext='{target_ext}'"
)
# 3. List actual files and compare case-insensitively
try:
for actual_filename in os.listdir(base_path):
if os.path.isfile(os.path.join(base_path, actual_filename)):
actual_base, actual_ext = os.path.splitext(actual_filename)
actual_base_lower = actual_base.lower()
actual_ext_lower = actual_ext.lower()
# Check if base names match (case-insensitive)
if actual_base_lower == target_base_lower:
# Check if the actual extension is valid (.wav or .mp3)
if actual_ext_lower in [".wav", ".mp3"]:
logger.info(
f"_find_reference_file: Found match for '{filename_input}' -> '{actual_filename}'"
)
return actual_filename # Return the filename with its original casing
except OSError as e:
logger.error(
f"Error listing reference directory '{base_path}': {e}", exc_info=True
)
return None # Indicate failure to list directory
logger.warning(