| |
| """ |
| DramaBox Voice Cloning LoRA Fine-Tuning with Multi-Auxiliary Losses (Epochs 15-17) |
| |
| Extends dramabox_finetune_train_clap.py with THREE auxiliary losses: |
| 1. CLAP Naturalness — CLAP text similarity (positive-negative) + quality MLP |
| 2. Centroid Real/Fake — cos(emb, real_centroid) - cos(emb, synth_centroid) |
| 3. Speaker Similarity — WavLM-SV cosine similarity between ref and pred speaker embs |
| |
| Each auxiliary loss is individually normalized via EMA-based adaptive coefficients |
| to have approximately the same magnitude as the flow matching loss. |
| |
| Usage: |
| accelerate launch --num_processes=8 scripts/dramabox_finetune_train_multi_aux.py \ |
| --config configs/finetune_multi_aux.yaml |
| """ |
|
|
| import os |
| import sys |
|
|
| |
| _ld = os.environ.get("LD_LIBRARY_PATH", "") |
| if _ld: |
| _filtered = [p for p in _ld.split(":") if "ml-general" not in p] |
| os.environ["LD_LIBRARY_PATH"] = ":".join(_filtered) |
|
|
| |
| os.environ["HF_HUB_TRUST_REMOTE_CODE"] = "1" |
| os.environ["TRUST_REMOTE_CODE"] = "1" |
|
|
| import argparse |
| import http.server |
| import json |
| import logging |
| import math |
| import random |
| import glob as glob_mod |
| import shutil |
| import subprocess as _subprocess |
| import threading |
| import time |
| from collections import defaultdict, deque |
| from pathlib import Path |
|
|
| import torch |
| import torch.nn.functional as F |
| from torch.utils.data import DataLoader, Dataset, Sampler |
|
|
| |
|
|
| def setup_paths(dramabox_dir: str): |
| """Add DramaBox to sys.path for imports.""" |
| sys.path.insert(0, os.path.join(dramabox_dir, "ltx2")) |
| sys.path.insert(0, os.path.join(dramabox_dir, "src")) |
|
|
|
|
| |
|
|
| class ShiftedLogitNormalTimestepSampler: |
| """Shifted logit-normal distribution, shift depends on sequence length.""" |
|
|
| def __init__(self, std: float = 1.0, eps: float = 1e-3, uniform_prob: float = 0.1): |
| self.std = std |
| self.eps = eps |
| self.uniform_prob = uniform_prob |
| self.normal_999_percentile = 3.0902 * std |
| self.normal_005_percentile = -2.5758 * std |
|
|
| def sample(self, batch_size: int, seq_length: int, device: torch.device = None) -> torch.Tensor: |
| mu = self._get_shift(seq_length) |
| normal = torch.randn(batch_size, device=device) * self.std + mu |
| logitnormal = torch.sigmoid(normal) |
| p999 = torch.sigmoid(torch.tensor(mu + self.normal_999_percentile, device=device)) |
| p005 = torch.sigmoid(torch.tensor(mu + self.normal_005_percentile, device=device)) |
| stretched = (logitnormal - p005) / (p999 - p005) |
| stretched = torch.where(stretched >= self.eps, stretched, 2 * self.eps - stretched) |
| stretched = stretched.clamp(0, 1) |
| uniform = (1 - self.eps) * torch.rand(batch_size, device=device) + self.eps |
| prob = torch.rand(batch_size, device=device) |
| return torch.where(prob > self.uniform_prob, stretched, uniform) |
|
|
| @staticmethod |
| def _get_shift(seq_length, min_tok=1024, max_tok=4096, min_s=0.95, max_s=2.05): |
| m = (max_s - min_s) / (max_tok - min_tok) |
| return m * seq_length + (min_s - m * min_tok) |
|
|
|
|
| class DistilledTimestepSampler: |
| SIGMAS = [1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0] |
|
|
| def __init__(self, jitter: float = 0.02): |
| self.jitter = jitter |
|
|
| def sample(self, batch_size: int, seq_length: int = None, device: torch.device = None) -> torch.Tensor: |
| n_intervals = len(self.SIGMAS) - 1 |
| interval_idx = torch.randint(0, n_intervals, (batch_size,), device=device) |
| t = torch.rand(batch_size, device=device) |
| sigma_high = torch.tensor([self.SIGMAS[i] for i in interval_idx], device=device) |
| sigma_low = torch.tensor([self.SIGMAS[i + 1] for i in interval_idx], device=device) |
| sigma = sigma_low + t * (sigma_high - sigma_low) |
| return sigma.clamp(0.01, 0.99) |
|
|
|
|
| |
|
|
| class DramaBoxFinetuneDataset(Dataset): |
| """Three-mode dataset for DramaBox voice cloning fine-tuning. |
| |
| Modes: |
| A (voice_clone_fwd): target=part2, ref=part1, text=scene2_expected_text |
| B (voice_clone_rev): target=part1, ref=part2, text=scene1_expected_text |
| C (unconditional): target=full, ref=None, text=full_prompt |
| """ |
|
|
| def __init__(self, preprocessed_dir: str, mode_weights: dict = None, |
| max_ref_tokens: int = 250, overfit_n: int = 0, |
| expand_all_modes: bool = False, index_file: str = None): |
| self.data_dir = Path(preprocessed_dir) |
| self.latent_dir = self.data_dir / "audio_latents" |
| self.cond_dir = self.data_dir / "conditions" |
|
|
| self.max_ref_tokens = max_ref_tokens |
| self.expand_all_modes = expand_all_modes |
| self.mode_weights = mode_weights or { |
| "voice_clone_fwd": 0.33, |
| "voice_clone_rev": 0.33, |
| "unconditional": 0.34, |
| } |
|
|
| |
| index_path = index_file if index_file else str(self.data_dir / "index.json") |
| with open(index_path) as f: |
| index = json.load(f) |
|
|
| self.groups = index["groups"] |
| self.group_keys = list(self.groups.keys()) |
| self.all_samples = index["samples"] |
|
|
| if overfit_n > 0: |
| self.group_keys = self.group_keys[:overfit_n] |
| valid_indices = set() |
| for k in self.group_keys: |
| valid_indices.update(self.groups[k]) |
| self.all_samples = [s for s in self.all_samples if s["index"] in valid_indices] |
|
|
| self._build_items() |
|
|
| logging.info(f"Dataset: {len(self.items)} items from {len(self.group_keys)} groups, " |
| f"{len(self.all_samples)} total samples (index: {os.path.basename(index_path)})") |
|
|
| def _build_items(self): |
| """Build flat item list.""" |
| self.items = [] |
| modes = list(self.mode_weights.keys()) |
| if self.expand_all_modes: |
| for sample in self.all_samples: |
| source = sample.get("source", "dramabox") |
| if source in ("emolia", "augmented", "podcast"): |
| |
| self.items.append((sample["index"], "voice_clone_fwd")) |
| else: |
| for mode in modes: |
| self.items.append((sample["index"], mode)) |
| else: |
| for group_key in self.group_keys: |
| for mode in modes: |
| self.items.append((group_key, mode)) |
|
|
| def __len__(self): |
| return len(self.items) |
|
|
| def _load_latent(self, idx: int, part: str) -> torch.Tensor: |
| path = self.latent_dir / f"sample_{idx:06d}_{part}.pt" |
| lat = torch.load(path, weights_only=True).detach() |
| return lat |
|
|
| def _load_condition(self, idx: int, part: str): |
| path = self.cond_dir / f"sample_{idx:06d}_{part}.pt" |
| cond = torch.load(path, weights_only=False) |
| audio_feats = cond.get("audio_prompt_embeds", cond.get("prompt_embeds")).detach() |
| attn_mask = cond.get("prompt_attention_mask").detach() |
|
|
| |
| REG = 128 |
| L = audio_feats.shape[0] |
| target_L = ((L + REG - 1) // REG) * REG |
| if target_L != L: |
| pad_len = target_L - L |
| pad_emb = torch.zeros(pad_len, audio_feats.shape[1], dtype=audio_feats.dtype) |
| pad_mask = torch.zeros(pad_len, dtype=attn_mask.dtype) |
| audio_feats = torch.cat([pad_emb, audio_feats], dim=0) |
| attn_mask = torch.cat([pad_mask, attn_mask], dim=0) |
|
|
| return audio_feats, attn_mask |
|
|
| def __getitem__(self, idx): |
| item_key, mode = self.items[idx] |
|
|
| if self.expand_all_modes: |
| sample_idx = item_key |
| else: |
| sample_indices = self.groups[item_key] |
| sample_idx = random.choice(sample_indices) |
|
|
| if mode == "voice_clone_fwd": |
| tgt_latent = self._load_latent(sample_idx, "part2") |
| ref_latent = self._load_latent(sample_idx, "part1") |
| audio_feats, attn_mask = self._load_condition(sample_idx, "part2") |
| elif mode == "voice_clone_rev": |
| tgt_latent = self._load_latent(sample_idx, "part1") |
| ref_latent = self._load_latent(sample_idx, "part2") |
| audio_feats, attn_mask = self._load_condition(sample_idx, "part1") |
| else: |
| tgt_latent = self._load_latent(sample_idx, "full") |
| C, F_dim = tgt_latent.shape[0], tgt_latent.shape[2] |
| ref_latent = torch.zeros(C, 0, F_dim, dtype=tgt_latent.dtype) |
| audio_feats, attn_mask = self._load_condition(sample_idx, "full") |
|
|
| |
| if ref_latent.shape[1] > self.max_ref_tokens: |
| ref_latent = ref_latent[:, :self.max_ref_tokens, :] |
|
|
| return { |
| "tgt_latent": tgt_latent, |
| "ref_latent": ref_latent, |
| "audio_features": audio_feats, |
| "attention_mask": attn_mask, |
| "mode": mode, |
| } |
|
|
|
|
| def collate_fn(batch): |
| """Pad variable-length audio to max in batch.""" |
| max_tgt_T = max(b["tgt_latent"].shape[1] for b in batch) |
| max_ref_T = max(b["ref_latent"].shape[1] for b in batch) |
| C = batch[0]["tgt_latent"].shape[0] |
| F_dim = batch[0]["tgt_latent"].shape[2] |
|
|
| tgt_list, ref_list, feat_list, mask_list = [], [], [], [] |
| tgt_lengths, ref_lengths = [], [] |
| modes = [] |
|
|
| for b in batch: |
| tgt = b["tgt_latent"] |
| ref = b["ref_latent"] |
| tgt_lengths.append(tgt.shape[1]) |
| ref_lengths.append(ref.shape[1]) |
|
|
| if tgt.shape[1] < max_tgt_T: |
| pad = torch.zeros(C, max_tgt_T - tgt.shape[1], F_dim, dtype=tgt.dtype) |
| tgt = torch.cat([tgt, pad], dim=1) |
| tgt_list.append(tgt) |
|
|
| if ref.shape[1] < max_ref_T: |
| pad = torch.zeros(C, max_ref_T - ref.shape[1], F_dim, dtype=ref.dtype) |
| ref = torch.cat([ref, pad], dim=1) |
| ref_list.append(ref) |
|
|
| feat_list.append(b["audio_features"]) |
| mask_list.append(b["attention_mask"]) |
| modes.append(b["mode"]) |
|
|
| return { |
| "tgt_latent": torch.stack(tgt_list), |
| "ref_latent": torch.stack(ref_list), |
| "audio_features": torch.stack(feat_list), |
| "attention_mask": torch.stack(mask_list), |
| "tgt_lengths": torch.tensor(tgt_lengths), |
| "ref_lengths": torch.tensor(ref_lengths), |
| "modes": modes, |
| } |
|
|
|
|
| |
|
|
| class BucketWeightedSampler(Sampler): |
| """Weighted sampling from source buckets with round-robin within each bucket. |
| |
| Divides dataset items into buckets by source (dramabox, podcast, emolia, |
| augmented). Each epoch produces items in proportions matching bucket_weights, |
| cycling through each bucket's items in shuffled order. |
| |
| One "epoch" = each item in the anchor bucket (podcast) seen once. |
| Total training length = anchor_items / anchor_weight. |
| """ |
|
|
| def __init__(self, dataset, bucket_weights: dict, anchor_bucket: str = "podcast", |
| seed: int = 42): |
| """ |
| Args: |
| dataset: DramaBoxFinetuneDataset with expand_all_modes=True |
| bucket_weights: source -> weight, e.g. {"dramabox": 0.5, "podcast": 0.35, ...} |
| anchor_bucket: which bucket defines one epoch (each item seen once per epoch) |
| seed: base random seed |
| """ |
| super().__init__(dataset) |
| self.bucket_weights = bucket_weights |
| self.seed = seed |
| self._epoch = 0 |
|
|
| |
| sample_idx_to_source = { |
| s["index"]: s.get("source", "dramabox") for s in dataset.all_samples |
| } |
| self.buckets = {} |
| for source in bucket_weights: |
| self.buckets[source] = [] |
| for i, (item_key, mode) in enumerate(dataset.items): |
| source = sample_idx_to_source.get(item_key, "dramabox") |
| if source in self.buckets: |
| self.buckets[source].append(i) |
| else: |
| |
| self.buckets.setdefault("dramabox", []).append(i) |
|
|
| |
| anchor_items = len(self.buckets.get(anchor_bucket, [])) |
| anchor_weight = bucket_weights.get(anchor_bucket, 0.35) |
| self.anchor = anchor_bucket |
| self._epoch_size = int(anchor_items / anchor_weight) if anchor_items > 0 else len(dataset) |
|
|
| |
| info = [] |
| for source, weight in bucket_weights.items(): |
| n = len(self.buckets.get(source, [])) |
| per_epoch = int(self._epoch_size * weight) |
| repeats = per_epoch / n if n > 0 else 0 |
| info.append(f" {source}: {n} items × {repeats:.1f}× = {per_epoch}/epoch (weight={weight})") |
| logging.info(f"BucketWeightedSampler: {self._epoch_size} items/epoch, anchor={self.anchor}") |
| for line in info: |
| logging.info(line) |
|
|
| def __len__(self): |
| return self._epoch_size |
|
|
| def set_epoch(self, epoch): |
| self._epoch = epoch |
|
|
| def __iter__(self): |
| rng = random.Random(self.seed + self._epoch) |
| self._epoch += 1 |
|
|
| indices = [] |
| for source, weight in self.bucket_weights.items(): |
| bucket = self.buckets.get(source, []) |
| if not bucket or weight <= 0: |
| continue |
| count = int(self._epoch_size * weight) |
| |
| repeats = (count // len(bucket)) + 1 |
| pool = [] |
| for _ in range(repeats): |
| shuffled = bucket.copy() |
| rng.shuffle(shuffled) |
| pool.extend(shuffled) |
| indices.extend(pool[:count]) |
|
|
| |
| rng.shuffle(indices) |
| return iter(indices) |
|
|
|
|
| |
|
|
| def build_audio_only_model(checkpoint_path, device, dtype): |
| from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder as Builder |
| from ltx_core.loader.registry import DummyRegistry |
| from ltx_core.loader.sd_ops import SDOps |
| from ltx_core.model.transformer.model import LTXModel, LTXModelType |
| from ltx_core.model.transformer.attention import AttentionFunction |
| from ltx_core.model.transformer.rope import LTXRopeType |
|
|
| sd_ops = SDOps("AO").with_matching( |
| prefix="model.diffusion_model." |
| ).with_replacement("model.diffusion_model.", "") |
|
|
| class Cfg: |
| @classmethod |
| def from_config(cls, config): |
| from ltx_core.model.model_protocol import ModelConfigurator |
| t = config.get("transformer", {}) |
| cp = None |
| if not t.get("caption_proj_before_connector", False): |
| from ltx_core.model.transformer.text_projection import create_caption_projection |
| with torch.device("meta"): |
| cp = create_caption_projection(t, audio=True) |
| return LTXModel( |
| model_type=LTXModelType.AudioOnly, |
| audio_num_attention_heads=t.get("audio_num_attention_heads", 32), |
| audio_attention_head_dim=t.get("audio_attention_head_dim", 64), |
| audio_in_channels=t.get("audio_in_channels", 128), |
| audio_out_channels=t.get("audio_out_channels", 128), |
| num_layers=t.get("num_layers", 48), |
| audio_cross_attention_dim=t.get("audio_cross_attention_dim", 2048), |
| norm_eps=t.get("norm_eps", 1e-6), |
| attention_type=AttentionFunction(t.get("attention_type", "default")), |
| positional_embedding_theta=t.get("positional_embedding_theta", 10000.0), |
| audio_positional_embedding_max_pos=t.get("audio_positional_embedding_max_pos", [20]), |
| timestep_scale_multiplier=t.get("timestep_scale_multiplier", 1000), |
| use_middle_indices_grid=t.get("use_middle_indices_grid", True), |
| rope_type=LTXRopeType(t.get("rope_type", "interleaved")), |
| double_precision_rope=t.get("frequencies_precision", False) == "float64", |
| apply_gated_attention=t.get("apply_gated_attention", False), |
| audio_caption_projection=cp, |
| cross_attention_adaln=t.get("cross_attention_adaln", False), |
| ) |
|
|
| builder = Builder(model_path=checkpoint_path, model_class_configurator=Cfg, |
| model_sd_ops=sd_ops, registry=DummyRegistry()) |
| return builder.build(device=device, dtype=dtype) |
|
|
|
|
| def load_audio_connector(checkpoint_path, device, dtype): |
| from ltx_trainer.model_loader import load_embeddings_processor |
| emb_proc = load_embeddings_processor(checkpoint_path, device=device, dtype=dtype) |
| connector = emb_proc.audio_connector |
| del emb_proc |
| return connector |
|
|
|
|
| def apply_lora(model, rank, alpha, dropout=0.0): |
| from peft import LoraConfig, get_peft_model |
| config = LoraConfig( |
| r=rank, lora_alpha=alpha, lora_dropout=dropout, bias="none", |
| target_modules=[ |
| "audio_attn1.to_k", "audio_attn1.to_q", |
| "audio_attn1.to_v", "audio_attn1.to_out.0", |
| "audio_ff.net.0.proj", "audio_ff.net.2", |
| ], |
| ) |
| model = get_peft_model(model, config) |
| trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) |
| total = sum(p.numel() for p in model.parameters()) |
| logging.info(f"LoRA: {trainable:,} trainable / {total:,} total ({100*trainable/total:.1f}%)") |
| return model |
|
|
|
|
| @torch.no_grad() |
| def prepare_audio_context(audio_connector, audio_features, attention_mask, device, dtype): |
| from ltx_core.text_encoders.gemma.embeddings_processor import convert_to_additive_mask |
| audio_features = audio_features.to(device=device, dtype=dtype) |
| attention_mask = attention_mask.to(device=device) |
| if audio_features.shape[0] > 1: |
| results = [] |
| for i in range(audio_features.shape[0]): |
| feat_i = audio_features[i:i+1] |
| mask_i = attention_mask[i:i+1] |
| additive = convert_to_additive_mask(mask_i, feat_i.dtype) |
| enc_i, _ = audio_connector(feat_i, additive) |
| results.append(enc_i) |
| return torch.cat(results, dim=0) |
| additive_mask = convert_to_additive_mask(attention_mask, audio_features.dtype) |
| audio_encoded, _ = audio_connector(audio_features, additive_mask) |
| return audio_encoded |
|
|
|
|
| def _unwrap_model_safe(model): |
| while hasattr(model, "module"): |
| model = model.module |
| return model |
|
|
|
|
| def save_training_state(output_dir, step, epoch, optimizer, scheduler, best_loss, |
| best_step, model, accelerator, tag=""): |
| """Save full training state for resumability.""" |
| suffix = f"_epoch{epoch}" if tag == "" else f"_{tag}" |
| state = { |
| "step": step, |
| "epoch": epoch, |
| "best_loss": best_loss, |
| "best_step": best_step, |
| "optimizer": optimizer.state_dict(), |
| "scheduler": scheduler.state_dict(), |
| "rng_python": random.getstate(), |
| "rng_torch": torch.random.get_rng_state(), |
| "rng_cuda": torch.cuda.get_rng_state(), |
| } |
| state_path = os.path.join(output_dir, f"training_state{suffix}.pt") |
| torch.save(state, state_path) |
| |
| unwrapped = _unwrap_model_safe(model) |
| unwrapped.save_pretrained(output_dir) |
| adapter = os.path.join(output_dir, "adapter_model.safetensors") |
| lora_path = os.path.join(output_dir, f"lora_epoch{epoch}.safetensors") |
| if os.path.exists(adapter): |
| shutil.copy(adapter, lora_path) |
| logging.info(f"Saved full training state: {state_path}, LoRA: {lora_path}") |
| return lora_path |
|
|
|
|
| def load_training_state(resume_dir, optimizer, scheduler): |
| """Load full training state from checkpoint directory (picks highest step).""" |
| state_files = glob_mod.glob(os.path.join(resume_dir, "training_state*.pt")) |
| |
| state_files = [f for f in state_files if os.path.isfile(f)] |
| if not state_files: |
| raise FileNotFoundError(f"No training state found in {resume_dir}") |
| |
| best_path, best_step = None, -1 |
| for sf in state_files: |
| try: |
| s = torch.load(sf, map_location="cpu", weights_only=False) |
| if s["step"] > best_step: |
| best_step = s["step"] |
| best_path = sf |
| del s |
| except Exception as e: |
| logging.warning(f"Skipping unreadable state file {sf}: {e}") |
| if best_path is None: |
| raise FileNotFoundError(f"No valid training state found in {resume_dir}") |
| state_path = best_path |
| logging.info(f"Resuming from: {state_path} (step {best_step})") |
| state = torch.load(state_path, weights_only=False) |
| optimizer.load_state_dict(state["optimizer"]) |
| scheduler.load_state_dict(state["scheduler"]) |
| random.setstate(state["rng_python"]) |
| torch.random.set_rng_state(state["rng_torch"]) |
| torch.cuda.set_rng_state(state["rng_cuda"]) |
| return state["step"], state["epoch"], state["best_loss"], state["best_step"] |
|
|
|
|
| def launch_validation_async(script_dir, output_dir, lora_path, epoch, args): |
| """Launch epoch validation as a background subprocess.""" |
| val_script = os.path.join(script_dir, "run_epoch_validation.py") |
| if not os.path.exists(val_script): |
| logging.warning(f"Validation script not found: {val_script}") |
| return None |
| cmd = [ |
| sys.executable, val_script, |
| "--lora", lora_path, |
| "--epoch", str(epoch), |
| "--output-dir", output_dir, |
| "--dramabox-dir", args.dramabox_dir, |
| "--checkpoint", args.checkpoint, |
| "--full-checkpoint", args.full_checkpoint, |
| "--gemma-root", getattr(args, "gemma_root", |
| "/home/deployer/.cache/dramabox/models--unsloth--gemma-3-12b-it-bnb-4bit/snapshots/826e729dbaeea4ecb143738eed2bcf3539ebf7bf"), |
| "--lora-rank", str(args.lora_rank), |
| "--val-samples", str(args.val_samples), |
| "--val-refs-dir", args.val_refs_dir, |
| "--preprocessed-dir", args.preprocessed_dir, |
| ] |
| log_path = os.path.join(output_dir, f"val_epoch{epoch}.log") |
| log_f = open(log_path, "w") |
| proc = _subprocess.Popen(cmd, stdout=log_f, stderr=_subprocess.STDOUT, |
| env={**os.environ, "CUDA_VISIBLE_DEVICES": "0"}) |
| logging.info(f"Launched validation for epoch {epoch} (PID {proc.pid}), log: {log_path}") |
| return proc |
|
|
|
|
| |
|
|
| class MetricsHandler(http.server.SimpleHTTPRequestHandler): |
| """Serves the training monitor HTML and metrics data.""" |
|
|
| metrics_dir = None |
|
|
| def do_GET(self): |
| path = self.path.split("?")[0] |
|
|
| if path == "/" or path == "/index.html": |
| monitor_path = os.path.join( |
| os.path.dirname(os.path.dirname(os.path.abspath(__file__))), |
| "scripts", "dramabox_finetune_monitor.html" |
| ) |
| if os.path.exists(monitor_path): |
| self.send_response(200) |
| self.send_header("Content-Type", "text/html") |
| self.send_header("Access-Control-Allow-Origin", "*") |
| self.end_headers() |
| with open(monitor_path, "rb") as f: |
| self.wfile.write(f.read()) |
| else: |
| self.send_error(404, "Monitor HTML not found") |
| elif path == "/metrics": |
| metrics_path = os.path.join(self.metrics_dir, "metrics.jsonl") |
| if os.path.exists(metrics_path): |
| self.send_response(200) |
| self.send_header("Content-Type", "application/json") |
| self.send_header("Access-Control-Allow-Origin", "*") |
| self.end_headers() |
| with open(metrics_path, "rb") as f: |
| self.wfile.write(f.read()) |
| else: |
| self.send_response(200) |
| self.send_header("Content-Type", "application/json") |
| self.send_header("Access-Control-Allow-Origin", "*") |
| self.end_headers() |
| self.wfile.write(b"") |
| elif path == "/status": |
| status_path = os.path.join(self.metrics_dir, "status.json") |
| if os.path.exists(status_path): |
| self.send_response(200) |
| self.send_header("Content-Type", "application/json") |
| self.send_header("Access-Control-Allow-Origin", "*") |
| self.end_headers() |
| with open(status_path, "rb") as f: |
| self.wfile.write(f.read()) |
| else: |
| self.send_error(404) |
| elif path.startswith("/val/"): |
| rel = path[5:] |
| fpath = os.path.join(self.metrics_dir, "val", rel) |
| if os.path.exists(fpath): |
| self.send_response(200) |
| if fpath.endswith(".html"): |
| ct = "text/html" |
| elif fpath.endswith(".wav"): |
| ct = "audio/wav" |
| elif fpath.endswith(".json"): |
| ct = "application/json" |
| else: |
| ct = "application/octet-stream" |
| self.send_header("Content-Type", ct) |
| self.send_header("Access-Control-Allow-Origin", "*") |
| self.end_headers() |
| with open(fpath, "rb") as f: |
| self.wfile.write(f.read()) |
| else: |
| self.send_error(404) |
| else: |
| self.send_error(404) |
|
|
| def log_message(self, format, *args): |
| pass |
|
|
|
|
| def start_metrics_server(output_dir: str, port: int = 8765): |
| MetricsHandler.metrics_dir = output_dir |
| server = http.server.HTTPServer(("0.0.0.0", port), MetricsHandler) |
| thread = threading.Thread(target=server.serve_forever, daemon=True) |
| thread.start() |
| logging.info(f"Training monitor serving on http://0.0.0.0:{port}") |
| return server |
|
|
|
|
| |
|
|
| def setup_clap_models(full_checkpoint, device, dtype): |
| """Load AudioDecoder + VoiceCLAP-small for auxiliary losses. |
| |
| All models are frozen and in eval mode. |
| Returns: (audio_decoder, clap_model, clap_tokenizer) |
| """ |
| from ltx_pipelines.utils.blocks import AudioDecoder |
|
|
| logging.info("Loading AudioDecoder (VAE decoder + vocoder)...") |
| audio_decoder = AudioDecoder( |
| checkpoint_path=full_checkpoint, |
| dtype=dtype, |
| device=device, |
| warm=True, |
| ) |
|
|
| logging.info("Loading VoiceCLAP-small...") |
| from transformers import AutoModel, AutoTokenizer |
| clap_model = AutoModel.from_pretrained( |
| "laion/voiceclap-small", trust_remote_code=True |
| ).eval().to(device) |
| clap_tokenizer = AutoTokenizer.from_pretrained("laion/voiceclap-small", |
| trust_remote_code=True) |
|
|
| for p in clap_model.parameters(): |
| p.requires_grad = False |
|
|
| logging.info("CLAP + AudioDecoder loaded successfully") |
| return audio_decoder, clap_model, clap_tokenizer |
|
|
|
|
| def setup_clap_models_large(full_checkpoint, device, dtype, args): |
| """Load AudioDecoder + large VoiceCLAP via SentenceTransformer with optional quantization. |
| |
| For 7B+ models, INT4 quantization via bitsandbytes keeps VRAM manageable (~4 GB). |
| Returns: (audio_decoder, st_model, None) — no separate tokenizer needed. |
| """ |
| from ltx_pipelines.utils.blocks import AudioDecoder |
|
|
| logging.info("Loading AudioDecoder (VAE decoder + vocoder)...") |
| audio_decoder = AudioDecoder( |
| checkpoint_path=full_checkpoint, |
| dtype=dtype, |
| device=device, |
| warm=True, |
| ) |
|
|
| logging.info(f"Loading large CLAP model: {args.clap_model} (quantize={args.clap_quantize})...") |
| from sentence_transformers import SentenceTransformer |
|
|
| model_kwargs = {"torch_dtype": torch.bfloat16, "trust_remote_code": True} |
|
|
| if args.clap_quantize in ("int4", "int8"): |
| from transformers import BitsAndBytesConfig |
| bnb_config = BitsAndBytesConfig( |
| load_in_4bit=(args.clap_quantize == "int4"), |
| load_in_8bit=(args.clap_quantize == "int8"), |
| bnb_4bit_compute_dtype=torch.bfloat16, |
| ) |
| model_kwargs["quantization_config"] = bnb_config |
|
|
| st_model = SentenceTransformer( |
| args.clap_model, |
| model_kwargs=model_kwargs, |
| trust_remote_code=True, |
| ) |
|
|
| |
| test_emb = st_model.encode(["test"], convert_to_tensor=True) |
| emb_dim = test_emb.shape[-1] |
| logging.info(f"Large CLAP loaded: embedding dim = {emb_dim}") |
|
|
| for p in st_model.parameters(): |
| p.requires_grad = False |
|
|
| logging.info("Large CLAP + AudioDecoder loaded successfully") |
| return audio_decoder, st_model, None |
|
|
|
|
| def encode_text_st(text, st_model): |
| """Encode text with SentenceTransformer CLAP, returns normalized embedding [1, D].""" |
| with torch.no_grad(): |
| emb = st_model.encode([text], convert_to_tensor=True) |
| emb = F.normalize(emb, p=2, dim=-1) |
| return emb |
|
|
|
|
| def encode_audio_st(waveform_np, sr, st_model, rank=0): |
| """Encode audio with SentenceTransformer CLAP via temp file, returns normalized [1, D]. |
| |
| Args: |
| waveform_np: numpy array of audio samples (mono) |
| sr: sample rate |
| st_model: SentenceTransformer model |
| rank: GPU rank for unique temp file naming |
| """ |
| import soundfile as sf |
| tmp_path = f"/dev/shm/clap_tmp_{rank}.wav" |
| try: |
| sf.write(tmp_path, waveform_np, sr) |
| with torch.no_grad(): |
| emb = st_model.encode([{"audio": tmp_path}], convert_to_tensor=True) |
| emb = F.normalize(emb, p=2, dim=-1) |
| return emb |
| finally: |
| if os.path.exists(tmp_path): |
| os.remove(tmp_path) |
|
|
|
|
| def encode_clap_text(text, clap_model, clap_tokenizer, device): |
| """Encode text with VoiceCLAP-small, returns normalized embedding [1, 768].""" |
| enc = clap_tokenizer([text], padding=True, truncation=True, return_tensors="pt").to(device) |
| with torch.no_grad(): |
| emb = clap_model.encode_text(enc.input_ids, enc.attention_mask) |
| emb = F.normalize(emb, p=2, dim=-1) |
| return emb |
|
|
|
|
| def encode_clap_waveform_differentiable(waveform, clap_model): |
| """Differentiable CLAP audio encoding — bypasses @torch.no_grad() on compute_log_mel. |
| |
| VoiceCLAP-small's encode_waveform has @torch.no_grad() on its mel computation, |
| which breaks gradient flow. This function replicates the mel computation with |
| gradients enabled, then feeds into the audio encoder normally. |
| |
| Args: |
| waveform: [B, T] or [T] at 16kHz, with requires_grad from upstream |
| clap_model: VoiceCLAP-small model (frozen but grad flows through ops) |
| |
| Returns: |
| Normalized audio embedding [B, 768] with gradient chain intact |
| """ |
| _CHUNK_SAMPLES = 30 * 16000 |
|
|
| if waveform.dim() == 1: |
| waveform = waveform.unsqueeze(0) |
| B, T = waveform.shape |
| device = waveform.device |
|
|
| |
| n_chunks = max(1, (T + _CHUNK_SAMPLES - 1) // _CHUNK_SAMPLES) |
| pad = n_chunks * _CHUNK_SAMPLES - T |
| if pad > 0: |
| waveform = F.pad(waveform, (0, pad)) |
| chunks = waveform.view(B, n_chunks, _CHUNK_SAMPLES).reshape(B * n_chunks, _CHUNK_SAMPLES) |
|
|
| |
| chunks_f32 = chunks.to(dtype=torch.float32) |
| window = torch.hann_window(400, device=device) |
| stft = torch.stft(chunks_f32, n_fft=400, hop_length=160, window=window, return_complex=True) |
| magnitudes = stft[..., :-1].abs() ** 2 |
|
|
| |
| mel_filters = clap_model.mel_filters.to(magnitudes.dtype) |
| mel = mel_filters @ magnitudes |
|
|
| log_spec = torch.clamp(mel, min=1e-10).log10() |
| log_spec = torch.maximum(log_spec, log_spec.amax(dim=(-2, -1), keepdim=True) - 8.0) |
| log_spec = (log_spec + 4.0) / 4.0 |
|
|
| |
| feats = clap_model.audio_encoder(log_spec) |
| feats = feats.mean(dim=1) |
| feats = clap_model.audio_proj(feats) |
| feats = F.normalize(feats, dim=-1) |
|
|
| if n_chunks == 1: |
| return feats |
| feats = feats.view(B, n_chunks, -1).mean(dim=1) |
| return F.normalize(feats, dim=-1) |
|
|
|
|
| |
|
|
| def setup_speaker_model(device, dtype): |
| """Load WavLM-SV for speaker similarity. |
| |
| WavLM-base-plus-sv produces 512-dim speaker embeddings. It is a superior |
| alternative to ECAPA-TDNN from the same HuggingFace ecosystem with no extra |
| dependencies (uses transformers WavLMForXVector class). |
| """ |
| from transformers import WavLMForXVector, Wav2Vec2FeatureExtractor |
|
|
| logging.info("Loading WavLM-base-plus-sv for speaker similarity...") |
| model = WavLMForXVector.from_pretrained("microsoft/wavlm-base-plus-sv") |
| model.eval().to(device=device, dtype=torch.float32) |
| for p in model.parameters(): |
| p.requires_grad = False |
|
|
| feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained("microsoft/wavlm-base-plus-sv") |
|
|
| logging.info("WavLM-SV loaded successfully (~200MB)") |
| return model, feature_extractor |
|
|
|
|
| def wavlm_extract_embeddings(model, input_values): |
| """Wrapper for WavLM that returns tensor (for grad_checkpoint compatibility).""" |
| return model(input_values).embeddings |
|
|
|
|
| |
|
|
| def setup_centroid_and_quality(classifiers_dir, device, dtype): |
| """Load centroid embeddings and quality MLP. |
| |
| Returns: (real_centroid [1, 768], synth_centroid [1, 768], quality_mlp) |
| """ |
| import torch.nn as nn |
|
|
| classifiers_dir = Path(classifiers_dir) |
|
|
| |
| logging.info("Loading CLAP centroid embeddings...") |
| emb_data = torch.load(classifiers_dir / "clap_embeddings.pt", map_location="cpu", |
| weights_only=False) |
| dramabox_embs = emb_data["dramabox_embeddings"] |
| emilia_embs = emb_data["emilia_embeddings"] |
|
|
| |
| n_train = int(len(dramabox_embs) * 0.8) |
| synth_centroid = F.normalize(dramabox_embs[:n_train].float().mean(0, keepdim=True), p=2, dim=-1) |
| real_centroid = F.normalize(emilia_embs[:n_train].float().mean(0, keepdim=True), p=2, dim=-1) |
| synth_centroid = synth_centroid.to(device=device, dtype=torch.float32) |
| real_centroid = real_centroid.to(device=device, dtype=torch.float32) |
| logging.info(f"Centroids computed from {n_train} train samples each") |
|
|
| |
| logging.info("Loading quality classifier MLP...") |
| ckpt = torch.load(classifiers_dir / "quality_classifier.pt", map_location="cpu", |
| weights_only=False) |
|
|
| class BinaryMLP(nn.Module): |
| def __init__(self, d_in, h1, h2): |
| super().__init__() |
| self.net = nn.Sequential( |
| nn.Linear(d_in, h1), nn.ReLU(), nn.Dropout(0.3), |
| nn.Linear(h1, h2), nn.ReLU(), nn.Dropout(0.3), |
| nn.Linear(h2, 1)) |
|
|
| def forward(self, x): |
| return self.net(x) |
|
|
| quality_mlp = BinaryMLP(ckpt["input_dim"], ckpt["hidden1"], ckpt["hidden2"]) |
| quality_mlp.load_state_dict(ckpt["model_state_dict"]) |
| quality_mlp.eval().to(device=device, dtype=torch.float32) |
| for p in quality_mlp.parameters(): |
| p.requires_grad = False |
| logging.info("Quality MLP loaded (102K params)") |
|
|
| return real_centroid, synth_centroid, quality_mlp |
|
|
|
|
| |
|
|
| def parse_args(): |
| import yaml |
| cfg_parser = argparse.ArgumentParser(add_help=False) |
| cfg_parser.add_argument("--config", default=None) |
| cfg_args, remaining = cfg_parser.parse_known_args() |
|
|
| yaml_defaults = {} |
| if cfg_args.config: |
| with open(cfg_args.config) as f: |
| yaml_defaults = yaml.safe_load(f) or {} |
| yaml_defaults = {k.replace("-", "_"): v for k, v in yaml_defaults.items()} |
|
|
| def _y(name, fallback): |
| return yaml_defaults.get(name, fallback) |
|
|
| p = argparse.ArgumentParser(parents=[cfg_parser], |
| description="DramaBox LoRA Training with Multi-Auxiliary Losses") |
| p.add_argument("--preprocessed-dir", default=_y("preprocessed_dir", "./finetune_data")) |
| p.add_argument("--output-dir", default=_y("output_dir", "./finetune_output")) |
| p.add_argument("--dramabox-dir", default=_y("dramabox_dir", "/home/deployer/laion/DramaBox")) |
| p.add_argument("--checkpoint", default=_y("checkpoint", |
| "/home/deployer/laion/DramaBox/models/ltx-2.3-22b-dev-audio-only-v13-merged.safetensors")) |
| p.add_argument("--full-checkpoint", default=_y("full_checkpoint", |
| "/home/deployer/laion/DramaBox/models/ltx-2.3-22b-dev.safetensors")) |
| p.add_argument("--gemma-root", default=_y("gemma_root", |
| "/home/deployer/.cache/dramabox/models--unsloth--gemma-3-12b-it-bnb-4bit/snapshots/826e729dbaeea4ecb143738eed2bcf3539ebf7bf")) |
| p.add_argument("--base-model", choices=["distilled", "dev"], default=_y("base_model", "dev")) |
| p.add_argument("--lora-rank", type=int, default=_y("lora_rank", 128)) |
| p.add_argument("--lora-alpha", type=int, default=_y("lora_alpha", 128)) |
| p.add_argument("--lora-dropout", type=float, default=_y("lora_dropout", 0.05)) |
| p.add_argument("--resume-lora", default=_y("resume_lora", None)) |
| p.add_argument("--max-ref-tokens", type=int, default=_y("max_ref_tokens", 250)) |
| p.add_argument("--text-dropout", type=float, default=_y("text_dropout", 0.1)) |
| p.add_argument("--steps", type=int, default=_y("steps", 15000)) |
| p.add_argument("--lr", type=float, default=_y("lr", 3e-5)) |
| p.add_argument("--lr-scheduler", choices=["cosine", "linear", "constant"], |
| default=_y("lr_scheduler", "cosine")) |
| p.add_argument("--batch-size", type=int, default=_y("batch_size", 1)) |
| p.add_argument("--grad-accum", type=int, default=_y("grad_accum", 4)) |
| p.add_argument("--max-grad-norm", type=float, default=_y("max_grad_norm", 1.0)) |
| p.add_argument("--save-every", type=int, default=_y("save_every", 500)) |
| p.add_argument("--log-every", type=int, default=_y("log_every", 25)) |
| p.add_argument("--seed", type=int, default=_y("seed", 42)) |
| p.add_argument("--warmup-steps", type=int, default=_y("warmup_steps", 161)) |
| p.add_argument("--monitor-port", type=int, default=_y("monitor_port", 8765)) |
| p.add_argument("--test", action="store_true", help="Quick test: 1 GPU, 100 steps") |
| p.add_argument("--overfit", type=int, default=0, |
| help="Overfit on N prompt groups (sanity check)") |
| p.add_argument("--expand-all-modes", action="store_true", |
| default=bool(_y("expand_all_modes", False)), |
| help="Use all 3 modes per sample (not per group).") |
| p.add_argument("--epochs", type=int, default=_y("epochs", 3), |
| help="Train for N epochs (overrides --steps)") |
| p.add_argument("--val-samples", type=int, default=_y("val_samples", 10), |
| help="Number of validation samples per epoch") |
| p.add_argument("--val-refs-dir", default=_y("val_refs_dir", "/home/deployer/laion/test-refs"), |
| help="Directory with reference audio WAVs for validation") |
| p.add_argument("--resume-dir", default=_y("resume_dir", None), |
| help="Resume from full training state (dir with training_state.pt)") |
|
|
| |
| p.add_argument("--index-file", default=_y("index_file", None), |
| help="Path to custom index.json (e.g. index_filtered.json)") |
|
|
| |
| bw_default = _y("bucket_weights", None) |
| p.add_argument("--bucket-weights", default=bw_default, |
| help="Source->weight dict for bucket sampling (YAML dict or JSON string)") |
| p.add_argument("--podcast-epochs", type=int, default=_y("podcast_epochs", 0), |
| help="Train until podcast bucket seen N times (overrides --epochs when > 0)") |
|
|
| |
| clap_default = _y("clap_loss", True) |
| p.add_argument("--clap-loss", action="store_true", default=clap_default, |
| help="Enable CLAP auxiliary losses (default)") |
| p.add_argument("--no-clap", action="store_true", default=False, |
| help="Disable all auxiliary losses (for ablation)") |
| p.add_argument("--aux-target-ratio", type=float, |
| default=_y("aux_target_ratio", 1.0), |
| help="Target ratio of EACH aux loss to flow matching (default 1.0)") |
| p.add_argument("--speaker-sim-ratio", type=float, |
| default=_y("speaker_sim_ratio", 0.0), |
| help="Override target ratio for speaker similarity loss (0 = use aux-target-ratio)") |
| p.add_argument("--coeff-cap", type=float, |
| default=_y("coeff_cap", 10.0), |
| help="Max value for adaptive aux coefficients (default 10.0)") |
| p.add_argument("--positive-text", default=_y("positive_text", |
| "Realistic, genuine, spontaneous, authentic, sensual, natural voice " |
| "with all imperfections and organic microdistractions a natural situation brings with it"), |
| help="CLAP positive text") |
| p.add_argument("--negative-text", default=_y("negative_text", |
| "distorted, unnatural, robotic, distortion"), |
| help="CLAP negative text") |
| p.add_argument("--classifiers-dir", default=_y("classifiers_dir", "./classifiers"), |
| help="Directory with quality_classifier.pt and clap_embeddings.pt") |
|
|
| p.add_argument("--aux-sigma-max", type=float, |
| default=_y("aux_sigma_max", 0.4), |
| help="Only compute aux losses when sigma < this threshold (default 0.4)") |
| p.add_argument("--rejection-sampling", action="store_true", |
| default=_y("rejection_sampling", False), |
| help="Enable rejection sampling: only train on above-median reward samples") |
| p.add_argument("--rejection-percentile", type=float, |
| default=_y("rejection_percentile", 50.0), |
| help="Percentile threshold for rejection (default 50 = median)") |
| p.add_argument("--differentiable-reward", action="store_true", |
| default=_y("differentiable_reward", False), |
| help="Backprop through decoder/CLAP/WavLM for true differentiable rewards (ReFL-style)") |
| p.add_argument("--diff-reward-checkpoint", action="store_true", |
| default=_y("diff_reward_checkpoint", False), |
| help="Use gradient checkpointing on decoder/CLAP for VRAM savings") |
|
|
| |
| p.add_argument("--no-speaker-sim", action="store_true", |
| default=bool(_y("no_speaker_sim", False)), |
| help="Disable speaker similarity loss") |
| p.add_argument("--no-centroid", action="store_true", |
| default=bool(_y("no_centroid", False)), |
| help="Disable centroid real/fake loss") |
| p.add_argument("--no-quality-mlp", action="store_true", |
| default=bool(_y("no_quality_mlp", False)), |
| help="Disable quality MLP (keep CLAP text similarity only)") |
|
|
| |
| p.add_argument("--clap-model", default=_y("clap_model", "laion/voiceclap-small"), |
| help="CLAP model name (default: laion/voiceclap-small)") |
| p.add_argument("--clap-quantize", default=_y("clap_quantize", "none"), |
| choices=["none", "int8", "int4"], |
| help="Quantization for CLAP model: none/int8/int4 (default: none)") |
| p.add_argument("--keep-last-n", type=int, default=_y("keep_last_n", 0), |
| help="Rolling checkpoint window (0 = keep all)") |
| p.add_argument("--no-save-state", action="store_true", |
| default=bool(_y("no_save_state", False)), |
| help="Skip saving optimizer state .pt files (saves disk)") |
|
|
| args = p.parse_args(remaining) |
|
|
| |
| if args.no_clap: |
| args.clap_loss = False |
|
|
| |
| if isinstance(args.bucket_weights, str): |
| args.bucket_weights = json.loads(args.bucket_weights) |
|
|
| return args |
|
|
|
|
| |
|
|
| def main(): |
| from accelerate import Accelerator |
| from accelerate.utils import set_seed |
|
|
| args = parse_args() |
| setup_paths(args.dramabox_dir) |
|
|
| from audio_conditioning import AudioConditionByReferenceLatent |
| from ltx_core.components.patchifiers import AudioPatchifier |
| from ltx_core.guidance.perturbations import BatchedPerturbationConfig |
| from ltx_core.tools import AudioLatentTools |
| from ltx_core.types import AudioLatentShape, LatentState |
| from ltx_pipelines.utils.helpers import modality_from_latent_state |
|
|
| if args.test: |
| args.steps = min(args.steps, 100) |
| args.save_every = 50 |
| args.log_every = 5 |
|
|
| accelerator = Accelerator( |
| gradient_accumulation_steps=args.grad_accum, |
| mixed_precision="bf16", |
| ) |
|
|
| is_main = accelerator.is_main_process |
| if is_main: |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") |
| else: |
| logging.basicConfig(level=logging.WARNING) |
|
|
| set_seed(args.seed) |
| device = accelerator.device |
| dtype = torch.bfloat16 |
|
|
| os.makedirs(args.output_dir, exist_ok=True) |
|
|
| |
| if is_main: |
| try: |
| start_metrics_server(args.output_dir, args.monitor_port) |
| except OSError as e: |
| logging.warning(f"Could not start metrics server: {e}") |
|
|
| |
| if is_main: |
| import yaml |
| args_dict = vars(args).copy() |
| args_dict["_meta"] = { |
| "world_size": accelerator.num_processes, |
| "dtype": str(dtype), |
| "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), |
| "script": "dramabox_finetune_train_multi_aux.py", |
| "pattern": "IC-LoRA 3-mode + 3 auxiliary losses (naturalness, centroid, speaker sim)", |
| "aux_enabled": args.clap_loss, |
| } |
| with open(os.path.join(args.output_dir, "training_args.yaml"), "w") as f: |
| yaml.dump(args_dict, f, default_flow_style=False, sort_keys=False) |
|
|
| |
| if is_main: |
| logging.info("Loading audio-only model...") |
| model = build_audio_only_model(args.checkpoint, device, dtype) |
|
|
| if is_main: |
| logging.info("Loading audio connector...") |
| audio_connector = load_audio_connector(args.full_checkpoint, device, dtype) |
| audio_connector.eval() |
| for p in audio_connector.parameters(): |
| p.requires_grad = False |
|
|
| if is_main: |
| logging.info(f"Applying LoRA (rank={args.lora_rank}, alpha={args.lora_alpha})...") |
| model = apply_lora(model, args.lora_rank, args.lora_alpha, args.lora_dropout) |
|
|
| |
| if args.resume_lora: |
| from safetensors.torch import load_file as st_load |
| if is_main: |
| logging.info(f"Resuming from: {args.resume_lora}") |
| lora_sd = st_load(args.resume_lora) |
| mapped = {} |
| for k, v in lora_sd.items(): |
| nk = k.replace(".lora_A.weight", ".lora_A.default.weight").replace( |
| ".lora_B.weight", ".lora_B.default.weight") |
| mapped[nk] = v |
| model.load_state_dict(mapped, strict=False) |
|
|
| model.train() |
| model.base_model.model.set_gradient_checkpointing(True) |
|
|
| |
| aux_enabled = args.clap_loss |
| audio_decoder_clap = None |
| clap_model = None |
| clap_tokenizer = None |
| st_model = None |
| pos_text_emb = None |
| neg_text_emb = None |
| real_centroid = None |
| synth_centroid = None |
| quality_mlp = None |
| wavlm_sv = None |
| wavlm_fe = None |
| use_large_clap = False |
|
|
| |
| ema_alpha = 0.95 |
| ema_flow = 0.0 |
| ema_aux1 = 0.0 |
| ema_aux2 = 0.0 |
| ema_aux3 = 0.0 |
| target_ratio = args.aux_target_ratio |
| speaker_sim_ratio = args.speaker_sim_ratio if args.speaker_sim_ratio > 0 else target_ratio |
| coeff_cap = args.coeff_cap |
| aux_sigma_max = args.aux_sigma_max |
|
|
| use_centroid = not args.no_centroid |
| use_quality_mlp = not args.no_quality_mlp |
| use_speaker_sim = not args.no_speaker_sim |
|
|
| if aux_enabled: |
| if is_main: |
| logging.info("Setting up multi-auxiliary loss models...") |
|
|
| |
| use_large_clap = (args.clap_model != "laion/voiceclap-small") |
|
|
| if use_large_clap: |
| |
| audio_decoder_clap, st_model, _ = setup_clap_models_large( |
| args.full_checkpoint, device, dtype, args) |
| pos_text_emb = encode_text_st(args.positive_text, st_model) |
| neg_text_emb = encode_text_st(args.negative_text, st_model) |
|
|
| |
| |
| if use_centroid or use_quality_mlp: |
| if is_main: |
| logging.info("Also loading CLAP-small for centroid/quality_mlp compatibility...") |
| from transformers import AutoModel, AutoTokenizer |
| clap_model = AutoModel.from_pretrained( |
| "laion/voiceclap-small", trust_remote_code=True |
| ).to(device).eval() |
| for p in clap_model.parameters(): |
| p.requires_grad_(False) |
| if is_main: |
| logging.info("CLAP-small loaded alongside CLAP-large for aux classifiers") |
|
|
| |
| if getattr(args, 'differentiable_reward', False) and is_main: |
| logging.warning("Differentiable reward is not supported with large CLAP models " |
| "(7B+ too large for activation storage). " |
| "Falling back to non-differentiable mode.") |
| args.differentiable_reward = False |
| else: |
| |
| audio_decoder_clap, clap_model, clap_tokenizer = setup_clap_models( |
| args.full_checkpoint, device, dtype) |
| pos_text_emb = encode_clap_text(args.positive_text, clap_model, clap_tokenizer, device) |
| neg_text_emb = encode_clap_text(args.negative_text, clap_model, clap_tokenizer, device) |
|
|
| |
| if use_centroid or use_quality_mlp: |
| real_centroid, synth_centroid, quality_mlp = setup_centroid_and_quality( |
| args.classifiers_dir, device, dtype) |
| if not use_centroid: |
| real_centroid = None |
| synth_centroid = None |
| if not use_quality_mlp: |
| quality_mlp = None |
|
|
| |
| if use_speaker_sim: |
| wavlm_sv, wavlm_fe = setup_speaker_model(device, dtype) |
|
|
| if is_main: |
| logging.info(f"Auxiliary losses enabled:") |
| logging.info(f" CLAP model: {args.clap_model} (large={use_large_clap}, quantize={args.clap_quantize})") |
| logging.info(f" Loss 1 (Naturalness): CLAP text={True}, quality_mlp={use_quality_mlp}") |
| logging.info(f" Loss 2 (Centroid): {use_centroid}") |
| logging.info(f" Loss 3 (Speaker Sim): {use_speaker_sim}") |
| logging.info(f" Target ratio (naturalness/centroid): {target_ratio}") |
| logging.info(f" Speaker sim ratio: {speaker_sim_ratio}") |
| logging.info(f" Coefficient cap: {coeff_cap}") |
| logging.info(f" Aux sigma threshold: {aux_sigma_max} (skip aux when sigma >= this)") |
| _rej = getattr(args, 'rejection_sampling', False) |
| _rej_pct = getattr(args, 'rejection_percentile', 50.0) |
| _diff = getattr(args, 'differentiable_reward', False) |
| if _diff: |
| logging.info(f" DIFFERENTIABLE REWARD: enabled (ReFL-style backprop through decoder/CLAP/WavLM)") |
| logging.info(f" → Gradients flow: pred_tgt → decoder → waveform → CLAP/WavLM → loss") |
| logging.info(f" → Gradient checkpointing on aux models: {getattr(args, 'diff_reward_checkpoint', False)}") |
| elif _rej: |
| logging.info(f" REJECTION SAMPLING: enabled, percentile={_rej_pct}%") |
| logging.info(f" → Only train on top {100-_rej_pct:.0f}% reward samples") |
| logging.info(f" → Flow loss boosted by {100.0/max(100.0-_rej_pct, 1.0):.1f}x to compensate") |
| else: |
| logging.info(f" Rejection sampling: disabled (using reward-weighted aux losses)") |
| logging.info(f" Positive text: {args.positive_text[:80]}...") |
| logging.info(f" Negative text: {args.negative_text}") |
| if getattr(args, 'keep_last_n', 0) > 0: |
| logging.info(f" Checkpoint management: rolling window of {args.keep_last_n} + keep better older ones") |
|
|
| |
| mode_weights = { |
| "voice_clone_fwd": 0.33, |
| "voice_clone_rev": 0.33, |
| "unconditional": 0.34, |
| } |
| expand_all = getattr(args, "expand_all_modes", False) |
| dataset = DramaBoxFinetuneDataset( |
| preprocessed_dir=args.preprocessed_dir, |
| mode_weights=mode_weights, |
| max_ref_tokens=args.max_ref_tokens, |
| overfit_n=args.overfit, |
| expand_all_modes=expand_all, |
| index_file=args.index_file, |
| ) |
|
|
| |
| bucket_sampler = None |
| if args.bucket_weights and isinstance(args.bucket_weights, dict): |
| bucket_sampler = BucketWeightedSampler( |
| dataset, bucket_weights=args.bucket_weights, seed=args.seed, |
| ) |
| dataloader = DataLoader( |
| dataset, batch_size=args.batch_size, sampler=bucket_sampler, |
| num_workers=2, pin_memory=True, drop_last=True, collate_fn=collate_fn, |
| ) |
| else: |
| dataloader = DataLoader( |
| dataset, batch_size=args.batch_size, shuffle=True, |
| num_workers=2, pin_memory=True, drop_last=True, collate_fn=collate_fn, |
| ) |
|
|
| |
| steps_per_epoch = 0 |
| if bucket_sampler is not None and args.podcast_epochs > 0: |
| |
| |
| args.epochs = args.podcast_epochs |
| items_per_gpu = math.ceil(len(bucket_sampler) / max(accelerator.num_processes, 1)) |
| steps_per_epoch = math.ceil(items_per_gpu / args.grad_accum) |
| args.steps = steps_per_epoch * args.epochs |
| if is_main: |
| logging.info(f"Bucket-weighted: {args.epochs} podcast-epochs x {steps_per_epoch} steps/epoch " |
| f"= {args.steps} total optimizer steps") |
| logging.info(f" ({len(bucket_sampler)} items/epoch, {accelerator.num_processes} GPUs, " |
| f"grad_accum={args.grad_accum})") |
| elif args.epochs > 0 and not args.overfit: |
| items_per_gpu = math.ceil(len(dataset) / max(accelerator.num_processes, 1)) |
| forward_per_epoch = items_per_gpu |
| steps_per_epoch = math.ceil(forward_per_epoch / args.grad_accum) |
| args.steps = steps_per_epoch * args.epochs |
| if is_main: |
| logging.info(f"Epoch-based: {args.epochs} epochs x {steps_per_epoch} steps/epoch " |
| f"= {args.steps} total optimizer steps") |
| logging.info(f" ({len(dataset)} items, {accelerator.num_processes} GPUs, " |
| f"grad_accum={args.grad_accum})") |
|
|
| |
| optimizer = torch.optim.AdamW( |
| [p for p in model.parameters() if p.requires_grad], |
| lr=args.lr, betas=(0.9, 0.999), weight_decay=0.01, |
| ) |
|
|
| from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR, ConstantLR |
| warmup = LinearLR(optimizer, start_factor=0.01, end_factor=1.0, total_iters=args.warmup_steps) |
| remaining = args.steps - args.warmup_steps |
| if args.lr_scheduler == "cosine": |
| hold_steps = max(remaining // 5, 0) |
| decay_steps = max(remaining - hold_steps, 1) |
| hold_sched = ConstantLR(optimizer, factor=1.0, total_iters=hold_steps) |
| decay_sched = CosineAnnealingLR(optimizer, T_max=decay_steps, eta_min=1e-6) |
| scheduler = SequentialLR( |
| optimizer, |
| [warmup, hold_sched, decay_sched], |
| milestones=[args.warmup_steps, args.warmup_steps + hold_steps], |
| ) |
| elif args.lr_scheduler == "linear": |
| main_sched = LinearLR(optimizer, start_factor=1.0, end_factor=0.01, |
| total_iters=max(remaining, 1)) |
| scheduler = SequentialLR(optimizer, [warmup, main_sched], milestones=[args.warmup_steps]) |
| else: |
| main_sched = ConstantLR(optimizer, factor=1.0, total_iters=max(remaining, 1)) |
| scheduler = SequentialLR(optimizer, [warmup, main_sched], milestones=[args.warmup_steps]) |
|
|
| model, optimizer, dataloader = accelerator.prepare(model, optimizer, dataloader) |
|
|
| |
| resume_step = 0 |
| resume_epoch = 0 |
| if args.resume_dir and is_main: |
| try: |
| resume_step, resume_epoch, best_loss_r, best_step_r = load_training_state( |
| args.resume_dir, optimizer, scheduler) |
| logging.info(f"Resumed: step={resume_step}, epoch={resume_epoch}, " |
| f"best_loss={best_loss_r:.4f}, best_step={best_step_r}") |
| except Exception as e: |
| logging.error(f"Failed to resume: {e}") |
| resume_step = 0 |
| resume_epoch = 0 |
|
|
| patchifier = AudioPatchifier(patch_size=1) |
|
|
| |
| if args.base_model == "distilled": |
| timestep_sampler = DistilledTimestepSampler() |
| else: |
| timestep_sampler = ShiftedLogitNormalTimestepSampler() |
|
|
| |
| silence_frame = None |
| sf_path = os.path.join(args.dramabox_dir, "assets", "silence_latent_frame.pt") |
| if os.path.exists(sf_path): |
| silence_frame = torch.load(sf_path, weights_only=True) |
| if is_main: |
| logging.info(f"Loaded silence latent from {sf_path}") |
|
|
| |
| metrics_path = os.path.join(args.output_dir, "metrics.jsonl") |
|
|
| if is_main: |
| logging.info(f"Training: {args.steps} steps, lr={args.lr}, scheduler={args.lr_scheduler}, " |
| f"batch={args.batch_size}, grad_accum={args.grad_accum}, " |
| f"world_size={accelerator.num_processes}, " |
| f"max_ref_tokens={args.max_ref_tokens}") |
| logging.info(f"3-mode IC-LoRA: voice_clone_fwd/rev + unconditional") |
| logging.info(f"Multi-auxiliary losses: {'ENABLED' if aux_enabled else 'DISABLED'}") |
| if args.overfit: |
| logging.info(f"OVERFIT MODE: training on {args.overfit} groups only") |
|
|
| data_iter = iter(dataloader) |
| step = 0 |
| accum_loss = 0.0 |
| accum_total_loss = 0.0 |
| |
| accum_aux1 = 0.0 |
| accum_aux2 = 0.0 |
| accum_aux3 = 0.0 |
| accum_coeff1 = 0.0 |
| accum_coeff2 = 0.0 |
| accum_coeff3 = 0.0 |
| accum_clap_text_reward = 0.0 |
| accum_quality_prob = 0.0 |
| accum_naturalness_reward = 0.0 |
| accum_centroid_score = 0.0 |
| accum_speaker_sim = 0.0 |
| aux_log_count = 0 |
| speaker_sim_count = 0 |
| |
| rejection_enabled = getattr(args, 'rejection_sampling', False) |
| rejection_percentile = getattr(args, 'rejection_percentile', 50.0) |
| reward_buffer = deque(maxlen=512) |
| rejection_count = 0 |
| acceptance_count = 0 |
| |
| differentiable_reward = getattr(args, 'differentiable_reward', False) |
| diff_checkpoint = getattr(args, 'diff_reward_checkpoint', False) |
| vram_peak_mb = 0.0 |
| best_loss = float("inf") |
| best_step = 0 |
| t0 = time.time() |
| mode_counts = defaultdict(int) |
| current_epoch = 0 |
| last_val_epoch = -1 |
| val_procs = [] |
| script_dir = os.path.dirname(os.path.abspath(__file__)) |
| |
| keep_last_n = getattr(args, 'keep_last_n', 0) |
| recent_checkpoints = deque(maxlen=keep_last_n) if keep_last_n > 0 else None |
| checkpoint_rewards = {} |
|
|
| |
| if resume_step > 0: |
| step = resume_step |
| current_epoch = resume_epoch |
| if 'best_loss_r' in dir(): |
| best_loss = best_loss_r |
| best_step = best_step_r |
| if is_main: |
| logging.info(f"Skipping to step {step}, epoch {current_epoch}") |
|
|
| total_micro_steps = args.steps * args.grad_accum |
| start_micro = step * args.grad_accum |
|
|
| for micro_step in range(start_micro, total_micro_steps): |
| try: |
| batch = next(data_iter) |
| except StopIteration: |
| data_iter = iter(dataloader) |
| batch = next(data_iter) |
|
|
| is_opt_step = (micro_step + 1) % args.grad_accum == 0 |
| if is_opt_step: |
| step += 1 |
|
|
| with accelerator.accumulate(model): |
| tgt_latent = batch["tgt_latent"].to(dtype=dtype) |
| ref_latent = batch["ref_latent"].to(dtype=dtype) |
| tgt_lengths = batch["tgt_lengths"].to(device=device) |
| B = tgt_latent.shape[0] |
|
|
| |
| for m in batch["modes"]: |
| mode_counts[m] += 1 |
|
|
| |
| max_pad = 25 |
| pad_frames = random.randint(0, max_pad) |
| if pad_frames > 0: |
| C, F_dim = tgt_latent.shape[1], tgt_latent.shape[3] |
| if silence_frame is not None: |
| sf = silence_frame.to(dtype=dtype, device=device) |
| silence_pad = sf.unsqueeze(0).expand(B, -1, pad_frames, -1) |
| else: |
| silence_pad = torch.zeros(B, C, pad_frames, F_dim, dtype=dtype, device=device) |
| tgt_latent = torch.cat([silence_pad, tgt_latent], dim=2) |
|
|
| |
| ref_T_frames = min(ref_latent.shape[2], args.max_ref_tokens) |
| ref_latent = ref_latent[:, :, :ref_T_frames, :] |
| tgt_T_frames = tgt_latent.shape[2] |
|
|
| |
| tgt_shape = AudioLatentShape( |
| batch=B, channels=tgt_latent.shape[1], |
| frames=tgt_T_frames, mel_bins=tgt_latent.shape[3], |
| ) |
| audio_tools = AudioLatentTools(patchifier=patchifier, target_shape=tgt_shape) |
| state = audio_tools.create_initial_state(device=device, dtype=dtype, initial_latent=tgt_latent) |
| tgt_T = audio_tools.target_shape.token_count() |
|
|
| |
| total_tokens = tgt_T + ref_T_frames |
| sigma = timestep_sampler.sample(B, total_tokens, device=device) |
| sigma_exp = sigma.view(-1, 1, 1) |
|
|
| noise = torch.randn_like(state.latent) |
| noisy_tgt = (1 - sigma_exp) * state.latent + sigma_exp * noise |
|
|
| state = LatentState( |
| latent=noisy_tgt, |
| denoise_mask=state.denoise_mask, |
| positions=state.positions, |
| clean_latent=state.clean_latent, |
| attention_mask=state.attention_mask, |
| ) |
|
|
| |
| if ref_T_frames > 0: |
| ref_conditioning = AudioConditionByReferenceLatent( |
| latent=ref_latent, strength=1.0, |
| ) |
| state = ref_conditioning.apply_to(latent_state=state, latent_tools=audio_tools) |
|
|
| |
| loss_mask = torch.zeros(B, tgt_T, device=device) |
| for b_idx in range(B): |
| real_len = min(tgt_lengths[b_idx].item() + pad_frames, tgt_T) |
| loss_mask[b_idx, :real_len] = 1.0 |
|
|
| |
| with torch.no_grad(): |
| audio_context = prepare_audio_context( |
| audio_connector, batch["audio_features"], |
| batch["attention_mask"], device, dtype) |
| if args.text_dropout > 0 and random.random() < args.text_dropout: |
| audio_context = torch.zeros_like(audio_context) |
|
|
| |
| audio_mod = modality_from_latent_state( |
| state=state, context=audio_context, sigma=sigma, enabled=True, |
| ) |
|
|
| |
| perturbations = BatchedPerturbationConfig.empty(B) |
| with torch.autocast(device_type="cuda", dtype=dtype): |
| _, velocity_pred = model(video=None, audio=audio_mod, perturbations=perturbations) |
|
|
| |
| tgt_patchified = audio_tools.patchifier.patchify(tgt_latent) |
| target_velocity = noise - tgt_patchified |
| pred_tgt = velocity_pred[:, :tgt_T] |
| per_token_mse = (pred_tgt - target_velocity).pow(2).mean(dim=-1) |
| loss = per_token_mse.mul(loss_mask).div(loss_mask.mean().clamp(min=1e-6)).mean() |
|
|
| |
| aux1_val = 0.0 |
| aux2_val = 0.0 |
| aux3_val = 0.0 |
| coeff1_val = 0.0 |
| coeff2_val = 0.0 |
| coeff3_val = 0.0 |
| clap_text_reward_val = 0.0 |
| quality_prob_val = 0.0 |
| naturalness_reward_val = 0.0 |
| centroid_score_val = 0.0 |
| speaker_sim_val = None |
|
|
| |
| |
| sigma_val = sigma.item() |
| aux_active = aux_enabled and sigma_val < aux_sigma_max |
|
|
| if aux_active and differentiable_reward and tgt_T_frames <= 250: |
| |
| |
| |
| |
| import torchaudio |
| from torch.utils.checkpoint import checkpoint as grad_checkpoint |
|
|
| try: |
| torch.cuda.empty_cache() |
| |
| noisy_tgt_tokens = state.latent[:, :tgt_T].detach() |
| x0_pred_tokens = noisy_tgt_tokens - pred_tgt * sigma_exp |
|
|
| x0_latent = patchifier.unpatchify( |
| x0_pred_tokens, |
| AudioLatentShape( |
| batch=B, channels=tgt_latent.shape[1], |
| frames=tgt_T_frames, mel_bins=tgt_latent.shape[3], |
| ), |
| ) |
|
|
| |
| if diff_checkpoint: |
| |
| decoded = grad_checkpoint( |
| audio_decoder_clap, x0_latent[0:1].to(dtype=dtype), |
| use_reentrant=False) |
| else: |
| decoded = audio_decoder_clap(x0_latent[0:1].to(dtype=dtype)) |
|
|
| pred_wav = decoded.waveform.squeeze(0).float() |
| audio_sr = decoded.sampling_rate |
| pred_mono = pred_wav.mean(0) if pred_wav.ndim > 1 else pred_wav |
| if audio_sr != 16000: |
| pred_mono = torchaudio.functional.resample(pred_mono, audio_sr, 16000) |
|
|
| |
| if diff_checkpoint: |
| audio_emb = grad_checkpoint( |
| encode_clap_waveform_differentiable, |
| pred_mono.unsqueeze(0).to(device), |
| clap_model, |
| use_reentrant=False) |
| else: |
| audio_emb = encode_clap_waveform_differentiable( |
| pred_mono.unsqueeze(0).to(device), clap_model) |
| audio_emb_norm = F.normalize(audio_emb, p=2, dim=-1) |
|
|
| |
| clap_pos_sim = (audio_emb_norm @ pos_text_emb.T).squeeze() |
| clap_neg_sim = (audio_emb_norm @ neg_text_emb.T).squeeze() |
| naturalness_loss = -clap_pos_sim + clap_neg_sim |
|
|
| |
| quality_logit_t = quality_mlp(audio_emb_norm.float()).squeeze() |
| quality_loss = -quality_logit_t |
|
|
| aux1_loss = naturalness_loss + 0.5 * quality_loss |
|
|
| |
| aux2_loss = torch.tensor(0.0, device=device) |
| if use_centroid and real_centroid is not None: |
| cos_real_t = (audio_emb_norm.float() @ real_centroid.T).squeeze() |
| cos_synth_t = (audio_emb_norm.float() @ synth_centroid.T).squeeze() |
| aux2_loss = -cos_real_t + cos_synth_t |
|
|
| |
| aux3_loss = torch.tensor(0.0, device=device) |
| if use_speaker_sim and wavlm_sv is not None and ref_T_frames > 0: |
| |
| with torch.no_grad(): |
| ref_decoded = audio_decoder_clap(ref_latent[0:1].to(dtype=dtype)) |
| ref_wav = ref_decoded.waveform.squeeze(0).float() |
| ref_mono = ref_wav.mean(0) if ref_wav.ndim > 1 else ref_wav |
| if ref_decoded.sampling_rate != 16000: |
| ref_mono = torchaudio.functional.resample( |
| ref_mono, ref_decoded.sampling_rate, 16000) |
| |
| ref_norm = (ref_mono - ref_mono.mean()) / (ref_mono.std() + 1e-7) |
| ref_spk = wavlm_sv( |
| ref_norm.unsqueeze(0).to(device=device, dtype=torch.float32) |
| ).embeddings |
| ref_spk = F.normalize(ref_spk.float(), p=2, dim=-1) |
|
|
| |
| pred_std = pred_mono.std().detach() |
| pred_norm = (pred_mono - pred_mono.mean().detach()) / (pred_std + 1e-7) |
| pred_input = pred_norm.unsqueeze(0).to(device=device, dtype=torch.float32) |
| if diff_checkpoint: |
| pred_spk_emb = grad_checkpoint( |
| wavlm_extract_embeddings, |
| wavlm_sv, |
| pred_input, |
| use_reentrant=False) |
| else: |
| pred_spk_emb = wavlm_sv(pred_input).embeddings |
| pred_spk = F.normalize(pred_spk_emb.float(), p=2, dim=-1) |
| speaker_sim_t = (pred_spk @ ref_spk.T).squeeze() |
| aux3_loss = -speaker_sim_t |
|
|
| |
| ema_flow = ema_alpha * ema_flow + (1 - ema_alpha) * loss.item() |
| aux1_item = aux1_loss.item() |
| ema_aux1 = ema_alpha * ema_aux1 + (1 - ema_alpha) * abs(aux1_item) |
| coeff1_val = min(target_ratio * ema_flow / max(ema_aux1, 1e-8), coeff_cap) |
|
|
| coeff2_val = 0.0 |
| if use_centroid and real_centroid is not None: |
| aux2_item = aux2_loss.item() |
| ema_aux2 = ema_alpha * ema_aux2 + (1 - ema_alpha) * abs(aux2_item) |
| coeff2_val = min(target_ratio * ema_flow / max(ema_aux2, 1e-8), coeff_cap) |
|
|
| coeff3_val = 0.0 |
| if aux3_loss.requires_grad or aux3_loss.item() != 0.0: |
| aux3_item = aux3_loss.item() |
| ema_aux3 = ema_alpha * ema_aux3 + (1 - ema_alpha) * abs(aux3_item) |
| coeff3_val = min(speaker_sim_ratio * ema_flow / max(ema_aux3, 1e-8), coeff_cap) |
|
|
| total_loss = loss + coeff1_val * aux1_loss + coeff2_val * aux2_loss + coeff3_val * aux3_loss |
|
|
| if torch.isnan(total_loss) or torch.isinf(total_loss): |
| accelerator.backward(loss) |
| total_loss = loss |
| else: |
| accelerator.backward(total_loss) |
|
|
| |
| clap_text_reward_val = (clap_pos_sim - clap_neg_sim).item() |
| quality_prob_val = torch.sigmoid(quality_logit_t.detach()).item() |
| naturalness_reward_val = clap_text_reward_val |
| if use_centroid and real_centroid is not None: |
| centroid_score_val = (cos_real_t - cos_synth_t).item() |
| if aux3_loss.item() != 0.0: |
| speaker_sim_val = -aux3_loss.item() |
|
|
| aux1_val = aux1_loss.item() |
| aux2_val = aux2_loss.item() |
| aux3_val = aux3_loss.item() |
| accum_total_loss += total_loss.item() |
| accum_aux1 += aux1_val |
| accum_aux2 += aux2_val |
| accum_aux3 += aux3_val |
| accum_coeff1 += coeff1_val |
| accum_coeff2 += coeff2_val |
| accum_coeff3 += coeff3_val |
|
|
| |
| cur_vram = torch.cuda.max_memory_allocated(device) / 1024**2 |
| if cur_vram > vram_peak_mb: |
| vram_peak_mb = cur_vram |
|
|
| except RuntimeError as e: |
| if "out of memory" in str(e).lower(): |
| |
| torch.cuda.empty_cache() |
| if is_main: |
| logging.warning(f"OOM in differentiable reward! VRAM peak: " |
| f"{torch.cuda.max_memory_allocated(device)/1024**2:.0f}MB. " |
| f"Falling back to flow-only. Consider --diff-reward-checkpoint") |
| accelerator.backward(loss) |
| else: |
| raise |
|
|
| |
| accum_clap_text_reward += clap_text_reward_val |
| accum_quality_prob += quality_prob_val |
| accum_naturalness_reward += naturalness_reward_val |
| accum_centroid_score += centroid_score_val |
| aux_log_count += 1 |
| if speaker_sim_val is not None: |
| accum_speaker_sim += speaker_sim_val |
| speaker_sim_count += 1 |
|
|
| elif aux_active: |
| |
| |
| |
| import torchaudio |
|
|
| with torch.no_grad(): |
| |
| noisy_tgt_tokens = state.latent[:, :tgt_T] |
| x0_pred_tokens_ng = noisy_tgt_tokens - pred_tgt.detach() * sigma_exp |
|
|
| |
| x0_latent = patchifier.unpatchify( |
| x0_pred_tokens_ng, |
| AudioLatentShape( |
| batch=B, channels=tgt_latent.shape[1], |
| frames=tgt_T_frames, mel_bins=tgt_latent.shape[3], |
| ), |
| ) |
|
|
| |
| decoded = audio_decoder_clap(x0_latent[0:1].to(dtype=dtype)) |
| pred_wav = decoded.waveform.squeeze(0).float() |
| audio_sr = decoded.sampling_rate |
| pred_mono = pred_wav.mean(0) if pred_wav.ndim > 1 else pred_wav |
| if audio_sr != 16000: |
| pred_mono = torchaudio.functional.resample(pred_mono, audio_sr, 16000) |
|
|
| |
| if use_large_clap and st_model is not None: |
| import numpy as np |
| wav_np = pred_mono.cpu().numpy() |
| audio_emb = encode_audio_st( |
| wav_np, 16000, st_model, |
| rank=accelerator.process_index) |
| else: |
| audio_emb = clap_model.encode_waveform(pred_mono.unsqueeze(0).to(device)) |
| audio_emb = F.normalize(audio_emb, p=2, dim=-1) |
|
|
| |
| |
| |
| audio_emb_small = None |
| if use_large_clap and clap_model is not None and (use_quality_mlp or use_centroid): |
| audio_emb_small = clap_model.encode_waveform(pred_mono.unsqueeze(0).to(device)) |
| audio_emb_small = F.normalize(audio_emb_small, p=2, dim=-1) |
|
|
| |
| clap_pos_sim = (audio_emb @ pos_text_emb.T).item() |
| clap_neg_sim = (audio_emb @ neg_text_emb.T).item() |
| clap_text_reward_val = clap_pos_sim - clap_neg_sim |
|
|
| if use_quality_mlp and quality_mlp is not None: |
| |
| emb_for_mlp = audio_emb_small if audio_emb_small is not None else audio_emb |
| quality_logit = quality_mlp(emb_for_mlp.float()).item() |
| quality_prob_val = torch.sigmoid(torch.tensor(quality_logit)).item() |
| naturalness_reward_val = (0.5 * clap_text_reward_val + |
| 0.5 * (2 * quality_prob_val - 1)) |
| else: |
| quality_prob_val = 0.5 |
| naturalness_reward_val = clap_text_reward_val |
|
|
| |
| if use_centroid and real_centroid is not None: |
| emb_for_cent = audio_emb_small if audio_emb_small is not None else audio_emb |
| cos_real = (emb_for_cent @ real_centroid.T).item() |
| cos_synth = (emb_for_cent @ synth_centroid.T).item() |
| centroid_score_val = cos_real - cos_synth |
|
|
| |
| if use_speaker_sim and wavlm_sv is not None and ref_T_frames > 0: |
| |
| ref_decoded = audio_decoder_clap(ref_latent[0:1].to(dtype=dtype)) |
| ref_wav = ref_decoded.waveform.squeeze(0).float() |
| ref_mono = ref_wav.mean(0) if ref_wav.ndim > 1 else ref_wav |
| if ref_decoded.sampling_rate != 16000: |
| ref_mono = torchaudio.functional.resample( |
| ref_mono, ref_decoded.sampling_rate, 16000) |
|
|
| |
| |
| pred_inputs = wavlm_fe( |
| pred_mono.cpu().numpy(), sampling_rate=16000, |
| return_tensors="pt", padding=True) |
| ref_inputs = wavlm_fe( |
| ref_mono.cpu().numpy(), sampling_rate=16000, |
| return_tensors="pt", padding=True) |
|
|
| pred_spk = wavlm_sv( |
| pred_inputs.input_values.to(device=device, dtype=torch.float32) |
| ).embeddings |
| ref_spk = wavlm_sv( |
| ref_inputs.input_values.to(device=device, dtype=torch.float32) |
| ).embeddings |
|
|
| pred_spk = F.normalize(pred_spk.float(), p=2, dim=-1) |
| ref_spk = F.normalize(ref_spk.float(), p=2, dim=-1) |
| speaker_sim_val = (pred_spk @ ref_spk.T).item() |
|
|
| |
| composite_reward = naturalness_reward_val |
| n_rewards = 1 |
| if use_centroid and real_centroid is not None: |
| composite_reward += centroid_score_val |
| n_rewards += 1 |
| if speaker_sim_val is not None: |
| composite_reward += speaker_sim_val |
| n_rewards += 1 |
| composite_reward /= n_rewards |
|
|
| |
| if rejection_enabled: |
| reward_buffer.append(composite_reward) |
|
|
| |
| if len(reward_buffer) >= 32: |
| sorted_buf = sorted(reward_buffer) |
| idx = int(len(sorted_buf) * rejection_percentile / 100.0) |
| idx = min(idx, len(sorted_buf) - 1) |
| threshold = sorted_buf[idx] |
| else: |
| threshold = -999.0 |
|
|
| if composite_reward >= threshold: |
| |
| |
| boost = 100.0 / max(100.0 - rejection_percentile, 1.0) |
| accelerator.backward(loss * boost) |
| acceptance_count += 1 |
| else: |
| |
| accelerator.backward(loss * 0.0) |
| rejection_count += 1 |
|
|
| total_loss = loss |
| accum_total_loss += loss.item() |
|
|
| else: |
| |
| x0_clean = patchifier.patchify(tgt_latent) |
| x0_pred_grad = state.latent[:, :tgt_T].detach() - pred_tgt * sigma_exp |
| x0_recon_loss = ((x0_pred_grad - x0_clean.detach()).pow(2).mean(dim=-1) |
| * loss_mask).div(loss_mask.mean().clamp(min=1e-6)).mean() |
|
|
| |
| w1 = torch.clamp( |
| torch.tensor(0.5 - naturalness_reward_val, device=device), |
| min=0.05, max=2.0) |
| aux1 = w1 * x0_recon_loss |
|
|
| |
| if use_centroid and real_centroid is not None: |
| w2 = torch.clamp( |
| torch.tensor(0.5 - centroid_score_val, device=device), |
| min=0.05, max=2.0) |
| aux2 = w2 * x0_recon_loss |
| else: |
| aux2 = torch.tensor(0.0, device=device) |
|
|
| |
| if speaker_sim_val is not None: |
| w3 = torch.clamp( |
| torch.tensor(0.5 - speaker_sim_val, device=device), |
| min=0.05, max=2.0) |
| aux3 = w3 * x0_recon_loss |
| else: |
| aux3 = torch.tensor(0.0, device=device) |
|
|
| |
| ema_flow = ema_alpha * ema_flow + (1 - ema_alpha) * loss.item() |
| ema_aux1 = ema_alpha * ema_aux1 + (1 - ema_alpha) * aux1.item() |
| coeff1_val = min(target_ratio * ema_flow / max(ema_aux1, 1e-8), coeff_cap) |
|
|
| coeff2_val = 0.0 |
| if use_centroid and real_centroid is not None: |
| ema_aux2 = ema_alpha * ema_aux2 + (1 - ema_alpha) * aux2.item() |
| coeff2_val = min(target_ratio * ema_flow / max(ema_aux2, 1e-8), coeff_cap) |
|
|
| coeff3_val = 0.0 |
| if speaker_sim_val is not None: |
| ema_aux3 = ema_alpha * ema_aux3 + (1 - ema_alpha) * aux3.item() |
| coeff3_val = min(speaker_sim_ratio * ema_flow / max(ema_aux3, 1e-8), coeff_cap) |
|
|
| total_loss = loss + coeff1_val * aux1 + coeff2_val * aux2 + coeff3_val * aux3 |
|
|
| if torch.isnan(total_loss) or torch.isinf(total_loss): |
| accelerator.backward(loss) |
| total_loss = loss |
| else: |
| accelerator.backward(total_loss) |
|
|
| aux1_val = aux1.item() if not math.isnan(aux1.item()) else 0.0 |
| aux2_val = aux2.item() if not math.isnan(aux2.item()) else 0.0 |
| aux3_val = aux3.item() if not math.isnan(aux3.item()) else 0.0 |
| accum_total_loss += total_loss.item() if not math.isnan(total_loss.item()) else loss.item() |
| accum_aux1 += aux1_val |
| accum_aux2 += aux2_val |
| accum_aux3 += aux3_val |
| accum_coeff1 += coeff1_val |
| accum_coeff2 += coeff2_val |
| accum_coeff3 += coeff3_val |
|
|
| |
| accum_clap_text_reward += clap_text_reward_val |
| accum_quality_prob += quality_prob_val |
| accum_naturalness_reward += naturalness_reward_val |
| accum_centroid_score += centroid_score_val |
| aux_log_count += 1 |
| if speaker_sim_val is not None: |
| accum_speaker_sim += speaker_sim_val |
| speaker_sim_count += 1 |
| else: |
| |
| accelerator.backward(loss) |
|
|
| if accelerator.sync_gradients and args.max_grad_norm > 0: |
| accelerator.clip_grad_norm_(model.parameters(), args.max_grad_norm) |
|
|
| optimizer.step() |
| optimizer.zero_grad() |
| if accelerator.sync_gradients: |
| scheduler.step() |
|
|
| accum_loss += loss.item() |
|
|
| |
| if is_opt_step and step % args.log_every == 0 and is_main: |
| avg_loss = accum_loss / (args.log_every * args.grad_accum) |
| lr = optimizer.param_groups[0]["lr"] |
| elapsed = time.time() - t0 |
| sps = step / elapsed if elapsed > 0 else 0 |
| eta = (args.steps - step) / sps if sps > 0 else 0 |
|
|
| total_modes = sum(mode_counts.values()) or 1 |
| mode_pcts = {k: f"{100*v/total_modes:.0f}%" for k, v in mode_counts.items()} |
|
|
| log_msg = ( |
| f"Step {step}/{args.steps} | loss={avg_loss:.4f} | lr={lr:.2e} | " |
| f"tgt_T={tgt_T} ref_T={ref_T_frames} | " |
| f"{sps:.2f} steps/s | ETA {eta/60:.0f}min | modes={mode_pcts}" |
| ) |
|
|
| |
| metric = { |
| "step": step, |
| "flow_loss": round(avg_loss, 6), |
| "lr": lr, |
| "tgt_tokens": tgt_T, |
| "ref_tokens": ref_T_frames, |
| "steps_per_sec": round(sps, 3), |
| "elapsed_sec": round(elapsed, 1), |
| "eta_sec": round(eta, 1), |
| "mode_counts": dict(mode_counts), |
| "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), |
| } |
|
|
| |
| |
| total_micro_batches = args.log_every * args.grad_accum |
| if aux_enabled and aux_log_count > 0: |
| avg_total = accum_total_loss / aux_log_count |
| avg_aux1 = accum_aux1 / aux_log_count |
| avg_aux2 = accum_aux2 / aux_log_count |
| avg_aux3 = accum_aux3 / aux_log_count |
| avg_coeff1 = accum_coeff1 / aux_log_count |
| avg_coeff2 = accum_coeff2 / aux_log_count |
| avg_coeff3 = accum_coeff3 / aux_log_count |
| avg_clap_text = accum_clap_text_reward / aux_log_count |
| avg_quality = accum_quality_prob / aux_log_count |
| avg_naturalness = accum_naturalness_reward / aux_log_count |
| avg_centroid = accum_centroid_score / aux_log_count |
| avg_speaker = (accum_speaker_sim / speaker_sim_count |
| if speaker_sim_count > 0 else None) |
|
|
| log_msg += ( |
| f" | nat={avg_naturalness:.3f} cent={avg_centroid:.3f}" |
| ) |
| if avg_speaker is not None: |
| log_msg += f" spk={avg_speaker:.3f}" |
| if rejection_enabled: |
| total_scored = acceptance_count + rejection_count |
| accept_pct = 100 * acceptance_count / max(total_scored, 1) |
| log_msg += ( |
| f" | reject={rejection_count}/{total_scored}" |
| f" ({accept_pct:.0f}% accepted)" |
| f" | aux_hit={aux_log_count}/{total_micro_batches}" |
| ) |
| elif differentiable_reward: |
| log_msg += ( |
| f" | c1={avg_coeff1:.2f} c2={avg_coeff2:.2f} c3={avg_coeff3:.2f}" |
| f" | total={avg_total:.4f}" |
| f" | aux_hit={aux_log_count}/{total_micro_batches}" |
| f" | VRAM={vram_peak_mb/1024:.1f}GB" |
| ) |
| else: |
| log_msg += ( |
| f" | c1={avg_coeff1:.2f} c2={avg_coeff2:.2f} c3={avg_coeff3:.2f}" |
| f" | total={avg_total:.4f}" |
| f" | aux_hit={aux_log_count}/{total_micro_batches}" |
| ) |
|
|
| metric.update({ |
| "clap_text_reward": round(avg_clap_text, 4), |
| "quality_prob": round(avg_quality, 4), |
| "naturalness_reward": round(avg_naturalness, 4), |
| "centroid_score": round(avg_centroid, 4), |
| "aux1_loss": round(avg_aux1, 6), |
| "aux2_loss": round(avg_aux2, 6), |
| "aux3_loss": round(avg_aux3, 6), |
| "coeff1": round(avg_coeff1, 4), |
| "coeff2": round(avg_coeff2, 4), |
| "coeff3": round(avg_coeff3, 4), |
| "total_loss": round(avg_total, 6), |
| }) |
| if avg_speaker is not None: |
| metric["speaker_sim"] = round(avg_speaker, 4) |
| if differentiable_reward and vram_peak_mb > 0: |
| metric["vram_peak_mb"] = round(vram_peak_mb, 0) |
|
|
| |
| accum_total_loss = 0.0 |
| accum_aux1 = 0.0 |
| accum_aux2 = 0.0 |
| accum_aux3 = 0.0 |
| accum_coeff1 = 0.0 |
| accum_coeff2 = 0.0 |
| accum_coeff3 = 0.0 |
| accum_clap_text_reward = 0.0 |
| accum_quality_prob = 0.0 |
| accum_naturalness_reward = 0.0 |
| accum_centroid_score = 0.0 |
| accum_speaker_sim = 0.0 |
| aux_log_count = 0 |
| speaker_sim_count = 0 |
|
|
| logging.info(log_msg) |
|
|
| with open(metrics_path, "a") as f: |
| f.write(json.dumps(metric) + "\n") |
|
|
| |
| status = { |
| "step": step, |
| "total_steps": args.steps, |
| "epoch": current_epoch, |
| "total_epochs": args.epochs if args.epochs > 0 else 0, |
| "steps_per_epoch": steps_per_epoch, |
| "flow_loss": round(avg_loss, 6), |
| "best_loss": round(best_loss, 6), |
| "best_step": best_step, |
| "lr": lr, |
| "elapsed_sec": round(elapsed, 1), |
| "eta_sec": round(eta, 1), |
| "steps_per_sec": round(sps, 3), |
| "world_size": accelerator.num_processes, |
| "mode_counts": dict(mode_counts), |
| "aux_enabled": aux_enabled, |
| "aux_losses": [l for l, e in [ |
| ("naturalness", True), ("quality_mlp", use_quality_mlp), |
| ("centroid", use_centroid), ("speaker_sim", use_speaker_sim), |
| ] if e], |
| } |
| with open(os.path.join(args.output_dir, "status.json"), "w") as f: |
| json.dump(status, f, indent=2) |
|
|
| |
| if avg_loss < best_loss: |
| best_loss = avg_loss |
| old_best = os.path.join(args.output_dir, f"best_step_{best_step:05d}.safetensors") |
| best_step = step |
| new_best = os.path.join(args.output_dir, f"best_step_{best_step:05d}.safetensors") |
| unwrapped = _unwrap_model_safe(model) |
| unwrapped.save_pretrained(args.output_dir) |
| adapter = os.path.join(args.output_dir, "adapter_model.safetensors") |
| if os.path.exists(adapter): |
| shutil.copy(adapter, new_best) |
| if old_best != new_best and os.path.exists(old_best): |
| os.remove(old_best) |
| logging.info(f" New best: loss={best_loss:.4f} at step {best_step}") |
|
|
| accum_loss = 0.0 |
| rejection_count = 0 |
| acceptance_count = 0 |
|
|
| |
| if is_opt_step and step % args.save_every == 0 and is_main: |
| save_path = os.path.join(args.output_dir, f"lora_step_{step:05d}.safetensors") |
| logging.info(f"Saving: {save_path}") |
| unwrapped = _unwrap_model_safe(model) |
| unwrapped.save_pretrained(args.output_dir) |
| adapter = os.path.join(args.output_dir, "adapter_model.safetensors") |
| if os.path.exists(adapter): |
| shutil.copy(adapter, save_path) |
| if not getattr(args, 'no_save_state', False): |
| opt_state = { |
| "step": step, |
| "epoch": current_epoch, |
| "best_loss": best_loss, |
| "best_step": best_step, |
| "optimizer": optimizer.state_dict(), |
| "scheduler": scheduler.state_dict(), |
| "rng_python": random.getstate(), |
| "rng_torch": torch.random.get_rng_state(), |
| "rng_cuda": torch.cuda.get_rng_state(), |
| } |
| state_path = os.path.join(args.output_dir, f"training_state_step_{step:05d}.pt") |
| torch.save(opt_state, state_path) |
| latest_path = os.path.join(args.output_dir, "training_state.pt") |
| if os.path.islink(latest_path) or os.path.exists(latest_path): |
| os.remove(latest_path) |
| os.symlink(os.path.basename(state_path), latest_path) |
|
|
| |
| if recent_checkpoints is not None: |
| |
| current_reward = naturalness_reward_val |
| checkpoint_rewards[save_path] = current_reward |
|
|
| |
| evicted_path = None |
| if len(recent_checkpoints) == recent_checkpoints.maxlen: |
| evicted_path = recent_checkpoints[0] |
|
|
| recent_checkpoints.append(save_path) |
|
|
| if evicted_path and evicted_path in checkpoint_rewards: |
| evicted_reward = checkpoint_rewards[evicted_path] |
| |
| deque_rewards = [checkpoint_rewards.get(p, float('-inf')) |
| for p in recent_checkpoints] |
| min_deque_reward = min(deque_rewards) if deque_rewards else float('-inf') |
|
|
| if evicted_reward < min_deque_reward: |
| |
| if os.path.exists(evicted_path): |
| os.remove(evicted_path) |
| logging.info(f" Deleted checkpoint: {os.path.basename(evicted_path)} " |
| f"(reward={evicted_reward:.4f} < min_deque={min_deque_reward:.4f})") |
| |
| evicted_step = evicted_path.split("step_")[1].split(".")[0] |
| evicted_state = os.path.join( |
| args.output_dir, f"training_state_step_{evicted_step}.pt") |
| if os.path.exists(evicted_state): |
| os.remove(evicted_state) |
| del checkpoint_rewards[evicted_path] |
| else: |
| logging.info(f" Keeping older checkpoint: {os.path.basename(evicted_path)} " |
| f"(reward={evicted_reward:.4f} >= min_deque={min_deque_reward:.4f})") |
|
|
| |
| if is_opt_step and steps_per_epoch > 0 and is_main: |
| new_epoch = step // steps_per_epoch |
| if new_epoch > current_epoch: |
| current_epoch = new_epoch |
| logging.info(f"{'='*60}") |
| logging.info(f"EPOCH {current_epoch}/{args.epochs} COMPLETE (step {step})") |
| logging.info(f"{'='*60}") |
| |
| lora_path = save_training_state( |
| args.output_dir, step, current_epoch, optimizer, |
| scheduler, best_loss, best_step, model, accelerator) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| if is_main: |
| final_epoch = args.epochs if args.epochs > 0 else 0 |
| lora_path = save_training_state( |
| args.output_dir, step, final_epoch, optimizer, |
| scheduler, best_loss, best_step, model, accelerator, tag="final") |
| logging.info(f"Training complete! {step} steps in {time.time()-t0:.0f}s") |
| logging.info(f"Best loss: {best_loss:.4f} at step {best_step}") |
| |
| if args.val_samples > 0 and final_epoch > 0: |
| proc = launch_validation_async( |
| script_dir, args.output_dir, lora_path, |
| final_epoch, args) |
| if proc: |
| val_procs.append(proc) |
| |
| for proc in val_procs: |
| proc.wait() |
| logging.info(f"Validation PID {proc.pid} finished (rc={proc.returncode})") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|