tts / train.py
Abhaykoul's picture
Upload folder using huggingface_hub
54f8682 verified
Raw
History Blame Contribute Delete
21.5 kB
"""
Fine-tune VTXAI/tts (VITS-architecture) on Hindi speech.
This is a REAL training script:
- real audio -> mel-spectrogram target (via torchaudio, no torchcodec needed)
- real VITS forward pass (text encoder, posterior encoder, flow, duration predictor, decoder)
- real losses: mel L1, KL divergence, duration loss, adversarial (LSGAN) + feature matching
- MultiPeriodDiscriminator trained jointly (adversarial training is required for VITS to
produce intelligible/natural audio -- without it you just get a blurry mean waveform)
- proper batching/padding via a collate function, not batch-size-1 streaming
- checkpointing so you can resume 1000+ hours of training across sessions
Usage:
Paste this entire file into one Jupyter/Colab cell and run it. Edit the
CONFIG block near the top (repo, dataset, batch size, etc.) instead of
passing CLI flags.
Requires: torch, torchaudio, datasets, safetensors, huggingface_hub
pip install -q torch torchaudio datasets safetensors huggingface_hub
Do NOT need torchcodec: we force decode=False and decode audio ourselves with torchaudio,
which is more memory-stable for long streaming runs anyway.
"""
import os
import io
import json
import math
import time
from pathlib import Path
import numpy as np
import torch
import torch.nn.functional as F
import torchaudio
from torch.utils.data import IterableDataset, DataLoader
import sys
import importlib.util
from huggingface_hub import snapshot_download
# ==============================================================================
# ⚙️ CONFIG — edit these values directly, then just run the cell
# ==============================================================================
HF_REPO = "VTXAI/tts" # base model repo to download+finetune
BASE_MODEL_DIR = "./vtx_base_model" # where the base snapshot gets cached
DATASET_NAME = "SPRINGLab/IndicTTS-Hindi"
DATASET_SPLIT = "train"
OUT_DIR = "./ckpt" # finetune checkpoints saved here
RESUME_PATH = "" # set to a ./ckpt/*.pth path to resume, else ""
BATCH_SIZE = 16
EPOCHS = 1
MAX_STEPS = 0 # 0 = run full epochs, otherwise stop after this many new steps
LR = 2e-4
SAVE_EVERY = 1000
LOG_EVERY = 50
NUM_WORKERS = 2
# ==============================================================================
def load_modeling(repo_id="VTXAI/tts", local_dir="./vtx_base_model"):
"""
Ensures modeling.py is available from local repo directory or downloaded snapshot
and imports required model classes/functions from it.
"""
local_modeling = Path("modeling.py")
if not local_modeling.exists():
snapshot_dir = Path(snapshot_download(repo_id=repo_id, local_dir=local_dir))
local_modeling = snapshot_dir / "modeling.py"
spec = importlib.util.spec_from_file_location("vtx_modeling", local_modeling)
vtx_modeling = importlib.util.module_from_spec(spec)
sys.modules["vtx_modeling"] = vtx_modeling
spec.loader.exec_module(vtx_modeling)
return (
vtx_modeling.SynthesizerTrn,
vtx_modeling.MultiPeriodDiscriminator,
vtx_modeling.kl_divergence,
vtx_modeling.slice_segments,
)
SynthesizerTrn, MultiPeriodDiscriminator, kl_divergence, slice_segments = load_modeling(HF_REPO, BASE_MODEL_DIR)
def fetch_base_model(repo_id="VTXAI/tts", local_dir="./vtx_base_model"):
"""
Downloads the full VTXAI/tts repo snapshot and locates config.json + weights
within it. Returns (config_path, weights_path).
"""
local_dir = Path(local_dir)
print(f"Downloading base model from https://huggingface.co/{repo_id} ...")
repo_path = Path(snapshot_download(repo_id=repo_id, local_dir=str(local_dir)))
print(f"Downloaded to {repo_path}")
config_path = None
p = repo_path / "config.json"
if p.exists():
config_path = p
else:
found = list(repo_path.rglob("config.json"))
if not found:
raise FileNotFoundError(f"No config.json found in {repo_id} snapshot.")
config_path = found[0]
weights_path = None
for cand in ["model.safetensors", "G.safetensors", "model.pth", "G.pth"]:
p = repo_path / cand
if p.exists():
weights_path = p
break
if weights_path is None:
for pattern in ["*.safetensors", "*.pth"]:
found = list(repo_path.rglob(pattern))
if found:
weights_path = found[0]
break
if weights_path is None:
raise FileNotFoundError(f"No weights (.safetensors/.pth) found in {repo_id} snapshot.")
print(f"Using config: {config_path}")
print(f"Using weights: {weights_path}")
return config_path, weights_path
# ----------------------------------------------------------------------------
# 0. maximum_path fallback
# ----------------------------------------------------------------------------
# The reference VITS repo ships a compiled `monotonic_align` extension exposing
# maximum_path(neg_cent, mask). modeling.py calls it but doesn't define it, so we
# provide a numba-free, pure-torch fallback here (slower, but correct and dependency-light).
def maximum_path(neg_cent, mask):
"""
neg_cent: [b, t_t, t_s] mask: [b, t_t, t_s] -> returns path [b, t_t, t_s]
Pure-python dynamic programming, batched via a simple loop (fine at these batch sizes).
"""
device = neg_cent.device
dtype = neg_cent.dtype
neg_cent = neg_cent.detach().cpu().numpy().astype(np.float64)
mask = mask.detach().cpu().numpy().astype(np.bool_)
b, t_t, t_s = neg_cent.shape
path = np.zeros_like(neg_cent, dtype=np.float64)
for i in range(b):
t_y = int(mask[i, :, 0].sum())
t_x = int(mask[i, 0, :].sum())
if t_y == 0 or t_x == 0:
continue
value = neg_cent[i, :t_y, :t_x]
v = np.full((t_y, t_x), -1e9, dtype=np.float64)
v[0, 0] = value[0, 0]
for y in range(1, t_y):
v[y, 0] = v[y - 1, 0] + value[y, 0]
for x in range(1, t_x):
v[0, x] = -1e9 # can't start later in x at t_y=0
for y in range(1, t_y):
for x in range(1, min(t_x, y + 1)):
v[y, x] = value[y, x] + max(v[y - 1, x], v[y - 1, x - 1])
# backtrack
index = t_x - 1
for y in range(t_y - 1, -1, -1):
path[i, y, index] = 1.0
if index > 0 and (y == 0 or v[y - 1, index - 1] > v[y - 1, index]):
index -= 1
return torch.from_numpy(path).to(device=device, dtype=dtype)
# ----------------------------------------------------------------------------
# 1. Text symbols / tokenizer (Hindi, matches your existing pipeline)
# ----------------------------------------------------------------------------
_pad = "_"
_punctuation = ';:,.!?¡¿—…"«»“” '
_hindi_letters = "अआइईउऊऋएऐओऔकखगघङचछजझञटठडढणतथदधनपफबभमयरलवशषसहक्षत्रज्ञ़ािीुूृेैोौॉ्िंः"
_letters_ipa = "ɑɐɒæɓɓβɔɕçɗɖðʤəɘɚɛɜɝɞɟʄɡɠɢʛɦɧħɥʜɨɪʝɭɬɫɮʟɱɯɰŋɳɲɴøɵɸθœɶʘɹɺɾɻʀʁɽʂʃʈʧʉʊʋⱱʌɣɤʍχʎʏʑʐʒʔʡʕʢǀǁǂǃˈˌːˑʼʴʰʱʲʷˠˤ˞"
hindi_symbols = [_pad] + list(_punctuation) + list(_hindi_letters) + list(_letters_ipa)
hindi_symbol_to_id = {s: i for i, s in enumerate(hindi_symbols)}
def hindi_text_to_sequence(text: str) -> list:
seq = []
for ch in text:
if ch in hindi_symbol_to_id:
seq.append(hindi_symbol_to_id[ch])
elif ch == " ":
seq.append(1)
if not seq:
seq = [1, 2, 3]
interspersed = [0] * (len(seq) * 2 + 1)
interspersed[1::2] = seq
return interspersed
# ----------------------------------------------------------------------------
# 2. Mel-spectrogram (matches VITS training target: linear spec for enc_q,
# mel spec for the reconstruction loss)
# ----------------------------------------------------------------------------
class AudioProcessor:
def __init__(self, cfg):
d = cfg["data"]
self.sampling_rate = d["sampling_rate"]
self.filter_length = d["filter_length"]
self.hop_length = d["hop_length"]
self.win_length = d["win_length"]
self.n_mel_channels = d.get("n_mel_channels", 80)
self.mel_fmin = d.get("mel_fmin", 0.0)
self.mel_fmax = d.get("mel_fmax", None)
self.spec_transform = torchaudio.transforms.Spectrogram(
n_fft=self.filter_length,
hop_length=self.hop_length,
win_length=self.win_length,
power=1.0,
center=False,
pad=(self.filter_length - self.hop_length) // 2,
)
self.mel_scale = torchaudio.transforms.MelScale(
n_mels=self.n_mel_channels,
sample_rate=self.sampling_rate,
f_min=self.mel_fmin,
f_max=self.mel_fmax,
n_stft=self.filter_length // 2 + 1,
)
def to(self, device):
self.spec_transform = self.spec_transform.to(device)
self.mel_scale = self.mel_scale.to(device)
return self
def wav_to_spec(self, wav):
# wav: [1, T] float32 in [-1, 1] -> spec [C, F]
spec = self.spec_transform(wav)
return spec.squeeze(0)
def spec_to_mel(self, spec):
if spec.device != self.mel_scale.fb.device:
self.mel_scale = self.mel_scale.to(spec.device)
return self.mel_scale(spec.unsqueeze(0)).squeeze(0)
def mel_scale_transform(self, spec):
if spec.device != self.mel_scale.fb.device:
self.mel_scale = self.mel_scale.to(spec.device)
return self.mel_scale(spec)
def wav_to_mel(self, wav):
return self.spec_to_mel(self.wav_to_spec(wav))
# ----------------------------------------------------------------------------
# 3. Streaming dataset over the HF Hindi corpus, with real audio decoding
# ----------------------------------------------------------------------------
class HindiTTSIterable(IterableDataset):
"""
Wraps datasets.load_dataset(..., streaming=True). We force decode=False on the
audio column and decode manually with torchaudio -- this avoids the torchcodec
dependency entirely and is more robust for very long (1000+ hr) streaming runs.
"""
def __init__(self, dataset_name, split, ap: AudioProcessor, max_audio_sec=12.0, text_key=None):
super().__init__()
self.dataset_name = dataset_name
self.split = split
self.ap = ap
self.max_samples = int(max_audio_sec * ap.sampling_rate)
self.text_key = text_key
def _load_stream(self):
from datasets import load_dataset, Audio
ds = load_dataset(self.dataset_name, split=self.split, streaming=True)
ds = ds.cast_column("audio", Audio(decode=False))
return ds
def __iter__(self):
ds = self._load_stream()
for item in ds:
try:
audio_field = item["audio"]
raw_bytes = audio_field["bytes"] if isinstance(audio_field, dict) else None
if raw_bytes is not None:
wav, sr = torchaudio.load(io.BytesIO(raw_bytes))
elif isinstance(audio_field, dict) and audio_field.get("path"):
wav, sr = torchaudio.load(audio_field["path"])
else:
continue
if wav.shape[0] > 1:
wav = wav.mean(0, keepdim=True)
if sr != self.ap.sampling_rate:
wav = torchaudio.functional.resample(wav, sr, self.ap.sampling_rate)
if wav.shape[1] > self.max_samples:
continue # skip overly long clips for stable batch memory
if wav.shape[1] < self.ap.hop_length * 8:
continue
text_key = self.text_key
if text_key is None:
text_key = "normalized_text" if "normalized_text" in item else "text"
text = item.get(text_key, "")
if not text or not text.strip():
continue
tok = torch.LongTensor(hindi_text_to_sequence(text))
yield tok, wav.squeeze(0)
except Exception:
# skip malformed rows rather than crashing a 1000hr run
continue
def collate_fn(batch):
batch = [b for b in batch if b[0].numel() > 0 and b[1].numel() > 0]
batch.sort(key=lambda x: x[1].shape[0], reverse=True)
text_lens = torch.LongTensor([b[0].shape[0] for b in batch])
wav_lens = torch.LongTensor([b[1].shape[0] for b in batch])
text_pad = torch.zeros(len(batch), text_lens.max(), dtype=torch.long)
wav_pad = torch.zeros(len(batch), wav_lens.max(), dtype=torch.float32)
for i, (tok, wav) in enumerate(batch):
text_pad[i, : tok.shape[0]] = tok
wav_pad[i, : wav.shape[0]] = wav
return text_pad, text_lens, wav_pad, wav_lens
# ----------------------------------------------------------------------------
# 4. Loss functions
# ----------------------------------------------------------------------------
def discriminator_loss(disc_real_outputs, disc_generated_outputs):
loss = 0
for dr, dg in zip(disc_real_outputs, disc_generated_outputs):
r_loss = torch.mean((1 - dr) ** 2)
g_loss = torch.mean(dg ** 2)
loss += r_loss + g_loss
return loss
def generator_adv_loss(disc_outputs):
loss = 0
for dg in disc_outputs:
loss += torch.mean((1 - dg) ** 2)
return loss
def feature_loss(fmap_r, fmap_g):
loss = 0
for dr, dg in zip(fmap_r, fmap_g):
for rl, gl in zip(dr, dg):
loss += torch.mean(torch.abs(rl.float() - gl.float()))
return loss * 2
# ----------------------------------------------------------------------------
# 5. Checkpoint loading (keeps your existing "transfer weights, resize vocab" fix)
# ----------------------------------------------------------------------------
def load_pretrained_generator(model, ckpt_path):
ckpt_path = Path(ckpt_path)
if ckpt_path.suffix == ".safetensors":
from safetensors.torch import load_file
state_dict = load_file(str(ckpt_path))
else:
raw = torch.load(ckpt_path, map_location="cpu")
state_dict = raw.get("model", raw)
# Drop embedding weights: symbol vocab size for Hindi differs from source checkpoint
filtered = {
k: v for k, v in state_dict.items()
if not (k.endswith("emb.weight") or k.startswith("enc_p.emb"))
}
model_dict = model.state_dict()
compatible = {k: v for k, v in filtered.items() if k in model_dict and model_dict[k].shape == v.shape}
skipped = [k for k in filtered if k not in compatible]
model_dict.update(compatible)
model.load_state_dict(model_dict, strict=False)
print(f"Loaded {len(compatible)}/{len(state_dict)} tensors from checkpoint "
f"(skipped {len(skipped)}, incl. resized text embedding).")
# ----------------------------------------------------------------------------
# 6. Training run (executes directly — paste this whole file into one notebook cell)
# ----------------------------------------------------------------------------
def main():
out_dir = Path(OUT_DIR)
out_dir.mkdir(parents=True, exist_ok=True)
config_path, checkpoint_path = fetch_base_model(HF_REPO, BASE_MODEL_DIR)
with open(config_path) as f:
config = json.load(f)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Device: {device}")
ap = AudioProcessor(config).to(device)
net_g = SynthesizerTrn(
len(hindi_symbols),
config["data"]["filter_length"] // 2 + 1,
config["train"]["segment_size"] // config["data"]["hop_length"],
**config["model"],
).to(device)
net_d = MultiPeriodDiscriminator(config["model"].get("use_spectral_norm", False)).to(device)
start_step = 0
if RESUME_PATH and Path(RESUME_PATH).exists():
ckpt = torch.load(RESUME_PATH, map_location="cpu")
net_g.load_state_dict(ckpt["net_g"])
net_d.load_state_dict(ckpt["net_d"])
start_step = ckpt.get("step", 0)
print(f"Resumed from {RESUME_PATH} at step {start_step}")
else:
load_pretrained_generator(net_g, checkpoint_path)
optim_g = torch.optim.AdamW(net_g.parameters(), lr=LR, betas=(0.8, 0.99), eps=1e-9)
optim_d = torch.optim.AdamW(net_d.parameters(), lr=LR, betas=(0.8, 0.99), eps=1e-9)
if RESUME_PATH and Path(RESUME_PATH).exists():
optim_g.load_state_dict(ckpt["optim_g"])
optim_d.load_state_dict(ckpt["optim_d"])
scheduler_g = torch.optim.lr_scheduler.ExponentialLR(optim_g, gamma=0.999875)
scheduler_d = torch.optim.lr_scheduler.ExponentialLR(optim_d, gamma=0.999875)
dataset = HindiTTSIterable(DATASET_NAME, DATASET_SPLIT, ap)
loader = DataLoader(
dataset, batch_size=BATCH_SIZE, collate_fn=collate_fn,
num_workers=NUM_WORKERS, pin_memory=(device.type == "cuda"),
)
hop = config["data"]["hop_length"]
seg_size = config["train"]["segment_size"]
seg_frames = seg_size // hop
c_mel = config["train"].get("c_mel", 45.0)
c_kl = config["train"].get("c_kl", 1.0)
step = start_step
t0 = time.time()
net_g.train()
net_d.train()
for epoch in range(EPOCHS):
for text_pad, text_lens, wav_pad, wav_lens in loader:
text_pad, text_lens = text_pad.to(device), text_lens.to(device)
wav_pad, wav_lens = wav_pad.to(device), wav_lens.to(device)
# Build linear spectrogram targets per-sample (variable length), then pad
specs, spec_lens = [], []
for i in range(wav_pad.shape[0]):
w = wav_pad[i, : wav_lens[i]].unsqueeze(0).cpu()
s = ap.wav_to_spec(w)
specs.append(s)
spec_lens.append(s.shape[-1])
spec_lens = torch.LongTensor(spec_lens).to(device)
spec_pad = torch.zeros(len(specs), specs[0].shape[0], spec_lens.max(), device=device)
for i, s in enumerate(specs):
spec_pad[i, :, : s.shape[-1]] = s.to(device)
# ---- Generator forward ----
(y_hat, l_length, attn, ids_slice, x_mask, y_mask,
(z, z_p, m_p, logs_p, m_q, logs_q)) = net_g(text_pad, text_lens, spec_pad, spec_lens)
mel_pad = ap.mel_scale_transform(spec_pad)
mel_slice = slice_segments(mel_pad, ids_slice, seg_frames)
with torch.no_grad():
wav_hat_mono = y_hat.squeeze(1)
y_hat_mel = torch.stack([
ap.spec_to_mel(ap.wav_to_spec(wav_hat_mono[i:i + 1].detach().cpu())).to(device)
for i in range(wav_hat_mono.shape[0])
])
y_slice = slice_segments(wav_pad.unsqueeze(1), ids_slice * hop, seg_size)
# ---- Discriminator step ----
y_d_hat_r, y_d_hat_g, _, _ = net_d(y_slice, y_hat.detach())
loss_disc = discriminator_loss(y_d_hat_r, y_d_hat_g)
optim_d.zero_grad()
loss_disc.backward()
torch.nn.utils.clip_grad_norm_(net_d.parameters(), 5.0)
optim_d.step()
# ---- Generator step ----
y_d_hat_r, y_d_hat_g, fmap_r, fmap_g = net_d(y_slice, y_hat)
loss_mel = F.l1_loss(mel_slice, y_hat_mel) * c_mel
loss_kl = torch.mean(kl_divergence(m_p, logs_p, m_q, logs_q)) * c_kl
loss_dur = torch.sum(l_length.float())
loss_fm = feature_loss(fmap_r, fmap_g)
loss_adv = generator_adv_loss(y_d_hat_g)
loss_gen_total = loss_adv + loss_fm + loss_mel + loss_kl + loss_dur
optim_g.zero_grad()
loss_gen_total.backward()
torch.nn.utils.clip_grad_norm_(net_g.parameters(), 5.0)
optim_g.step()
step += 1
if step % LOG_EVERY == 0:
elapsed = time.time() - t0
print(f"[step {step}] gen={loss_gen_total.item():.4f} "
f"mel={loss_mel.item():.4f} kl={loss_kl.item():.4f} "
f"dur={loss_dur.item():.4f} adv={loss_adv.item():.4f} "
f"fm={loss_fm.item():.4f} disc={loss_disc.item():.4f} "
f"({elapsed:.1f}s elapsed)")
if step % SAVE_EVERY == 0:
ckpt_path = out_dir / f"vtx_hindi_step{step}.pth"
torch.save({
"net_g": net_g.state_dict(),
"net_d": net_d.state_dict(),
"optim_g": optim_g.state_dict(),
"optim_d": optim_d.state_dict(),
"step": step,
"symbols": hindi_symbols,
}, ckpt_path)
print(f"Saved checkpoint: {ckpt_path}")
if MAX_STEPS and step >= start_step + MAX_STEPS:
break
scheduler_g.step()
scheduler_d.step()
if MAX_STEPS and step >= start_step + MAX_STEPS:
break
final_path = out_dir / "vtx_hindi_final.pth"
torch.save({"net_g": net_g.state_dict(), "net_d": net_d.state_dict(), "step": step,
"symbols": hindi_symbols}, final_path)
print(f"Done. Final checkpoint: {final_path}")
if __name__ == "__main__":
main()