ViuAI_TTS_200M / train.py
ViuAI's picture
Fix: Peak audio normalization (-1 dB), robust speaker search, and tuned CFG for crystal-clear benchmark generation
d62a3fe verified
Raw History Blame Contribute Delete
45.9 kB
"""
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,
)