""" Training Script for ViuAI_TTS_200M (SOTA Speech Generation & Zero-Shot Voice Cloning). Optimized for Full Continuous Training on Any GPU (T4, 3090, 4090, 5090, A100, H100/H200). Architectural Features: 1. In-Context Dynamic Same-Speaker Reference Conditioning (Zero-Shot Voice Cloning) 2. Decoupled High-Fidelity Studio Vocoder (100% GPU compute on 175M DiT Flow Matching) 3. Energy-Aware Realistic Duration Modeling (Dynamic vowel/consonant pacing & micro-pauses) 4. Linear Warmup (1,000 steps) + Cosine Annealing Learning Rate 5. Exponential Moving Average (EMA, decay 0.999) for smooth, artifact-free speech 6. Automated Golden Audio Benchmark Suite at Milestones (Hindi/English, Male/Female) """ import sys import os import time import math import csv import wave import random import argparse import glob from collections import Counter, defaultdict from typing import Optional, List, Dict, Tuple import torch import torch.nn as nn import torch.nn.functional as F import torch.distributed as dist import torch.multiprocessing as mp # CUDA Memory Optimization: Prevent fragmentation with expandable_segments os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") # Docker/Rental Cloud SHM Guard: Use file_system to prevent /dev/shm shared memory exhaustion try: mp.set_sharing_strategy("file_system") except Exception: pass # Hardware Performance Accelerators: cuDNN autotuner and Tensor Core precision if torch.cuda.is_available(): torch.backends.cudnn.benchmark = True try: torch.set_float32_matmul_precision("high") except Exception: pass from torch.nn.parallel import DistributedDataParallel as DDP from torch.utils.data import DataLoader, Dataset from torch.utils.data.distributed import DistributedSampler # Setup encoding on Windows if sys.platform == "win32": try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from models import ViuAITTS200M from dataset.tokenizer import ViuAITokenizer from dataset.audio_processing import ( extract_mel_from_file, load_audio_wav, get_default_extractor, extract_pitch_and_energy, ) # --------------------------------------------------------------------------- # Any-GPU auto configuration # --------------------------------------------------------------------------- GPU_PROFILES: List[Tuple[str, Dict]] = [ ("H200", {"batch_size": 32, "num_workers": 2, "precision": "bf16"}), ("H100", {"batch_size": 24, "num_workers": 2, "precision": "bf16"}), ("A100", {"batch_size": 24, "num_workers": 2, "precision": "bf16"}), ("5090", {"batch_size": 32, "num_workers": 2, "precision": "bf16"}), ("4090", {"batch_size": 12, "num_workers": 2, "precision": "bf16"}), ("3090", {"batch_size": 10, "num_workers": 2, "precision": "bf16"}), ("3080", {"batch_size": 8, "num_workers": 2, "precision": "bf16"}), ("A10", {"batch_size": 12, "num_workers": 2, "precision": "bf16"}), ("L4", {"batch_size": 12, "num_workers": 2, "precision": "bf16"}), ("A6000", {"batch_size": 16, "num_workers": 2, "precision": "bf16"}), ("T4", {"batch_size": 8, "num_workers": 2, "precision": "fp16"}), ("V100", {"batch_size": 8, "num_workers": 2, "precision": "fp16"}), ] DEFAULT_PROFILE = {"batch_size": 8, "num_workers": 2, "precision": "fp16"} def get_gpu_profile() -> Tuple[str, Dict]: """Returns (device_label, profile). Works on CPU too (no CUDA).""" if not torch.cuda.is_available(): return "cpu", {"batch_size": 2, "num_workers": 0, "precision": "fp32"} name = torch.cuda.get_device_name(0) for key, prof in GPU_PROFILES: if key.lower() in name.lower(): return name, dict(prof) try: major, _ = torch.cuda.get_device_capability(0) prec = "bf16" if major >= 8 else "fp16" except Exception: prec = "fp16" prof = dict(DEFAULT_PROFILE) prof["precision"] = prec return name, prof def setup_distributed() -> Tuple[bool, int, int, torch.device]: """Init torchrun/DDP if WORLD_SIZE>1. Returns (is_ddp, rank, world, device).""" world = int(os.environ.get("WORLD_SIZE", "1")) if world < 2 or not dist.is_available(): dev = torch.device("cuda" if torch.cuda.is_available() else "cpu") return False, 0, 1, dev rank = int(os.environ.get("RANK", "0")) local_rank = int(os.environ.get("LOCAL_RANK", "0")) backend = "nccl" if (torch.cuda.is_available() and sys.platform != "win32") else "gloo" dist.init_process_group(backend=backend) if torch.cuda.is_available(): torch.cuda.set_device(local_rank) dev = torch.device("cuda", local_rank) else: dev = torch.device("cpu") return True, rank, world, dev def resolve_precision(requested: str, profile_prec: str, device: torch.device) -> Tuple[str, torch.dtype, bool]: """Returns (name, amp_dtype, use_scaler).""" name = requested.lower() if name == "auto": name = profile_prec if device.type == "cuda" else "fp32" if device.type == "cpu": return "fp32", torch.float32, False if name == "bf16": try: major, _ = torch.cuda.get_device_capability(device) if major < 8: name = "fp16" except Exception: pass if name == "bf16": return "bf16", torch.bfloat16, False if name == "fp32": return "fp32", torch.float32, False return "fp16", torch.float16, True # --------------------------------------------------------------------------- # Exponential Moving Average (EMA) & Warmup Learning Rate Scheduler # --------------------------------------------------------------------------- class EMAModel: """Maintains Exponential Moving Average (EMA) of model weights for smooth, artifact-free speech.""" def __init__(self, model: nn.Module, decay: float = 0.999): self.decay = decay self.shadow: Dict[str, torch.Tensor] = {} for name, param in model.named_parameters(): if param.requires_grad: self.shadow[name] = param.data.clone().detach() @torch.no_grad() def update(self, model: nn.Module): for name, param in model.named_parameters(): if param.requires_grad and name in self.shadow: self.shadow[name].lerp_(param.data, 1.0 - self.decay) def state_dict(self) -> Dict[str, torch.Tensor]: return self.shadow def copy_to(self, model: nn.Module): """Loads EMA weights into model.""" model_state = model.state_dict() for name, param in self.shadow.items(): if name in model_state: model_state[name].copy_(param) class WarmupCosineScheduler(torch.optim.lr_scheduler._LRScheduler): """Linear warmup for stability followed by Cosine Annealing decay.""" def __init__(self, optimizer, warmup_steps: int, total_steps: int, min_lr: float = 1e-6, last_epoch: int = -1): self.warmup_steps = max(1, warmup_steps) self.total_steps = max(self.warmup_steps + 1, total_steps) self.min_lr = min_lr super().__init__(optimizer, last_epoch) def get_lr(self): step = max(1, self.last_epoch) if step <= self.warmup_steps: alpha = step / float(self.warmup_steps) return [base_lr * alpha for base_lr in self.base_lrs] else: progress = (step - self.warmup_steps) / max(1, (self.total_steps - self.warmup_steps)) progress = min(max(progress, 0.0), 1.0) cosine_decay = 0.5 * (1.0 + math.cos(math.pi * progress)) return [self.min_lr + (base_lr - self.min_lr) * cosine_decay for base_lr in self.base_lrs] # --------------------------------------------------------------------------- # Rental GPU Cloud-Sync & Auto-Recovery Engine # --------------------------------------------------------------------------- def resolve_resume_checkpoint( resume_arg: Optional[str], checkpoint_dir: str = "checkpoints", repo_id: str = "ViuAI/ViuAI_TTS_200M", ) -> Optional[str]: """Resolves checkpoint path. If 'auto' or 'latest', checks locally first, then downloads from Hugging Face.""" if not resume_arg or resume_arg.lower() in ("none", "null", "false", "0"): return None def _is_healthy(path: str) -> bool: if not os.path.exists(path) or os.path.getsize(path) < 1000: return False try: torch.load(path, map_location="cpu", weights_only=True) return True except Exception: try: torch.load(path, map_location="cpu", weights_only=False) return True except Exception: return False # 1. If explicit existing file path if os.path.exists(resume_arg) and os.path.isfile(resume_arg): if _is_healthy(resume_arg): return resume_arg print(f"[!] Warning: Specified checkpoint {resume_arg} is corrupt/unreadable! Searching alternatives...") # 2. If 'auto' or 'latest' if resume_arg.lower() in ("auto", "latest", "true", "1") or not _is_healthy(resume_arg): # Check local latest latest_local = os.path.join(checkpoint_dir, "viuai_tts_latest.pt") if _is_healthy(latest_local): print(f"[*] 🔄 Found valid local latest checkpoint: {latest_local}") return latest_local # Search all healthy local step checkpoints (e.g. step_006000.pt) step_ckpts = sorted(glob.glob(os.path.join(checkpoint_dir, "viuai_tts_step_*.pt")), reverse=True) for s_ckpt in step_ckpts: if _is_healthy(s_ckpt): print(f"[*] 🔄 Restoring from latest healthy step checkpoint: {s_ckpt}") return s_ckpt # Search all healthy local epoch checkpoints epoch_ckpts = sorted(glob.glob(os.path.join(checkpoint_dir, "viuai_tts_epoch_*.pt")), reverse=True) for e_ckpt in epoch_ckpts: if _is_healthy(e_ckpt): print(f"[*] 🔄 Restoring from latest healthy epoch checkpoint: {e_ckpt}") return e_ckpt # Check Hugging Face for remote checkpoint print(f"[*] ☁️ Checking Hugging Face ({repo_id}) for remote checkpoint 'checkpoints/viuai_tts_latest.pt'...") try: from huggingface_hub import hf_hub_download, get_token token = get_token() or os.environ.get("HF_TOKEN") os.makedirs(checkpoint_dir, exist_ok=True) downloaded = hf_hub_download( repo_id=repo_id, filename="checkpoints/viuai_tts_latest.pt", local_dir=".", token=token, ) if _is_healthy(downloaded): print(f"[OK] ☁️ Restored latest checkpoint from Hugging Face: {downloaded}") return downloaded else: # Try step_006000 from HF if latest on HF had an issue s6000 = hf_hub_download( repo_id=repo_id, filename="checkpoints/viuai_tts_step_006000.pt", local_dir=".", token=token, ) if _is_healthy(s6000): print(f"[OK] ☁️ Restored step 6000 from Hugging Face: {s6000}") return s6000 except Exception as e: print(f"[*] Note: No existing cloud checkpoint found ({e}). Starting fresh training from Epoch 1.") return None return None def sync_checkpoint_to_hub( checkpoint_path: str, repo_id: str = "ViuAI/ViuAI_TTS_200M", benchmark_dir: Optional[str] = None, ): """Uploads milestone checkpoint and benchmark audio to Hugging Face so training progress is never lost.""" try: from huggingface_hub import HfApi, get_token token = get_token() or os.environ.get("HF_TOKEN") if not token: print(" [!] Warning: Hugging Face authentication token not found. Checkpoint saved locally only.") return api = HfApi(token=token) filename = os.path.basename(checkpoint_path) # 1. Upload milestone checkpoint print(f" [☁️ CLOUD SYNC] Uploading {filename} to Hugging Face ({repo_id})...") api.upload_file( path_or_fileobj=checkpoint_path, path_in_repo=f"checkpoints/{filename}", repo_id=repo_id, repo_type="model", ) # 2. Update 'viuai_tts_latest.pt' pointer on HF api.upload_file( path_or_fileobj=checkpoint_path, path_in_repo="checkpoints/viuai_tts_latest.pt", repo_id=repo_id, repo_type="model", ) # 3. Upload audio benchmark WAVs if present if benchmark_dir and os.path.exists(benchmark_dir): b_tag = os.path.basename(benchmark_dir) for wpath in glob.glob(os.path.join(benchmark_dir, "*.wav")): wname = os.path.basename(wpath) api.upload_file( path_or_fileobj=wpath, path_in_repo=f"eval_benchmarks/{b_tag}/{wname}", repo_id=repo_id, repo_type="model", ) print(f" [☁️ CLOUD BACKUP OK] {filename} & viuai_tts_latest.pt safely backed up to Hugging Face!") except Exception as e: print(f" [!] Cloud sync notice: {e}") # --------------------------------------------------------------------------- # High-Performance Dataset with Dynamic In-Context Reference Audio # --------------------------------------------------------------------------- class ViuAISpeechDataset(Dataset): """ High-Performance Balanced Dataset Loader for ViuAI_TTS_Dataset. Guarantees 50% Hindi / 50% English, balanced Gender priors, and same-speaker in-context reference pairing. """ def __init__(self, dataset_dir: str): self.dataset_dir = dataset_dir self.tokenizer = ViuAITokenizer() self.records = [] self.sample_weights = [] self.speaker_to_indices = defaultdict(list) meta_path = os.path.join(dataset_dir, "metadata.csv") if os.path.exists(meta_path): raw_items = [] with open(meta_path, "r", encoding="utf-8") as f: reader = csv.reader(f, delimiter="|") for row in reader: if len(row) >= 4 and row[0] != "audio_file": rel_path = row[0].strip() transcript = row[1].strip() speaker = row[2].strip() lang = row[3].strip() full_audio_path = os.path.join(dataset_dir, rel_path) if os.path.exists(full_audio_path): raw_items.append((full_audio_path, transcript, speaker, lang)) # Compute balanced language & gender weights spk_counts = Counter(item[2] for item in raw_items) lang_counts = Counter(item[3] for item in raw_items) for idx, (full_p, txt, spk, lang) in enumerate(raw_items): self.records.append((full_p, txt, spk, lang)) self.speaker_to_indices[spk].append(idx) # 50% mass to Hindi, 50% to English; balanced within speaker pools lang_w = 0.5 / max(1, lang_counts.get(lang, 1)) spk_w = 0.5 / max(1, spk_counts.get(spk, 1)) self.sample_weights.append(lang_w + spk_w) print(f"[*] ViuAISpeechDataset loaded {len(self.records)} verified audio-text pairs across {len(self.speaker_to_indices)} speakers from {dataset_dir}") if self.sample_weights: print(" - ⚖️ Balanced Sampler Active: 50% Hindi / 50% English & Balanced Gender priors!") print(" - 🎙️ In-Context Voice Conditioning Active: Dynamic same-speaker reference audio pairing enabled!") def __len__(self): return len(self.records) def __getitem__(self, idx): audio_path, text, speaker, lang = self.records[idx] tokens = torch.tensor(self.tokenizer.text_to_ids(text), dtype=torch.long) # 1. Load 24kHz audio waveform ONCE in-memory wav = load_audio_wav(audio_path, target_sr=24000) if wav is None: wav = torch.zeros(1, 24000) # OOM Guard: Cap audio duration to max 10.0s (240,000 samples) to prevent quadratic attention spikes max_samples = 24000 * 10 if wav.shape[-1] > max_samples: wav = wav[:, :max_samples] # 2. Extract 80-channel mel directly from in-memory waveform extractor = get_default_extractor(sample_rate=24000) mel = extractor(wav) if mel is None: mel = torch.randn(80, max(20, len(tokens) * 4)) else: mel = mel.squeeze(0) # [80, T_mel] # OOM Guard: Cap mel frames to max 800 frames (~9.3 seconds) if mel.shape[-1] > 800: mel = mel[:, :800] T_text = len(tokens) T_mel = mel.shape[-1] # Extract real acoustic pitch (F0) and RMS energy from speech waveform first frame_pitch, frame_energy = extract_pitch_and_energy(wav, sample_rate=24000, hop_length=256) if frame_pitch.numel() > 0: text_pitch = F.interpolate( frame_pitch.view(1, 1, -1), size=T_text, mode="linear", align_corners=False ).view(T_text) else: text_pitch = torch.zeros(T_text, dtype=torch.float32) if frame_energy.numel() > 0: text_energy = F.interpolate( frame_energy.view(1, 1, -1), size=T_text, mode="linear", align_corners=False ).view(T_text) else: text_energy = torch.zeros(T_text, dtype=torch.float32) # 3. Dynamic Energy-Aware Duration Allocation # Content characters receive more frames; vowels get high duration, spaces/punct get micro-pauses token_weights = torch.ones(T_text, dtype=torch.float32) for ti, ch_id in enumerate(tokens): if ch_id in (0, 1, 2, 3): # pad, unk, bos, eos token_weights[ti] = 0.4 elif ch_id in (4, 5, 6, 7, 8, 9, 10, 11): # punctuation/space token_weights[ti] = 0.9 else: token_weights[ti] = 2.4 # speech letters/Devanagari matras # Modulate content weights with active speech energy ratio if frame_energy.numel() > 0: speech_ratio = (frame_energy > 0.15).float().mean().item() token_weights[token_weights > 1.0] *= (1.0 + speech_ratio * 0.4) token_weights = token_weights / token_weights.sum() durations = torch.clamp(torch.round(token_weights * T_mel).long(), min=1) diff = T_mel - int(durations.sum().item()) # Distribute residual evenly across content tokens (NEVER dumping all diff into token 0!) if diff > 0: sort_indices = torch.argsort(token_weights, descending=True) for i in range(diff): durations[sort_indices[i % T_text]] += 1 elif diff < 0: sort_indices = torch.argsort(durations, descending=True) for i in range(abs(diff)): for idx in sort_indices: if durations[idx] > 1: durations[idx] -= 1 break # 4. In-Context Reference Audio from SAME Speaker for Zero-Shot Voice Conditioning # Fast RAM-cached path: avoids reloading duplicate reference audio files from disk ref_mel = None cached_refs = getattr(self, "speaker_ref_mels", {}).get(speaker) if cached_refs and len(cached_refs) > 0 and random.random() < 0.85: ref_mel = random.choice(cached_refs) else: spk_indices = self.speaker_to_indices.get(speaker, [idx]) ref_idx = random.choice(spk_indices) if len(spk_indices) > 1 else idx if ref_idx == idx: ref_wav = wav else: ref_audio_path, _, _, _ = self.records[ref_idx] ref_wav = load_audio_wav(ref_audio_path, target_sr=24000) if ref_wav is not None: # Cap reference audio to max ~3.5 seconds (84,000 samples) to prevent VRAM spikes max_ref_samples = 24000 * 3.5 if ref_wav.shape[-1] > max_ref_samples: ref_wav = ref_wav[:, :int(max_ref_samples)] ref_mel = extractor(ref_wav).squeeze(0) # [80, T_ref] else: ref_mel = mel[:, :min(T_mel, 150)] if not hasattr(self, "speaker_ref_mels"): self.speaker_ref_mels = {} if speaker not in self.speaker_ref_mels: self.speaker_ref_mels[speaker] = [] if len(self.speaker_ref_mels[speaker]) < 4: self.speaker_ref_mels[speaker].append(ref_mel.detach().clone()) # Return dummy wav to eliminate 30MB of /dev/shm IPC serialization per batch (vocoder is decoupled) dummy_wav = torch.zeros(1, dtype=torch.float32) return tokens, mel, durations, text_pitch, text_energy, dummy_wav, ref_mel def speech_collate_fn(batch): tokens_list, mels_list, durs_list, pitch_list, energy_list, audio_list, ref_mels_list = zip(*batch) # 1. Text lengths & padding text_lengths = torch.tensor([len(t) for t in tokens_list], dtype=torch.long) max_token_len = max(len(t) for t in tokens_list) padded_tokens = torch.zeros(len(tokens_list), max_token_len, dtype=torch.long) padded_durs = torch.zeros(len(durs_list), max_token_len, dtype=torch.long) padded_pitch = torch.zeros(len(pitch_list), max_token_len, dtype=torch.float32) padded_energy = torch.zeros(len(energy_list), max_token_len, dtype=torch.float32) for i in range(len(tokens_list)): t_len = len(tokens_list[i]) padded_tokens[i, :t_len] = tokens_list[i] padded_durs[i, :t_len] = durs_list[i] padded_pitch[i, :t_len] = pitch_list[i] padded_energy[i, :t_len] = energy_list[i] # 2. Mel lengths & padding: [B, 80, max_mel_len] mel_lengths = torch.tensor([m.shape[-1] for m in mels_list], dtype=torch.long) max_mel_len = max(m.shape[-1] for m in mels_list) padded_mels = torch.zeros(len(mels_list), 80, max_mel_len, dtype=torch.float32) for i, m in enumerate(mels_list): padded_mels[i, :, :m.shape[-1]] = m # 3. Reference Mels: [B, 80, max_ref_len] max_ref_len = max(r.shape[-1] for r in ref_mels_list) padded_ref_mels = torch.zeros(len(ref_mels_list), 80, max_ref_len, dtype=torch.float32) for i, r in enumerate(ref_mels_list): padded_ref_mels[i, :, :r.shape[-1]] = r # 4. Audio waveforms: Minimal dummy tensor to keep shared memory under 200KB per batch (vocoder is decoupled) padded_audios = torch.zeros(len(tokens_list), 1, 1, dtype=torch.float32) return padded_tokens, padded_mels, padded_durs, text_lengths, mel_lengths, padded_pitch, padded_energy, padded_audios, padded_ref_mels # --------------------------------------------------------------------------- # Golden Audio Benchmark Suite (Hindi & English, Male & Female) # --------------------------------------------------------------------------- def run_golden_benchmark(model, dataset_dir: str, tag: str, output_dir: str = "checkpoints/eval_benchmarks"): """ Evaluates 4 fixed audio benchmarks: 1. Hindi Male (Deep Baritone) 2. Hindi Female (Expressive Melodic) 3. English Male (Flagship Deep Adam style) 4. English Female (Clear Narrator) Saves WAV files directly into output_dir/{tag}/ """ model.eval() save_dir = os.path.join(output_dir, str(tag)) os.makedirs(save_dir, exist_ok=True) device = next(model.parameters()).device tokenizer = ViuAITokenizer() test_prompts = [ ("01_hindi_male", "भारतीय संस्कृति, संगीत और इतिहास की यह एक अनूठी गाथा है।", "indic_hi_male_1"), ("02_hindi_female", "नमस्ते! आज का मौसम बहुत ही सुहावना है, आप कैसे हैं?", "indic_hi_female_4"), ("03_english_male", "The future of artificial intelligence begins with human speech and deep resonance.", "en_flagship"), ("04_english_female", "Welcome to our studio, where advanced technology meets creative natural expression.", "en_female"), ] print(f"\n🎧 [GENERATING GOLDEN AUDIO BENCHMARK FOR {tag}]...") for file_tag, text, spk_pref in test_prompts: try: token_ids = tokenizer.text_to_ids(text) token_tensor = torch.tensor([token_ids], dtype=torch.long, device=device) ref_mel = None if dataset_dir: patterns = [ os.path.join(dataset_dir, "audio", spk_pref, "*.wav"), os.path.join(dataset_dir, "audio", f"*{spk_pref}*.wav"), os.path.join(dataset_dir, "**", f"*{spk_pref}*.wav"), os.path.join(dataset_dir, "audio", "*", "*.wav"), os.path.join(dataset_dir, "audio", "*.wav"), ] candidates = [] for p in patterns: candidates = glob.glob(p, recursive=True) if candidates: break if candidates: ref_mel = extract_mel_from_file(candidates[0], target_sr=24000) if ref_mel is not None: ref_mel = ref_mel.to(device) with torch.no_grad(): audio_out, _ = model.generate( text_tokens=token_tensor, ref_mel=ref_mel, num_steps=20, cfg_scale=1.6, speed=1.0, ) out_wav_path = os.path.join(save_dir, f"{file_tag}.wav") audio_flat = audio_out.squeeze().detach().cpu().float() # Step A: DC Offset Removal (Removes massive electrical hum/bias) audio_flat = audio_flat - audio_flat.mean() # Step B: High-pass filtering (Remove sub-audible rumble below 50Hz) try: import torchaudio audio_flat = torchaudio.functional.highpass_biquad(audio_flat.unsqueeze(0), 24000, cutoff_freq=50.0).squeeze(0) audio_flat = audio_flat - audio_flat.mean() except Exception: pass # Step C: Peak normalize to -1 dB (0.89) safely without amplifying DC noise max_amp = audio_flat.abs().max().item() if max_amp > 1e-5: audio_flat = audio_flat * (0.89 / max_amp) audio_flat = audio_flat.clamp(-1.0, 1.0) audio_int16 = (audio_flat * 32767.0).to(torch.int16).numpy().tobytes() with wave.open(out_wav_path, "wb") as wav_out: wav_out.setnchannels(1) wav_out.setsampwidth(2) wav_out.setframerate(24000) wav_out.writeframes(audio_int16) print(f" [OK] Saved {file_tag}.wav ({len(audio_flat)/24000:.1f}s)") except Exception as e: print(f" [!] Benchmark {file_tag} note: {e}") print(f" [🎧 BENCHMARKS READY] Audio files saved at: {save_dir}\n") model.train() # --------------------------------------------------------------------------- # Master Training Engine # --------------------------------------------------------------------------- def train( dataset_dir: str = None, epochs: int = 200, batch_size: int = 0, # 0/auto => GPU profile default lr: float = 1e-4, checkpoint_dir: str = "checkpoints", seed: int = 42, precision: str = "auto", # auto|fp16|bf16|fp32 grad_accum: int = 1, num_workers: int = -1, # -1/auto => GPU profile default max_steps: int = 0, # 0 => no cap, runs full training across all epochs warmup_steps: int = 1000, resume: str = "auto", # 'auto' automatically checks local & Hugging Face for latest checkpoint push_to_hub: bool = False, # True automatically backs up checkpoints & benchmarks to Hugging Face hf_repo: str = "ViuAI/ViuAI_TTS_200M", save_every: int = 5, save_every_steps: int = 0, log_every: int = 10, train_vocoder: bool = False, ): is_ddp, rank, world, device = setup_distributed() is_main = (rank == 0) torch.manual_seed(seed + rank) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed + rank) gpu_label, profile = get_gpu_profile() if batch_size is None or batch_size <= 0: batch_size = profile["batch_size"] if num_workers is None or num_workers < 0: num_workers = profile["num_workers"] prec_name, amp_dtype, use_scaler = resolve_precision(precision, profile["precision"], device) if device.type == "cuda": try: torch.backends.cuda.matmul.allow_tf32 = True torch.backends.cudnn.allow_tf32 = True torch.backends.cudnn.benchmark = True except Exception: pass try: torch.set_float32_matmul_precision("high") except Exception: pass if is_main: print("=" * 70) print(f"🚀 Training ViuAI_TTS_200M (Master SOTA Mode) | device={device}") print(f" GPU: '{gpu_label}' | Precision: {prec_name}") print(f" Batch/GPU: {batch_size} x Accum: {grad_accum} x World: {world} = Effective Batch {batch_size * grad_accum * world}") print(f" In-Context Reference Conditioning: Enabled (Same-Speaker Zero-Shot Voice Cloning)") print(f" Decoupled Vocoder: Enabled (100% GPU compute dedicated to DiT Flow Matching)") print(f" Full Training Target: {epochs} Epochs (Continuous Training)") print("=" * 70) # 1. Initialize Model (~190.5M Parameters) model = ViuAITTS200M().to(device) if is_main: params_m = model.count_parameters()["Total Trainable Parameters"] / 1e6 print(f"[*] Model Loaded: {params_m:.2f}M Parameters") if is_ddp: model = DDP(model, device_ids=[device.index] if device.type == "cuda" else None) raw_model = model.module if is_ddp else model # Exponential Moving Average (EMA) for artifact-free audio ema = EMAModel(raw_model, decay=0.999) # 2. Locate Dataset possible_dirs = [ dataset_dir, "./dataset", "./colab_dataset", "../colab_dataset", "../ViuAI_TTS_Dataset", "../../ViuAI_TTS_Dataset", "../dataset", ] chosen_dir = None for d in possible_dirs: if d and os.path.exists(os.path.join(d, "metadata.csv")): chosen_dir = d break if not chosen_dir: if is_main: print("[*] No local dataset found. Auto-downloading ViuAI_TTS_Dataset from Hugging Face...") try: from huggingface_hub import snapshot_download, get_token chosen_dir = "./dataset" hf_tok = os.environ.get("HF_TOKEN") or get_token() snapshot_download( "ViuAI/ViuAI_TTS_Dataset", repo_type="dataset", local_dir=chosen_dir, token=hf_tok, max_workers=8, ) print(f"[OK] Dataset downloaded successfully to: {chosen_dir}") except Exception as e: print(f"[!] Failed to auto-download dataset: {e}") chosen_dir = None if is_ddp: dist.barrier() chosen_dir = "./dataset" if not chosen_dir or not os.path.exists(os.path.join(chosen_dir, "metadata.csv")): print("[!] No dataset folder found with metadata.csv. Exiting.") if is_ddp: dist.destroy_process_group() return dataset = ViuAISpeechDataset(chosen_dir) if is_ddp: sampler = DistributedSampler(dataset, shuffle=True) elif dataset.sample_weights: from torch.utils.data import WeightedRandomSampler sampler = WeightedRandomSampler( weights=dataset.sample_weights, num_samples=len(dataset), replacement=True, ) else: sampler = None dataloader = DataLoader( dataset, batch_size=batch_size, shuffle=(sampler is None), sampler=sampler, collate_fn=speech_collate_fn, drop_last=True, num_workers=num_workers, pin_memory=(device.type == "cuda"), persistent_workers=(num_workers > 0), prefetch_factor=(2 if num_workers > 0 else None), ) # 3. Setup Optimizer & Warmup Cosine Annealing LR Scheduler optimizer = torch.optim.AdamW( model.parameters(), lr=lr, betas=(0.9, 0.99), eps=1e-8, weight_decay=0.01, ) steps_per_epoch = max(1, len(dataloader)) total_steps = epochs * steps_per_epoch if max_steps and max_steps > 0: total_steps = min(total_steps, max_steps) scheduler = WarmupCosineScheduler( optimizer, warmup_steps=warmup_steps, total_steps=total_steps, min_lr=1e-6, ) scaler = torch.amp.GradScaler("cuda", enabled=(use_scaler and device.type == "cuda")) # 4. Checkpoints directory if is_main: os.makedirs(checkpoint_dir, exist_ok=True) if is_ddp: dist.barrier() # 5. Optional resume (supports local & Hugging Face auto-recovery) start_epoch, step = 1, 0 resolved_resume = resolve_resume_checkpoint(resume, checkpoint_dir=checkpoint_dir, repo_id=hf_repo) if resolved_resume and os.path.exists(resolved_resume): try: ckpt = torch.load(resolved_resume, map_location=device, weights_only=True) except Exception: ckpt = torch.load(resolved_resume, map_location=device, weights_only=False) sd = ckpt.get("model_state_dict", ckpt) raw_model.load_state_dict(sd, strict=False) if "ema_state_dict" in ckpt: for k, v in ckpt["ema_state_dict"].items(): if k in ema.shadow: ema.shadow[k].copy_(v) if "optimizer_state_dict" in ckpt: try: optimizer.load_state_dict(ckpt["optimizer_state_dict"]) except Exception as e: if is_main: print(f"[!] Optimizer state skipped: {e}") if "scheduler_state_dict" in ckpt: try: scheduler.load_state_dict(ckpt["scheduler_state_dict"]) except Exception as e: if is_main: print(f"[!] Scheduler state skipped: {e}") start_epoch = int(ckpt.get("epoch", 0)) + 1 step = int(ckpt.get("step", 0)) if is_main: print(f"[*] 🔄 Resumed seamlessly from {resolved_resume} (next epoch: {start_epoch}, next step: {step + 1})") if is_main: print(f"[*] Checkpoints will be saved locally to: {checkpoint_dir}") if push_to_hub: print(f" - ☁️ Auto-Cloud Backup Active: Every checkpoint will be backed up to Hugging Face ({hf_repo})") print(f"[*] Total Batches per Epoch: {len(dataloader)}") print(f"[*] Total Training Steps: {total_steps} (Linear Warmup {warmup_steps} steps + Cosine Decay)") print("-" * 70) model.train() start_time = time.time() accum = max(1, grad_accum) for epoch in range(start_epoch, epochs + 1): if is_ddp and sampler is not None and hasattr(sampler, "set_epoch"): sampler.set_epoch(epoch) epoch_loss = 0.0 for batch_idx, batch in enumerate(dataloader): batch_tokens, batch_mels, batch_durs, text_lens, mel_lens, batch_pitch, batch_energy, batch_audios, batch_ref_mels = [ b.to(device, non_blocking=True) if isinstance(b, torch.Tensor) else b for b in batch ] is_accum_step = ((batch_idx + 1) % accum != 0) ctx = model.no_sync() if (is_ddp and is_accum_step) else torch.enable_grad() with ctx: with torch.amp.autocast("cuda" if device.type == "cuda" else "cpu", dtype=amp_dtype, enabled=(device.type == "cuda" and prec_name != "fp32")): losses = raw_model.forward_train( text_tokens=batch_tokens, mel_target=batch_mels, durations=batch_durs, pitch_target=batch_pitch, energy_target=batch_energy, ref_mel=batch_ref_mels, text_lengths=text_lens, mel_lengths=mel_lens, cond_drop_prob=0.15, audio_target=batch_audios, train_vocoder=train_vocoder, ) loss = losses["total_loss"] / accum scaler.scale(loss).backward() if is_accum_step: continue step += 1 scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) scaler.step(optimizer) scaler.update() # Update EMA weights ema.update(raw_model) optimizer.zero_grad(set_to_none=True) scheduler.step() epoch_loss += losses["total_loss"].item() if is_main and (step % log_every == 0 or step == 1): elapsed = time.time() - start_time current_lr = scheduler.get_last_lr()[0] print( f"Epoch [{epoch:02d}/{epochs:02d}] Step {step:05d} | " f"Total Loss: {losses['total_loss'].item():.4f} | " f"Flow Loss: {losses['flow_loss'].item():.4f} | " f"Prosody Loss: {losses['prosody_loss'].item():.4f} | " f"LR: {current_lr:.2e} | " f"Time: {elapsed:.1f}s" ) # Optional step-based milestone save if is_main and save_every_steps and save_every_steps > 0 and step % save_every_steps == 0: step_ckpt = os.path.join(checkpoint_dir, f"viuai_tts_step_{step:06d}.pt") latest_ckpt = os.path.join(checkpoint_dir, "viuai_tts_latest.pt") step_payload = { "epoch": epoch, "step": step, "model_state_dict": raw_model.state_dict(), "ema_state_dict": ema.state_dict(), "optimizer_state_dict": optimizer.state_dict(), "scheduler_state_dict": scheduler.state_dict(), "loss": losses["total_loss"].item(), } torch.save(step_payload, step_ckpt) torch.save(step_payload, latest_ckpt) print(f"\n [🎯 MILESTONE SAVED: Step {step}] -> {step_ckpt} (updated {latest_ckpt})") # Benchmark with EMA weights b_dir = os.path.join(checkpoint_dir, "eval_benchmarks", f"step_{step:06d}") try: eval_model = ViuAITTS200M().to(device) eval_model.load_state_dict(raw_model.state_dict(), strict=False) ema.copy_to(eval_model) run_golden_benchmark(eval_model, chosen_dir, f"step_{step:06d}", os.path.join(checkpoint_dir, "eval_benchmarks")) del eval_model except Exception as eb: print(f" [!] Benchmark notice: {eb}") if is_main and push_to_hub: sync_checkpoint_to_hub( checkpoint_path=step_ckpt, repo_id=hf_repo, benchmark_dir=b_dir if os.path.exists(b_dir) else None, ) if max_steps and max_steps > 0 and step >= max_steps: break if is_ddp: dist.barrier() if is_main: avg_loss = epoch_loss / max(1, steps_per_epoch) print(f"\n---> Epoch {epoch:02d}/{epochs:02d} Completed | Avg Loss: {avg_loss:.4f}") # ALWAYS save latest_ckpt at the end of every epoch so training can seamlessly resume anytime latest_ckpt = os.path.join(checkpoint_dir, "viuai_tts_latest.pt") ckpt_payload = { "epoch": epoch, "step": step, "model_state_dict": raw_model.state_dict(), "ema_state_dict": ema.state_dict(), "optimizer_state_dict": optimizer.state_dict(), "scheduler_state_dict": scheduler.state_dict(), "loss": avg_loss, } torch.save(ckpt_payload, latest_ckpt) # Save milestone epoch checkpoint (every `save_every` epochs or last epoch) if epoch % save_every == 0 or epoch == epochs or (max_steps and step >= max_steps): ckpt_path = os.path.join(checkpoint_dir, f"viuai_tts_epoch_{epoch:02d}.pt") torch.save(ckpt_payload, ckpt_path) print(f" [CHECKPOINT SAVED] -> {ckpt_path} (updated {latest_ckpt})") # Benchmark evaluation using smoothed EMA weights b_dir = os.path.join(checkpoint_dir, "eval_benchmarks", f"epoch_{epoch:02d}") try: eval_model = ViuAITTS200M().to(device) eval_model.load_state_dict(raw_model.state_dict(), strict=False) ema.copy_to(eval_model) run_golden_benchmark(eval_model, chosen_dir, f"epoch_{epoch:02d}", os.path.join(checkpoint_dir, "eval_benchmarks")) del eval_model except Exception as eb: print(f" [!] Benchmark notice: {eb}") # Cloud sync to Hugging Face if enabled if is_main and push_to_hub: sync_checkpoint_to_hub( checkpoint_path=ckpt_path, repo_id=hf_repo, benchmark_dir=b_dir if os.path.exists(b_dir) else None, ) if device.type == "cuda": torch.cuda.empty_cache() if max_steps and max_steps > 0 and step >= max_steps: break if is_ddp: dist.barrier() dist.destroy_process_group() if is_main: print("=" * 70) print("🎉 Full Training Completed Successfully across all epochs!") print("=" * 70) if __name__ == "__main__": parser = argparse.ArgumentParser(description="ViuAI_TTS_200M Master Training (SOTA Mode)") parser.add_argument("--data_dir", type=str, default=None, help="Path to dataset dir with metadata.csv") parser.add_argument("--epochs", type=int, default=200, help="Number of full training epochs (default: 200)") parser.add_argument("--batch_size", type=int, default=0, help="Per-GPU batch size (0/auto = GPU profile default)") parser.add_argument("--lr", type=float, default=1e-4, help="Learning rate") parser.add_argument("--seed", type=int, default=42, help="Random seed") parser.add_argument("--precision", type=str, default="auto", choices=["auto", "fp16", "bf16", "fp32"], help="AMP precision (auto picks bf16 on Ampere+ / Hopper / Blackwell, fp16 on T4)") parser.add_argument("--grad_accum", type=int, default=1, help="Gradient accumulation steps") parser.add_argument("--num_workers", type=int, default=-1, help="Dataloader workers (-1/auto = GPU profile default)") parser.add_argument("--max_steps", type=int, default=0, help="Stop after N optimizer steps (0 = full training across all epochs)") parser.add_argument("--warmup_steps", type=int, default=1000, help="Linear warmup steps for LR scheduler (default: 1000)") parser.add_argument("--resume", type=str, default="auto", help="Resume mode: 'auto' (checks local and HF for latest checkpoint), or path/to/ckpt.pt") parser.add_argument("--push_to_hub", action="store_true", help="Auto-sync checkpoints and benchmarks to Hugging Face to prevent rental GPU data loss") parser.add_argument("--hf_repo", type=str, default="ViuAI/ViuAI_TTS_200M", help="Hugging Face repository ID for cloud backup") parser.add_argument("--save_every", type=int, default=5, help="Save checkpoint every N epochs (default: 5)") parser.add_argument("--save_every_steps", type=int, default=0, help="Save checkpoint every N optimizer steps (0 = off)") parser.add_argument("--log_every", type=int, default=10, help="Log every N optimizer steps") parser.add_argument("--train_vocoder", action="store_true", help="Enable Phase-2 neural vocoder fine-tuning alongside acoustic DiT") args = parser.parse_args() train( dataset_dir=args.data_dir, epochs=args.epochs, batch_size=args.batch_size, lr=args.lr, seed=args.seed, precision=args.precision, grad_accum=args.grad_accum, num_workers=args.num_workers, max_steps=args.max_steps, warmup_steps=args.warmup_steps, resume=args.resume, push_to_hub=args.push_to_hub, hf_repo=args.hf_repo, save_every=args.save_every, save_every_steps=args.save_every_steps, log_every=args.log_every, train_vocoder=args.train_vocoder, )