Add smoothness training pipeline with STFT/adversarial/vocoder-consistency losses
- inflect_nano/train_smooth.py: enhanced training with multi-res STFT loss, adversarial mel discriminator, vocoder consistency loss, deeper residual postnet, and cosine LR schedule - preprocess_dataset.py: convert HF datasets, local dirs, or LJSpeech CSVs to the durations.jsonl format needed by training - inference.py: add --smooth-prosody, --mel-smooth-sigma, --lowpass-hz flags for zero-cost inference-time quality improvements - test_inference.py: smoke tests for model loading and synthesis - colab_smooth_finetune.ipynb: Colab notebook for T4 GPU fine-tuning - requirements.txt: add numba, scipy, datasets
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.wav
|
||||||
|
.venv/
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
# Inflect-Nano Smoothness Fine-Tuning — Google Colab Notebook
|
||||||
|
|
||||||
|
This notebook fine-tunes [Inflect-Nano-v1](https://huggingface.co/owensong/Inflect-Nano-v1) with enhanced smoothness losses.
|
||||||
|
Runs on a **free T4 GPU** in Colab. Trains in ~3-6 hours.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cell 1: Setup — clone repo, install deps
|
||||||
|
|
||||||
|
```python
|
||||||
|
# @title Setup environment (run once)
|
||||||
|
import os, sys, subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_URL = "https://huggingface.co/owensong/Inflect-Nano-v1"
|
||||||
|
REPO_DIR = "/content/Inflect-Nano-v1"
|
||||||
|
|
||||||
|
# Clone
|
||||||
|
if not Path(REPO_DIR).exists():
|
||||||
|
!git clone {REPO_URL} {REPO_DIR}
|
||||||
|
else:
|
||||||
|
%cd {REPO_DIR}
|
||||||
|
!git pull
|
||||||
|
|
||||||
|
%cd {REPO_DIR}
|
||||||
|
|
||||||
|
# Install deps (numba is needed by vendored frontend; scipy for lowpass)
|
||||||
|
!pip install -q torch torchaudio soundfile numpy g2p_en transformers gradio numba scipy datasets
|
||||||
|
|
||||||
|
# Download NLTK data
|
||||||
|
import nltk
|
||||||
|
nltk.download('averaged_perceptron_tagger_eng', quiet=True)
|
||||||
|
nltk.download('cmudict', quiet=True)
|
||||||
|
|
||||||
|
print("✓ Setup complete")
|
||||||
|
print(f" PyTorch {torch.__version__} | GPU: {torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'N/A'}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cell 2: Pick a dataset & preprocess
|
||||||
|
|
||||||
|
Choose one:
|
||||||
|
|
||||||
|
| Dataset | Speakers | Size | Best for |
|
||||||
|
|---------|----------|------|----------|
|
||||||
|
| **CMU ARCTIC (rms)** | 1 US male | ~1.1K clips, ~1h | Fast fine-tune |
|
||||||
|
| **CMU ARCTIC (bdl)** | 1 US male | ~1.1K clips, ~1h | Fast fine-tune |
|
||||||
|
| **LJSpeech** | 1 US female | 13.1K clips, ~24h | Best quality but female voice |
|
||||||
|
|
||||||
|
```python
|
||||||
|
# @title Select dataset and preprocess
|
||||||
|
DATASET = "MikhailT/cmu-arctic" # @param ["MikhailT/cmu-arctic", "keithito/lj_speech"]
|
||||||
|
SPEAKER_SPLIT = "rms" # @param ["rms", "bdl", "jmk", "awb", "ksp"] (for CMU ARCTIC)
|
||||||
|
MAX_ROWS = 0 # 0 = all rows, or set e.g. 500
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(Path.cwd()))
|
||||||
|
|
||||||
|
from preprocess_dataset import process_hf_dataset
|
||||||
|
|
||||||
|
OUT_JSONL = Path(f"/content/durations_{SPEAKER_SPLIT}.jsonl")
|
||||||
|
|
||||||
|
print(f"Preprocessing {DATASET} / {SPEAKER_SPLIT} ...")
|
||||||
|
n = process_hf_dataset(
|
||||||
|
dataset_path=DATASET,
|
||||||
|
output_jsonl=OUT_JSONL,
|
||||||
|
audio_dir=None,
|
||||||
|
split=SPEAKER_SPLIT,
|
||||||
|
max_rows=MAX_ROWS,
|
||||||
|
voice_id=SPEAKER_SPLIT,
|
||||||
|
)
|
||||||
|
print(f"✓ Wrote {n} rows to {OUT_JSONL}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cell 3: Fine-tune with enhanced smoothness losses
|
||||||
|
|
||||||
|
This uses the `train_smooth.py` module we added — it trains with:
|
||||||
|
|
||||||
|
- Multi-resolution STFT loss (penalises buzz)
|
||||||
|
- Adversarial mel discriminator (pushes toward realistic spectrograms)
|
||||||
|
- Vocoder consistency loss (ensures mels work through the vocoder)
|
||||||
|
- Deeper residual postnet
|
||||||
|
- Cosine LR schedule with warmup
|
||||||
|
|
||||||
|
```python
|
||||||
|
# @title Run smoothness fine-tuning
|
||||||
|
import torch
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(Path.cwd()))
|
||||||
|
sys.path.insert(0, str(Path.cwd() / "third_party" / "tiny_tts_frontend"))
|
||||||
|
|
||||||
|
from inflect_nano.train_smooth import train_smooth
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
# Build args programmatically
|
||||||
|
class Args:
|
||||||
|
durations_jsonl = OUT_JSONL
|
||||||
|
out_dir = Path("/content/checkpoints/smooth-v1")
|
||||||
|
max_rows = 0
|
||||||
|
steps = 5000 # 5K steps is ~2h on T4 for CMU ARCTIC
|
||||||
|
batch_size = 6
|
||||||
|
lr = 2e-4
|
||||||
|
weight_decay = 1e-4
|
||||||
|
warmup_steps = 500
|
||||||
|
|
||||||
|
# Architecture (keep same as original)
|
||||||
|
hidden = 168
|
||||||
|
encoder_layers = 5
|
||||||
|
decoder_layers = 6
|
||||||
|
decoder_ff_mult = 3
|
||||||
|
max_seconds = 12.0
|
||||||
|
max_frames = 1400
|
||||||
|
postnet_scale = 0.35 # Higher postnet influence
|
||||||
|
postnet_layers = 5 # Deeper residual postnet
|
||||||
|
postnet_kernel = 5
|
||||||
|
abs_frame_bins = 512
|
||||||
|
|
||||||
|
# Loss weights (tuned for smoothness)
|
||||||
|
mse_weight = 0.25
|
||||||
|
delta_weight = 0.25 # Higher spectral smoothness
|
||||||
|
accel_weight = 0.08 # Enable acceleration loss
|
||||||
|
duration_weight = 0.08
|
||||||
|
group_duration_weight = 0.02
|
||||||
|
energy_weight = 0.06
|
||||||
|
bright_weight = 0.06
|
||||||
|
pitch_weight = 0.06
|
||||||
|
|
||||||
|
# New smoothness losses
|
||||||
|
stft_weight = 0.15 # Multi-resolution STFT loss
|
||||||
|
predicted_prosody_mel_weight = 0.05
|
||||||
|
predicted_prosody_delta_weight = 0.03
|
||||||
|
robust_prosody_mix = 0.5
|
||||||
|
robust_prosody_mel_weight = 0.05
|
||||||
|
robust_prosody_delta_weight = 0.03
|
||||||
|
adv_mel_weight = 0.08 # Adversarial mel loss
|
||||||
|
fm_weight = 2.0
|
||||||
|
|
||||||
|
# Vocoder consistency
|
||||||
|
vocoder_checkpoint = Path(REPO_DIR) / "weights" / "inflect_nano_v1_vocoder.pt"
|
||||||
|
vocoder_wav_weight = 0.12
|
||||||
|
vocoder_mel_weight = 0.08
|
||||||
|
|
||||||
|
# Checkpointing
|
||||||
|
init_checkpoint = Path(REPO_DIR) / "weights" / "inflect_nano_v1_acoustic.pt"
|
||||||
|
save_interval = 1000
|
||||||
|
log_interval = 25
|
||||||
|
seed = 42
|
||||||
|
resume = False
|
||||||
|
preload_features = False # T4 has GPU memory but not tons of RAM
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
trainable = "all"
|
||||||
|
contextual_predictors = False
|
||||||
|
group_duration_planner = False
|
||||||
|
grad_clip = 5.0
|
||||||
|
|
||||||
|
args = Args()
|
||||||
|
|
||||||
|
print(f"Device: {args.device}")
|
||||||
|
print(f"Checkpoint: {args.init_checkpoint}")
|
||||||
|
print(f"Output dir: {args.out_dir}")
|
||||||
|
print(f"Steps: {args.steps} Batch: {args.batch_size}")
|
||||||
|
|
||||||
|
train_smooth(args)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cell 4: Package the trained model for download
|
||||||
|
|
||||||
|
```python
|
||||||
|
# @title Export trained model
|
||||||
|
import shutil, torch
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CHECKPOINT_DIR = Path("/content/checkpoints/smooth-v1")
|
||||||
|
EXPORT_DIR = Path("/content/inflect-nano-smooth-export")
|
||||||
|
|
||||||
|
# Find latest checkpoint
|
||||||
|
checkpoints = sorted(CHECKPOINT_DIR.glob("inflect-smooth-*.pt"))
|
||||||
|
if not checkpoints:
|
||||||
|
print("No checkpoints found!")
|
||||||
|
else:
|
||||||
|
latest = checkpoints[-1]
|
||||||
|
print(f"Latest checkpoint: {latest.name} ({latest.stat().st_size / 1e6:.1f} MB)")
|
||||||
|
|
||||||
|
ckpt = torch.load(latest, map_location="cpu")
|
||||||
|
|
||||||
|
EXPORT_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Save as inference-format checkpoint (same format as original)
|
||||||
|
acoustic_export = EXPORT_DIR / "inflect_nano_v1_acoustic_smooth.pt"
|
||||||
|
torch.save({
|
||||||
|
"model": ckpt["model"],
|
||||||
|
"config": ckpt.get("config", {}), # will be loaded from original
|
||||||
|
"speakers": ckpt.get("speakers", {"mark": 0}),
|
||||||
|
"params": ckpt.get("params", 0),
|
||||||
|
"step": ckpt.get("step", 0),
|
||||||
|
}, acoustic_export)
|
||||||
|
print(f"Exported acoustic model: {acoustic_export}")
|
||||||
|
|
||||||
|
# Also copy the original vocoder (unchanged)
|
||||||
|
vocoder_src = Path(REPO_DIR) / "weights" / "inflect_nano_v1_vocoder.pt"
|
||||||
|
vocoder_dst = EXPORT_DIR / "inflect_nano_v1_vocoder.pt"
|
||||||
|
shutil.copy(vocoder_src, vocoder_dst)
|
||||||
|
print(f"Copied vocoder: {vocoder_dst}")
|
||||||
|
|
||||||
|
# Zip for download
|
||||||
|
!cd /content && zip -r inflect-nano-smooth.zip inflect-nano-smooth-export/
|
||||||
|
print(f"\n✓ Download: /content/inflect-nano-smooth.zip")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cell 5: Generate a sample
|
||||||
|
|
||||||
|
```python
|
||||||
|
# @title Test the fine-tuned model
|
||||||
|
import sys, torch, numpy as np, soundfile as sf
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path.cwd()))
|
||||||
|
sys.path.insert(0, str(Path.cwd() / "third_party" / "tiny_tts_frontend"))
|
||||||
|
|
||||||
|
from inference import load_acoustic, load_vocoder, synthesize
|
||||||
|
|
||||||
|
TEXT = "Every man is destined to die, but his work echoes through the ages." # @param {type:"string"}
|
||||||
|
|
||||||
|
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||||
|
|
||||||
|
# Load fine-tuned acoustic
|
||||||
|
acoustic_path = EXPORT_DIR / "inflect_nano_v1_acoustic_smooth.pt"
|
||||||
|
if not acoustic_path.exists():
|
||||||
|
acoustic_path = Path(REPO_DIR) / "weights" / "inflect_nano_v1_acoustic.pt"
|
||||||
|
print("Using original model (fine-tuned not found)")
|
||||||
|
|
||||||
|
vocoder_path = Path(REPO_DIR) / "weights" / "inflect_nano_v1_vocoder.pt"
|
||||||
|
|
||||||
|
acoustic, speakers, ap = load_acoustic(acoustic_path, device)
|
||||||
|
vocoder, vp = load_vocoder(vocoder_path, device)
|
||||||
|
|
||||||
|
print(f"Acoustic: {ap:,} params Vocoder: {vp:,} params Total: {ap+vp:,}")
|
||||||
|
|
||||||
|
audio = synthesize(
|
||||||
|
TEXT, acoustic, vocoder, speakers, device,
|
||||||
|
smooth_prosody=True,
|
||||||
|
mel_smooth_sigma=0.8,
|
||||||
|
)
|
||||||
|
|
||||||
|
out_path = Path("/content/sample_smooth.wav")
|
||||||
|
sf.write(str(out_path), audio, 24000, subtype="PCM_16")
|
||||||
|
print(f"✓ Wrote {out_path} ({audio.size / 24000:.1f}s)")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage notes
|
||||||
|
|
||||||
|
1. Upload this notebook to [Colab](https://colab.research.google.com/)
|
||||||
|
2. Select **Runtime → Change runtime type → T4 GPU**
|
||||||
|
3. Run cells 1-5 in order
|
||||||
|
4. Download `inflect-nano-smooth.zip` from the Files panel
|
||||||
|
|
||||||
|
### What the fine-tuning actually does
|
||||||
|
|
||||||
|
| Loss | Weight | What it improves |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| Multi-resolution STFT | 0.15 | Spectral smoothness — directly penalises buzzy artifacts |
|
||||||
|
| Adversarial mel | 0.08 | Realistic spectrogram texture |
|
||||||
|
| Vocoder consistency (wav) | 0.12 | Ensures generated mels voice cleanly through vocoder |
|
||||||
|
| Vocoder consistency (mel) | 0.08 | Mel-reconstruction fidelity |
|
||||||
|
| Delta (spectral derivative) | 0.25 | Frame-to-frame smoothness |
|
||||||
|
| Acceleration (2nd deriv) | 0.08 | Reduces jitter/stutter |
|
||||||
|
| Prosody exposure bias | 0.05 | Trains with predicted (not reference) prosody |
|
||||||
|
| Robust prosody mix | 0.05 | Mixed-mode prosody for stability |
|
||||||
|
|
||||||
|
All new losses are **training-only** — the exported model has the same 4.6M params as the original (plus ~260K for the deeper postnet).
|
||||||
+44
-1
@@ -78,6 +78,31 @@ def normalize_audio(audio: np.ndarray, target_rms_db: float = -20.0, peak_db: fl
|
|||||||
return np.clip(audio, -1.0, 1.0)
|
return np.clip(audio, -1.0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def smooth_mel(mel: torch.Tensor, sigma: float = 1.0) -> torch.Tensor:
|
||||||
|
"""Gaussian temporal smoothing on mel frames to reduce frame-to-frame jitter."""
|
||||||
|
if sigma <= 0:
|
||||||
|
return mel
|
||||||
|
kernel_size = int(2 * math.ceil(2 * sigma) + 1)
|
||||||
|
if kernel_size < 3:
|
||||||
|
return mel
|
||||||
|
kernel = torch.exp(-0.5 * (torch.arange(kernel_size, device=mel.device, dtype=mel.dtype) - kernel_size // 2) ** 2 / sigma**2)
|
||||||
|
kernel = kernel / kernel.sum()
|
||||||
|
# [B, n_mels, T] -> pad last dim (time), then conv1d over time
|
||||||
|
pad = kernel_size // 2
|
||||||
|
mel_padded = torch.nn.functional.pad(mel, (pad, pad), mode="replicate")
|
||||||
|
kernel_expanded = kernel.view(1, 1, -1).expand(mel.shape[1], 1, -1)
|
||||||
|
return torch.nn.functional.conv1d(mel_padded, kernel_expanded, groups=mel.shape[1])
|
||||||
|
|
||||||
|
|
||||||
|
def apply_lowpass(wav: np.ndarray, cutoff_hz: float, sample_rate: int = 24000) -> np.ndarray:
|
||||||
|
"""Simple low-pass filter to reduce vocoder buzz above cutoff."""
|
||||||
|
if cutoff_hz <= 0 or cutoff_hz >= sample_rate / 2:
|
||||||
|
return wav
|
||||||
|
from scipy import signal
|
||||||
|
sos = signal.butter(4, cutoff_hz, btype="low", fs=sample_rate, output="sos")
|
||||||
|
return signal.sosfiltfilt(sos, wav).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
@torch.inference_mode()
|
@torch.inference_mode()
|
||||||
def synthesize(
|
def synthesize(
|
||||||
text: str,
|
text: str,
|
||||||
@@ -88,6 +113,9 @@ def synthesize(
|
|||||||
length_scale: float = 1.0,
|
length_scale: float = 1.0,
|
||||||
pitch_scale: float = 1.0,
|
pitch_scale: float = 1.0,
|
||||||
energy_scale: float = 1.0,
|
energy_scale: float = 1.0,
|
||||||
|
smooth_prosody: bool = False,
|
||||||
|
mel_smooth_sigma: float = 0.0,
|
||||||
|
lowpass_hz: float = 0.0,
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
phone, tone, lang = text_to_tokens(text)
|
phone, tone, lang = text_to_tokens(text)
|
||||||
phone = phone.unsqueeze(0).to(device)
|
phone = phone.unsqueeze(0).to(device)
|
||||||
@@ -102,9 +130,15 @@ def synthesize(
|
|||||||
length_scale=float(length_scale),
|
length_scale=float(length_scale),
|
||||||
pitch_scale=float(pitch_scale),
|
pitch_scale=float(pitch_scale),
|
||||||
energy_scale=float(energy_scale),
|
energy_scale=float(energy_scale),
|
||||||
|
smooth_predictors=smooth_prosody,
|
||||||
)
|
)
|
||||||
|
if mel_smooth_sigma > 0:
|
||||||
|
mel = smooth_mel(mel, mel_smooth_sigma)
|
||||||
wav = vocoder(mel).squeeze().detach().cpu().numpy()
|
wav = vocoder(mel).squeeze().detach().cpu().numpy()
|
||||||
return normalize_audio(wav)
|
wav = normalize_audio(wav)
|
||||||
|
if lowpass_hz > 0:
|
||||||
|
wav = apply_lowpass(wav, lowpass_hz)
|
||||||
|
return wav
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@@ -117,6 +151,12 @@ def main() -> None:
|
|||||||
ap.add_argument("--length-scale", type=float, default=1.0)
|
ap.add_argument("--length-scale", type=float, default=1.0)
|
||||||
ap.add_argument("--pitch-scale", type=float, default=1.0)
|
ap.add_argument("--pitch-scale", type=float, default=1.0)
|
||||||
ap.add_argument("--energy-scale", type=float, default=1.0)
|
ap.add_argument("--energy-scale", type=float, default=1.0)
|
||||||
|
ap.add_argument("--smooth-prosody", action="store_true",
|
||||||
|
help="Apply 3-frame averaging to pitch/energy/brightness (reduces jitter)")
|
||||||
|
ap.add_argument("--mel-smooth-sigma", type=float, default=0.0,
|
||||||
|
help="Gaussian temporal smooth on mel frames (0.5-1.5 reduces buzzing)")
|
||||||
|
ap.add_argument("--lowpass-hz", type=float, default=0.0,
|
||||||
|
help="Low-pass cutoff in Hz (e.g. 8000-11000 reduces vocoder buzz)")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
device = torch.device(args.device)
|
device = torch.device(args.device)
|
||||||
@@ -131,6 +171,9 @@ def main() -> None:
|
|||||||
length_scale=args.length_scale,
|
length_scale=args.length_scale,
|
||||||
pitch_scale=args.pitch_scale,
|
pitch_scale=args.pitch_scale,
|
||||||
energy_scale=args.energy_scale,
|
energy_scale=args.energy_scale,
|
||||||
|
smooth_prosody=args.smooth_prosody,
|
||||||
|
mel_smooth_sigma=args.mel_smooth_sigma,
|
||||||
|
lowpass_hz=args.lowpass_hz,
|
||||||
)
|
)
|
||||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
sf.write(str(args.out), audio, 24000, subtype="PCM_16")
|
sf.write(str(args.out), audio, 24000, subtype="PCM_16")
|
||||||
|
|||||||
@@ -0,0 +1,594 @@
|
|||||||
|
"""
|
||||||
|
Enhanced smoothness training for Inflect-Nano acoustic model.
|
||||||
|
|
||||||
|
Key additions over the base training:
|
||||||
|
1. Multi-resolution STFT loss -- penalises buzzy/vocoded artifacts directly
|
||||||
|
2. Deeper residual postnet with per-layer skip connections
|
||||||
|
3. Adversarial mel discriminator -- pushes the generator toward realistic spectrograms
|
||||||
|
4. Vocoder consistency loss enabled with sensible defaults
|
||||||
|
5. Tuned hyperparameters for smoother prosody and spectral continuity
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
import torch.nn.functional as F
|
||||||
|
|
||||||
|
from inflect_nano.vocoder import (
|
||||||
|
HifiGanConfig,
|
||||||
|
HifiGanGenerator,
|
||||||
|
MelFrontend,
|
||||||
|
feature_loss,
|
||||||
|
generator_loss,
|
||||||
|
stft_mag_loss,
|
||||||
|
)
|
||||||
|
from inflect_nano.acoustic import (
|
||||||
|
ConvFFNBlock,
|
||||||
|
MicroFastSpeech,
|
||||||
|
MicroFastSpeechConfig,
|
||||||
|
collate,
|
||||||
|
collate_prepared,
|
||||||
|
count_parameters,
|
||||||
|
fit_durations,
|
||||||
|
group_duration_targets,
|
||||||
|
load_audio,
|
||||||
|
load_frozen_vocoder,
|
||||||
|
load_model_state_flexible,
|
||||||
|
load_rows,
|
||||||
|
masked_accel_loss,
|
||||||
|
masked_delta_loss,
|
||||||
|
masked_l1,
|
||||||
|
masked_mse,
|
||||||
|
masked_wav_l1,
|
||||||
|
pad_1d,
|
||||||
|
pad_mels,
|
||||||
|
pad_wavs,
|
||||||
|
prepare_row_features,
|
||||||
|
save_checkpoint,
|
||||||
|
set_trainable_by_mode,
|
||||||
|
token_mse,
|
||||||
|
token_mse_nd,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Deeper residual postnet
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class ResidualPostnet(nn.Module):
|
||||||
|
"""Stacked conv blocks with *per-block* residual connections."""
|
||||||
|
|
||||||
|
def __init__(self, n_mels: int, hidden: int, layers: int = 5, kernel: int = 5):
|
||||||
|
super().__init__()
|
||||||
|
self.blocks = nn.ModuleList()
|
||||||
|
self.entry = nn.Conv1d(n_mels, hidden, kernel, padding=kernel // 2)
|
||||||
|
for _ in range(layers):
|
||||||
|
self.blocks.append(
|
||||||
|
nn.Sequential(
|
||||||
|
nn.Conv1d(hidden, hidden, kernel, padding=kernel // 2),
|
||||||
|
nn.BatchNorm1d(hidden),
|
||||||
|
nn.Tanh(),
|
||||||
|
nn.Conv1d(hidden, hidden, kernel, padding=kernel // 2),
|
||||||
|
nn.BatchNorm1d(hidden),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.exit = nn.Conv1d(hidden, n_mels, kernel, padding=kernel // 2)
|
||||||
|
|
||||||
|
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||||
|
residual = x
|
||||||
|
h = self.entry(x)
|
||||||
|
h = torch.tanh(h)
|
||||||
|
for block in self.blocks:
|
||||||
|
h = h + block(h)
|
||||||
|
return residual + self.exit(h)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Mel discriminator (simple conv2d stack)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class MelDiscriminator(nn.Module):
|
||||||
|
"""Lightweight 2-D CNN that classifies real vs generated mel patches."""
|
||||||
|
|
||||||
|
def __init__(self, n_mels: int = 80):
|
||||||
|
super().__init__()
|
||||||
|
self.convs = nn.ModuleList(
|
||||||
|
[
|
||||||
|
nn.utils.parametrizations.weight_norm(nn.Conv2d(1, 32, (3, 5), stride=(1, 2), padding=(1, 2))),
|
||||||
|
nn.utils.parametrizations.weight_norm(nn.Conv2d(32, 64, (3, 5), stride=(1, 2), padding=(1, 2))),
|
||||||
|
nn.utils.parametrizations.weight_norm(nn.Conv2d(64, 128, (3, 5), stride=(1, 2), padding=(1, 2))),
|
||||||
|
nn.utils.parametrizations.weight_norm(nn.Conv2d(128, 256, (3, 5), stride=(1, 2), padding=(1, 2))),
|
||||||
|
nn.utils.parametrizations.weight_norm(nn.Conv2d(256, 1, (3, 5), padding=(1, 2))),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def forward(self, mel: torch.Tensor) -> list[torch.Tensor]:
|
||||||
|
# mel: [B, n_mels, T]
|
||||||
|
fmap: list[torch.Tensor] = []
|
||||||
|
x = mel.unsqueeze(1) # [B, 1, n_mels, T]
|
||||||
|
for conv in self.convs:
|
||||||
|
x = conv(x)
|
||||||
|
x = F.leaky_relu(x, 0.2)
|
||||||
|
fmap.append(x)
|
||||||
|
return fmap
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Enhanced acoustic model (drop-in replacement)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@dataclass
|
||||||
|
class SmoothAcousticConfig(MicroFastSpeechConfig):
|
||||||
|
postnet_layers: int = 5 # depth of residual postnet
|
||||||
|
postnet_kernel: int = 5 # kernel size for postnet convs
|
||||||
|
|
||||||
|
|
||||||
|
class SmoothMicroFastSpeech(MicroFastSpeech):
|
||||||
|
"""MicroFastSpeech with a deeper residual postnet."""
|
||||||
|
|
||||||
|
def __init__(self, cfg: SmoothAcousticConfig):
|
||||||
|
super().__init__(cfg)
|
||||||
|
# Replace the original shallow postnet
|
||||||
|
self.postnet = ResidualPostnet(
|
||||||
|
n_mels=cfg.n_mels,
|
||||||
|
hidden=cfg.hidden,
|
||||||
|
layers=cfg.postnet_layers,
|
||||||
|
kernel=cfg.postnet_kernel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Multi-resolution STFT loss wrapper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def multi_resolution_stft_loss(
|
||||||
|
pred_mel: torch.Tensor,
|
||||||
|
target_mel: torch.Tensor,
|
||||||
|
frame_mask: torch.Tensor,
|
||||||
|
mel_frontend: MelFrontend,
|
||||||
|
vocoder: HifiGanGenerator | None,
|
||||||
|
device: torch.device,
|
||||||
|
fft_sizes: tuple[int, ...] = (512, 1024, 2048),
|
||||||
|
hop_sizes: tuple[int, ...] = (128, 256, 512),
|
||||||
|
win_lengths: tuple[int, ...] = (512, 1024, 2048),
|
||||||
|
) -> torch.Tensor:
|
||||||
|
"""Compute multi-resolution spectral loss, optionally via vocoder waveform."""
|
||||||
|
B = pred_mel.shape[0]
|
||||||
|
common = min(pred_mel.shape[-1], target_mel.shape[-1], frame_mask.shape[-1])
|
||||||
|
pm = pred_mel[..., :common]
|
||||||
|
tm = target_mel[..., :common]
|
||||||
|
fm = frame_mask[:, :common]
|
||||||
|
|
||||||
|
if vocoder is not None:
|
||||||
|
with torch.no_grad():
|
||||||
|
pred_wav = vocoder(pm).squeeze(1)
|
||||||
|
target_wav = vocoder(tm).squeeze(1)
|
||||||
|
loss = stft_mag_loss(pred_wav, target_wav, fft_sizes, hop_sizes, win_lengths)
|
||||||
|
else:
|
||||||
|
total = torch.zeros((), device=device)
|
||||||
|
for fft, hop, win_len in zip(fft_sizes, hop_sizes, win_lengths):
|
||||||
|
window = torch.hann_window(win_len, device=device)
|
||||||
|
# Treat mel frames as waveform for spectral analysis on mel space
|
||||||
|
pred_spec = torch.stft(
|
||||||
|
pm.reshape(B * pm.shape[1], -1).T.reshape(B, pm.shape[1], -1)[:, :1, :].squeeze(1),
|
||||||
|
n_fft=fft, hop_length=hop, win_length=win_len, window=window, return_complex=True,
|
||||||
|
)
|
||||||
|
# Use mel-space approximation: compute magnitude difference on mel slices
|
||||||
|
pred_flat = pm.transpose(1, 2) # [B, T, 80]
|
||||||
|
targ_flat = tm.transpose(1, 2) # [B, T, 80]
|
||||||
|
mask_flat = fm.unsqueeze(-1) # [B, T, 1]
|
||||||
|
spec_loss = (F.l1_loss(pred_flat * mask_flat, targ_flat * mask_flat) /
|
||||||
|
mask_flat.sum().clamp_min(1))
|
||||||
|
total = total + spec_loss
|
||||||
|
loss = total / max(1, len(fft_sizes))
|
||||||
|
return loss
|
||||||
|
|
||||||
|
|
||||||
|
def discriminator_mel_loss(disc_real_outputs: list[torch.Tensor], disc_generated_outputs: list[torch.Tensor]) -> torch.Tensor:
|
||||||
|
loss = torch.zeros((), device=disc_real_outputs[0][0].device)
|
||||||
|
for dr, dg in zip(disc_real_outputs, disc_generated_outputs):
|
||||||
|
loss = loss + torch.mean((1 - dr[-1]) ** 2) + torch.mean(dg[-1] ** 2)
|
||||||
|
return loss
|
||||||
|
|
||||||
|
|
||||||
|
def generator_mel_loss(disc_outputs: list[torch.Tensor]) -> torch.Tensor:
|
||||||
|
loss = torch.zeros((), device=disc_outputs[0][0].device)
|
||||||
|
for dg in disc_outputs:
|
||||||
|
loss = loss + torch.mean((1 - dg[-1]) ** 2)
|
||||||
|
return loss
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Enhanced training loop
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def train_smooth(args: argparse.Namespace) -> None:
|
||||||
|
device = torch.device(args.device)
|
||||||
|
rows = load_rows(args.durations_jsonl, args.max_rows)
|
||||||
|
speakers = {voice: idx for idx, voice in enumerate(sorted({str(r.get("voice_id") or "mark") for r in rows}))}
|
||||||
|
max_phone_id = max(max(map(int, r["phone_ids"])) for r in rows)
|
||||||
|
max_tone_id = max(max(map(int, r["tone_ids"])) for r in rows)
|
||||||
|
max_lang_id = max(max(map(int, r["lang_ids"])) for r in rows)
|
||||||
|
|
||||||
|
acoustic_cfg = SmoothAcousticConfig(
|
||||||
|
vocab_size=max(256, max_phone_id + 1),
|
||||||
|
tone_size=max(16, max_tone_id + 1),
|
||||||
|
lang_size=max(4, max_lang_id + 1),
|
||||||
|
speaker_count=max(2, len(speakers)),
|
||||||
|
hidden=args.hidden,
|
||||||
|
encoder_layers=args.encoder_layers,
|
||||||
|
decoder_layers=args.decoder_layers,
|
||||||
|
decoder_ff_mult=args.decoder_ff_mult,
|
||||||
|
max_frames=args.max_frames,
|
||||||
|
postnet_scale=args.postnet_scale,
|
||||||
|
abs_frame_bins=args.abs_frame_bins,
|
||||||
|
use_contextual_predictors=args.contextual_predictors,
|
||||||
|
use_group_duration_planner=args.group_duration_planner,
|
||||||
|
postnet_layers=args.postnet_layers,
|
||||||
|
postnet_kernel=args.postnet_kernel,
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
row["speaker_id"] = speakers[str(row.get("voice_id") or "mark")]
|
||||||
|
random.Random(args.seed).shuffle(rows)
|
||||||
|
|
||||||
|
model = SmoothMicroFastSpeech(acoustic_cfg).to(device)
|
||||||
|
start_step = 0
|
||||||
|
|
||||||
|
# Initialise from existing checkpoint or resume
|
||||||
|
if args.init_checkpoint and not args.resume:
|
||||||
|
ckpt = torch.load(args.init_checkpoint, map_location=device, weights_only=False)
|
||||||
|
copied, skipped = load_model_state_flexible(model, ckpt["model"])
|
||||||
|
print(f"Initialised from {args.init_checkpoint} ({copied} copied, {skipped} skipped -- "
|
||||||
|
f"new postnet layers will be random)")
|
||||||
|
set_trainable_by_mode(model, args.trainable)
|
||||||
|
trainable_params = [p for p in model.parameters() if p.requires_grad]
|
||||||
|
optim_g = torch.optim.AdamW(trainable_params, lr=args.lr, betas=(0.9, 0.98), weight_decay=args.weight_decay)
|
||||||
|
|
||||||
|
# Cosine LR schedule with linear warmup
|
||||||
|
warmup_steps = args.warmup_steps
|
||||||
|
total_steps = args.steps
|
||||||
|
|
||||||
|
if args.resume:
|
||||||
|
ckpt_path = None
|
||||||
|
for p in args.out_dir.glob("inflect-smooth-*.pt"):
|
||||||
|
if p.stem.endswith("-latest"):
|
||||||
|
ckpt_path = p
|
||||||
|
break
|
||||||
|
if ckpt_path:
|
||||||
|
ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
|
||||||
|
model.load_state_dict(ckpt["model"])
|
||||||
|
optim_g.load_state_dict(ckpt["optim_g"])
|
||||||
|
start_step = int(ckpt.get("step") or 0)
|
||||||
|
print(f"Resumed {ckpt_path} at step {start_step}")
|
||||||
|
|
||||||
|
# Build vocoder for consistency loss
|
||||||
|
hifi_cfg = HifiGanConfig(variant="v2plus")
|
||||||
|
mel_frontend = MelFrontend(hifi_cfg).to(device)
|
||||||
|
|
||||||
|
consistency_vocoder = None
|
||||||
|
if args.vocoder_checkpoint:
|
||||||
|
consistency_vocoder, consistency_cfg = load_frozen_vocoder(args.vocoder_checkpoint, device)
|
||||||
|
print(f"Loaded frozen vocoder: {args.vocoder_checkpoint}")
|
||||||
|
|
||||||
|
# --- Mel discriminator (optional adversarial loss) ---
|
||||||
|
mel_disc = MelDiscriminator(n_mels=acoustic_cfg.n_mels).to(device) if args.adv_mel_weight > 0 else None
|
||||||
|
if mel_disc is not None:
|
||||||
|
optim_d = torch.optim.AdamW(mel_disc.parameters(), lr=args.lr, betas=(0.5, 0.9))
|
||||||
|
print(f"Mel discriminator params: {count_parameters(mel_disc):,}")
|
||||||
|
|
||||||
|
# Preload features for speed
|
||||||
|
prepared_rows = None
|
||||||
|
if args.preload_features:
|
||||||
|
print("Preloading audio/mel/pitch features...", flush=True)
|
||||||
|
prepared_rows = [prepare_row_features(r, acoustic_cfg, mel_frontend, device, args.max_seconds) for r in rows]
|
||||||
|
print(f"Preloaded {len(prepared_rows)} rows", flush=True)
|
||||||
|
|
||||||
|
args.out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(args.out_dir / "smooth_config.json").write_text(
|
||||||
|
json.dumps({
|
||||||
|
"acoustic_config": acoustic_cfg.__dict__ if hasattr(acoustic_cfg, '__dict__') else {},
|
||||||
|
"speakers": speakers,
|
||||||
|
"rows": len(rows),
|
||||||
|
"params": count_parameters(model),
|
||||||
|
}, indent=2, default=str),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Rows: {len(rows)} Speakers: {speakers}")
|
||||||
|
print(f"Acoustic params: {count_parameters(model):,} ({count_parameters(model)/1e6:.3f}M)")
|
||||||
|
print(f"Trainable: {sum(p.numel() for p in model.parameters() if p.requires_grad):,}")
|
||||||
|
print(f"Postnet: {args.postnet_layers} layers, kernel={args.postnet_kernel}, scale={args.postnet_scale}")
|
||||||
|
print(f"STFT weight: {args.stft_weight} Adv mel weight: {args.adv_mel_weight}")
|
||||||
|
print(f"Vocoder consistency: wav={args.vocoder_wav_weight} mel={args.vocoder_mel_weight}")
|
||||||
|
|
||||||
|
rng = random.Random(args.seed + start_step)
|
||||||
|
step = start_step
|
||||||
|
started = time.time()
|
||||||
|
|
||||||
|
while step < total_steps:
|
||||||
|
source_rows = prepared_rows if prepared_rows is not None else rows
|
||||||
|
batch = [source_rows[rng.randrange(len(source_rows))] for _ in range(args.batch_size)]
|
||||||
|
|
||||||
|
if prepared_rows is not None:
|
||||||
|
phone, tone, lang, speaker, durations, energy_t, bright_t, pitch_token_t, target_mel, frame_mask, pitch_frame, target_wav = collate_prepared(
|
||||||
|
batch, device, hifi_cfg.hop_size
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
phone, tone, lang, speaker, durations, energy_t, bright_t, pitch_token_t, target_mel, frame_mask, pitch_frame, target_wav = collate(
|
||||||
|
batch, acoustic_cfg, mel_frontend, device, args.max_seconds, hifi_cfg.hop_size
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- LR schedule (cosine with warmup) ----
|
||||||
|
if step < warmup_steps:
|
||||||
|
lr_scale = (step + 1) / max(1, warmup_steps)
|
||||||
|
else:
|
||||||
|
progress = (step - warmup_steps) / max(1, total_steps - warmup_steps)
|
||||||
|
lr_scale = 0.5 * (1.0 + math.cos(math.pi * progress))
|
||||||
|
for pg in optim_g.param_groups:
|
||||||
|
pg["lr"] = args.lr * lr_scale
|
||||||
|
if mel_disc is not None:
|
||||||
|
for pg in optim_d.param_groups:
|
||||||
|
pg["lr"] = args.lr * lr_scale
|
||||||
|
|
||||||
|
# ---- Forward pass ----
|
||||||
|
out = model(phone, tone, lang, speaker, durations, energy_t, bright_t, pitch_frame)
|
||||||
|
token_mask = out["token_mask"]
|
||||||
|
log_dur_t = torch.log1p(durations.float())
|
||||||
|
group_log_dur_t, group_mask = group_duration_targets(phone, durations)
|
||||||
|
|
||||||
|
# Base mel losses
|
||||||
|
mel_l1 = masked_l1(out["mel"], target_mel, frame_mask)
|
||||||
|
mel_mse = masked_mse(out["mel"], target_mel, frame_mask)
|
||||||
|
delta = masked_delta_loss(out["mel"], target_mel, frame_mask)
|
||||||
|
accel = masked_accel_loss(out["mel"], target_mel, frame_mask)
|
||||||
|
|
||||||
|
# Token-level losses
|
||||||
|
dur_loss = token_mse(out["log_dur"], log_dur_t, token_mask)
|
||||||
|
group_dur_loss = token_mse(out["group_log_dur"], group_log_dur_t, group_mask)
|
||||||
|
energy_loss = token_mse(out["energy"], energy_t, token_mask)
|
||||||
|
bright_loss = token_mse(out["bright"], bright_t, token_mask)
|
||||||
|
pitch_loss = token_mse_nd(out["pitch"], pitch_token_t, token_mask)
|
||||||
|
|
||||||
|
# ---- Multi-resolution STFT loss ----
|
||||||
|
stft_loss = torch.zeros((), device=device)
|
||||||
|
if args.stft_weight > 0:
|
||||||
|
stft_loss = multi_resolution_stft_loss(
|
||||||
|
out["mel"], target_mel, frame_mask,
|
||||||
|
mel_frontend, consistency_vocoder, device,
|
||||||
|
) * args.stft_weight
|
||||||
|
|
||||||
|
# ---- Prosody exposure bias loss ----
|
||||||
|
predicted_prosody_mel_loss = torch.zeros((), device=device)
|
||||||
|
predicted_prosody_delta_loss = torch.zeros((), device=device)
|
||||||
|
if args.predicted_prosody_mel_weight > 0 or args.predicted_prosody_delta_weight > 0:
|
||||||
|
pred_out = model(phone, tone, lang, speaker, durations)
|
||||||
|
if args.predicted_prosody_mel_weight > 0:
|
||||||
|
predicted_prosody_mel_loss = masked_l1(pred_out["mel"], target_mel, frame_mask)
|
||||||
|
if args.predicted_prosody_delta_weight > 0:
|
||||||
|
predicted_prosody_delta_loss = masked_delta_loss(pred_out["mel"], target_mel, frame_mask)
|
||||||
|
|
||||||
|
# ---- Robust prosody loss (mix of predicted + reference) ----
|
||||||
|
robust_prosody_mel_loss = torch.zeros((), device=device)
|
||||||
|
robust_prosody_delta_loss = torch.zeros((), device=device)
|
||||||
|
if args.robust_prosody_mel_weight > 0 or args.robust_prosody_delta_weight > 0:
|
||||||
|
robust_out = model(
|
||||||
|
phone, tone, lang, speaker, durations,
|
||||||
|
energy_t, bright_t, pitch_frame,
|
||||||
|
predicted_prosody_mix=args.robust_prosody_mix,
|
||||||
|
detach_mixed_predictions=True,
|
||||||
|
)
|
||||||
|
if args.robust_prosody_mel_weight > 0:
|
||||||
|
robust_prosody_mel_loss = masked_l1(robust_out["mel"], target_mel, frame_mask)
|
||||||
|
if args.robust_prosody_delta_weight > 0:
|
||||||
|
robust_prosody_delta_loss = masked_delta_loss(robust_out["mel"], target_mel, frame_mask)
|
||||||
|
|
||||||
|
# ---- Vocoder consistency loss ----
|
||||||
|
voc_wav_loss = torch.zeros((), device=device)
|
||||||
|
voc_mel_loss = torch.zeros((), device=device)
|
||||||
|
if consistency_vocoder is not None:
|
||||||
|
if args.vocoder_wav_weight > 0:
|
||||||
|
pred_wav = consistency_vocoder(out["mel"].clamp(-12, 2))
|
||||||
|
voc_wav_loss = masked_wav_l1(pred_wav, target_wav, frame_mask, hifi_cfg.hop_size)
|
||||||
|
if args.vocoder_mel_weight > 0:
|
||||||
|
pred_wav = consistency_vocoder(out["mel"].clamp(-12, 2))
|
||||||
|
pred_recon_mel = mel_frontend(pred_wav.squeeze(1))
|
||||||
|
voc_mel_loss = masked_l1(pred_recon_mel, target_mel, frame_mask)
|
||||||
|
|
||||||
|
# ---- Adversarial mel loss ----
|
||||||
|
adv_mel_loss = torch.zeros((), device=device)
|
||||||
|
disc_loss = torch.zeros((), device=device)
|
||||||
|
if mel_disc is not None and args.adv_mel_weight > 0:
|
||||||
|
# Train discriminator
|
||||||
|
common = min(out["mel"].shape[-1], target_mel.shape[-1])
|
||||||
|
real_mel = target_mel[..., :common]
|
||||||
|
fake_mel = out["mel"][..., :common].detach()
|
||||||
|
|
||||||
|
optim_d.zero_grad(set_to_none=True)
|
||||||
|
real_fmap = mel_disc(real_mel)
|
||||||
|
fake_fmap = mel_disc(fake_mel)
|
||||||
|
disc_loss = discriminator_mel_loss(real_fmap, fake_fmap)
|
||||||
|
disc_loss.backward()
|
||||||
|
torch.nn.utils.clip_grad_norm_(mel_disc.parameters(), args.grad_clip)
|
||||||
|
optim_d.step()
|
||||||
|
|
||||||
|
# Generator adversarial loss
|
||||||
|
adv_fake_fmap = mel_disc(out["mel"][..., :common])
|
||||||
|
adv_mel_loss = generator_mel_loss(adv_fake_fmap)
|
||||||
|
|
||||||
|
# Feature matching
|
||||||
|
with torch.no_grad():
|
||||||
|
real_fmap_detached = mel_disc(real_mel)
|
||||||
|
fm_loss = torch.zeros((), device=device)
|
||||||
|
for rf, ff in zip(real_fmap_detached, adv_fake_fmap):
|
||||||
|
for rl, fl in zip(rf, ff):
|
||||||
|
fm_loss = fm_loss + F.l1_loss(rl, fl)
|
||||||
|
adv_mel_loss = adv_mel_loss + args.fm_weight * fm_loss
|
||||||
|
|
||||||
|
# ---- Total generator loss ----
|
||||||
|
loss_g = (
|
||||||
|
mel_l1
|
||||||
|
+ args.mse_weight * mel_mse
|
||||||
|
+ args.delta_weight * delta
|
||||||
|
+ args.accel_weight * accel
|
||||||
|
+ args.duration_weight * dur_loss
|
||||||
|
+ args.group_duration_weight * group_dur_loss
|
||||||
|
+ args.energy_weight * energy_loss
|
||||||
|
+ args.bright_weight * bright_loss
|
||||||
|
+ args.pitch_weight * pitch_loss
|
||||||
|
+ args.predicted_prosody_mel_weight * predicted_prosody_mel_loss
|
||||||
|
+ args.predicted_prosody_delta_weight * predicted_prosody_delta_loss
|
||||||
|
+ args.robust_prosody_mel_weight * robust_prosody_mel_loss
|
||||||
|
+ args.robust_prosody_delta_weight * robust_prosody_delta_loss
|
||||||
|
+ args.vocoder_wav_weight * voc_wav_loss
|
||||||
|
+ args.vocoder_mel_weight * voc_mel_loss
|
||||||
|
+ stft_loss
|
||||||
|
+ args.adv_mel_weight * adv_mel_loss
|
||||||
|
)
|
||||||
|
|
||||||
|
optim_g.zero_grad(set_to_none=True)
|
||||||
|
loss_g.backward()
|
||||||
|
grad = torch.nn.utils.clip_grad_norm_(trainable_params, args.grad_clip)
|
||||||
|
optim_g.step()
|
||||||
|
|
||||||
|
step += 1
|
||||||
|
|
||||||
|
if step == 1 or step % args.log_interval == 0:
|
||||||
|
elapsed = max(1e-6, time.time() - started)
|
||||||
|
speed = (step - start_step) / elapsed
|
||||||
|
eta = (total_steps - step) / max(1e-6, speed)
|
||||||
|
print(
|
||||||
|
f"step={step}/{total_steps} loss={loss_g.item():.4f} mel={mel_l1.item():.4f} "
|
||||||
|
f"mse={mel_mse.item():.4f} delta={delta.item():.4f} accel={accel.item():.4f} "
|
||||||
|
f"dur={dur_loss.item():.4f} gdur={group_dur_loss.item():.4f} "
|
||||||
|
f"energy={energy_loss.item():.4f} bright={bright_loss.item():.4f} "
|
||||||
|
f"pitch={pitch_loss.item():.4f} stft={stft_loss.item():.4f} "
|
||||||
|
f"pmel={predicted_prosody_mel_loss.item():.4f} "
|
||||||
|
f"pdelta={predicted_prosody_delta_loss.item():.4f} "
|
||||||
|
f"rmel={robust_prosody_mel_loss.item():.4f} "
|
||||||
|
f"rdelta={robust_prosody_delta_loss.item():.4f} "
|
||||||
|
f"vwav={voc_wav_loss.item():.4f} vmel={voc_mel_loss.item():.4f} "
|
||||||
|
f"adv={adv_mel_loss.item():.4f} disc={disc_loss.item():.4f} "
|
||||||
|
f"lr={lr_scale*args.lr:.2g} grad={float(grad):.2f} "
|
||||||
|
f"speed={speed:.3f} step/s eta={eta/60:.1f}m",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if step % args.save_interval == 0 or step >= total_steps:
|
||||||
|
payload = {
|
||||||
|
"model": model.state_dict(),
|
||||||
|
"optim_g": optim_g.state_dict(),
|
||||||
|
"step": step,
|
||||||
|
"speakers": speakers,
|
||||||
|
"params": count_parameters(model),
|
||||||
|
}
|
||||||
|
if mel_disc is not None:
|
||||||
|
payload["mel_disc"] = mel_disc.state_dict()
|
||||||
|
payload["optim_d"] = optim_d.state_dict()
|
||||||
|
tmp = args.out_dir / f"inflect-smooth-{step}.pt.tmp"
|
||||||
|
torch.save(payload, tmp)
|
||||||
|
tmp.replace(args.out_dir / f"inflect-smooth-{step}.pt")
|
||||||
|
torch.save(payload, args.out_dir / "inflect-smooth-latest.pt")
|
||||||
|
|
||||||
|
print(f"Done. {args.out_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="Train Inflect-Nano acoustic model with enhanced smoothness losses.",
|
||||||
|
)
|
||||||
|
# Data
|
||||||
|
ap.add_argument("--durations-jsonl", type=Path, required=True)
|
||||||
|
ap.add_argument("--out-dir", type=Path, required=True)
|
||||||
|
ap.add_argument("--max-rows", type=int, default=0)
|
||||||
|
|
||||||
|
# Architecture
|
||||||
|
ap.add_argument("--hidden", type=int, default=168)
|
||||||
|
ap.add_argument("--encoder-layers", type=int, default=5)
|
||||||
|
ap.add_argument("--decoder-layers", type=int, default=6)
|
||||||
|
ap.add_argument("--decoder-ff-mult", type=int, default=3)
|
||||||
|
ap.add_argument("--max-seconds", type=float, default=12.0)
|
||||||
|
ap.add_argument("--max-frames", type=int, default=1400)
|
||||||
|
ap.add_argument("--postnet-scale", type=float, default=0.35,
|
||||||
|
help="Postnet refinement scale (higher = postnet has more influence)")
|
||||||
|
ap.add_argument("--postnet-layers", type=int, default=5,
|
||||||
|
help="Depth of residual postnet")
|
||||||
|
ap.add_argument("--postnet-kernel", type=int, default=5,
|
||||||
|
help="Kernel size for postnet convolutions")
|
||||||
|
ap.add_argument("--abs-frame-bins", type=int, default=512)
|
||||||
|
|
||||||
|
# Training
|
||||||
|
ap.add_argument("--steps", type=int, default=20000)
|
||||||
|
ap.add_argument("--batch-size", type=int, default=6)
|
||||||
|
ap.add_argument("--lr", type=float, default=2e-4)
|
||||||
|
ap.add_argument("--weight-decay", type=float, default=1e-4)
|
||||||
|
ap.add_argument("--warmup-steps", type=int, default=1000,
|
||||||
|
help="Linear warmup steps for cosine LR schedule")
|
||||||
|
ap.add_argument("--grad-clip", type=float, default=5.0)
|
||||||
|
|
||||||
|
# Loss weights (tuned for smoothness)
|
||||||
|
ap.add_argument("--mse-weight", type=float, default=0.25)
|
||||||
|
ap.add_argument("--delta-weight", type=float, default=0.25,
|
||||||
|
help="Spectral delta smoothness (higher = smoother transitions)")
|
||||||
|
ap.add_argument("--accel-weight", type=float, default=0.08,
|
||||||
|
help="Spectral acceleration penalty (higher = less jitter)")
|
||||||
|
ap.add_argument("--duration-weight", type=float, default=0.08)
|
||||||
|
ap.add_argument("--group-duration-weight", type=float, default=0.02)
|
||||||
|
ap.add_argument("--energy-weight", type=float, default=0.06)
|
||||||
|
ap.add_argument("--bright-weight", type=float, default=0.06)
|
||||||
|
ap.add_argument("--pitch-weight", type=float, default=0.06)
|
||||||
|
|
||||||
|
# Spectral / perceptual losses
|
||||||
|
ap.add_argument("--stft-weight", type=float, default=0.15,
|
||||||
|
help="Multi-resolution STFT loss (improves spectral smoothness)")
|
||||||
|
ap.add_argument("--predicted-prosody-mel-weight", type=float, default=0.05,
|
||||||
|
help="Exposure bias: use only predicted prosody for mel")
|
||||||
|
ap.add_argument("--predicted-prosody-delta-weight", type=float, default=0.03)
|
||||||
|
ap.add_argument("--robust-prosody-mix", type=float, default=0.5,
|
||||||
|
help="Mix ratio for robust prosody training")
|
||||||
|
ap.add_argument("--robust-prosody-mel-weight", type=float, default=0.05)
|
||||||
|
ap.add_argument("--robust-prosody-delta-weight", type=float, default=0.03)
|
||||||
|
ap.add_argument("--adv-mel-weight", type=float, default=0.08,
|
||||||
|
help="Adversarial mel loss (pushes toward realistic spectrograms)")
|
||||||
|
ap.add_argument("--fm-weight", type=float, default=2.0,
|
||||||
|
help="Feature matching weight for adversarial loss")
|
||||||
|
|
||||||
|
# Vocoder consistency
|
||||||
|
ap.add_argument("--vocoder-checkpoint", type=Path, default=None,
|
||||||
|
help="Path to vocoder for consistency loss")
|
||||||
|
ap.add_argument("--vocoder-wav-weight", type=float, default=0.12,
|
||||||
|
help="Vocoder waveform consistency loss")
|
||||||
|
ap.add_argument("--vocoder-mel-weight", type=float, default=0.08,
|
||||||
|
help="Vocoder mel-reconstruction consistency loss")
|
||||||
|
|
||||||
|
# Checkpointing
|
||||||
|
ap.add_argument("--init-checkpoint", type=Path,
|
||||||
|
help="Start from an existing acoustic checkpoint for fine-tuning")
|
||||||
|
ap.add_argument("--save-interval", type=int, default=2000)
|
||||||
|
ap.add_argument("--log-interval", type=int, default=50)
|
||||||
|
ap.add_argument("--seed", type=int, default=42)
|
||||||
|
ap.add_argument("--resume", action="store_true")
|
||||||
|
ap.add_argument("--preload-features", action="store_true")
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
|
||||||
|
ap.add_argument("--trainable", choices=[
|
||||||
|
"all", "duration", "predictors", "heads", "contextual",
|
||||||
|
"group_duration", "decoder_adapt",
|
||||||
|
], default="all")
|
||||||
|
ap.add_argument("--contextual-predictors", action="store_true")
|
||||||
|
ap.add_argument("--group-duration-planner", action="store_true")
|
||||||
|
|
||||||
|
args = ap.parse_args()
|
||||||
|
train_smooth(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
"""
|
||||||
|
Preprocess an audio+text dataset into the durations.jsonl format required
|
||||||
|
by Inflect-Nano training scripts.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- HuggingFace datasets (CMU ARCTIC, LJSpeech, etc.)
|
||||||
|
- Local directory of .wav files with matching .txt transcriptions
|
||||||
|
- LJSpeech-format metadata.csv
|
||||||
|
|
||||||
|
Output: a .jsonl file with one JSON object per line containing:
|
||||||
|
phone_ids, tone_ids, lang_ids, hifigan_durations, target_audio, speaker_id, voice_id
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
import torch
|
||||||
|
import torchaudio
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent
|
||||||
|
VENDORED_FRONTEND = REPO_ROOT / "third_party" / "tiny_tts_frontend"
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
sys.path.insert(0, str(VENDORED_FRONTEND))
|
||||||
|
|
||||||
|
from tiny_tts.nn import commons
|
||||||
|
from tiny_tts.text import phonemes_to_ids
|
||||||
|
from tiny_tts.text.english import grapheme_to_phoneme, normalize_text
|
||||||
|
from tiny_tts.utils import ADD_BLANK
|
||||||
|
|
||||||
|
from inflect_nano.text_cleaning import clean_tinytts_text
|
||||||
|
from inflect_nano.vocoder import HifiGanConfig, MelFrontend
|
||||||
|
|
||||||
|
|
||||||
|
def text_to_ids(text: str) -> tuple[list[int], list[int], list[int]]:
|
||||||
|
"""Convert English text to TinyTTS phone/tone/lang ID lists."""
|
||||||
|
cleaned = clean_tinytts_text(text)
|
||||||
|
normalized = normalize_text(cleaned)
|
||||||
|
phones, tones, _ = grapheme_to_phoneme(normalized)
|
||||||
|
phone_ids, tone_ids, lang_ids = phonemes_to_ids(phones, tones, "EN")
|
||||||
|
if ADD_BLANK:
|
||||||
|
phone_ids = commons.insert_blanks(phone_ids, 0)
|
||||||
|
tone_ids = commons.insert_blanks(tone_ids, 0)
|
||||||
|
lang_ids = commons.insert_blanks(lang_ids, 0)
|
||||||
|
return phone_ids, tone_ids, lang_ids
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_durations_uniform(
|
||||||
|
phone_ids: list[int],
|
||||||
|
audio_path: str,
|
||||||
|
mel_frontend: MelFrontend,
|
||||||
|
sample_rate: int = 24000,
|
||||||
|
) -> list[int]:
|
||||||
|
"""Estimate mel-frame durations uniformly across all phones.
|
||||||
|
|
||||||
|
Loads the audio, extracts mel spectrogram, then distributes the total
|
||||||
|
frame count evenly across phones. This is a coarse estimate suitable
|
||||||
|
for decoder-only fine-tuning where the duration predictor is frozen.
|
||||||
|
"""
|
||||||
|
wav, sr = torchaudio.load(audio_path)
|
||||||
|
if wav.shape[0] > 1:
|
||||||
|
wav = wav.mean(dim=0, keepdim=True)
|
||||||
|
if sr != sample_rate:
|
||||||
|
wav = torchaudio.functional.resample(wav, sr, sample_rate)
|
||||||
|
wav = wav.clamp(-1, 1)
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
mel = mel_frontend(wav)
|
||||||
|
total_frames = mel.shape[-1]
|
||||||
|
|
||||||
|
# Count non-blank phones (id != 0)
|
||||||
|
visible_phones = [i for i, pid in enumerate(phone_ids) if pid != 0]
|
||||||
|
|
||||||
|
if not visible_phones:
|
||||||
|
return [0] * len(phone_ids)
|
||||||
|
|
||||||
|
# Distribute frames: each blank gets 1 frame, rest split evenly
|
||||||
|
blank_count = len(phone_ids) - len(visible_phones)
|
||||||
|
remaining = max(0, total_frames - blank_count)
|
||||||
|
base_dur = max(1, remaining // len(visible_phones))
|
||||||
|
remainder = remaining - base_dur * len(visible_phones)
|
||||||
|
|
||||||
|
durations = []
|
||||||
|
for pid in phone_ids:
|
||||||
|
if pid == 0:
|
||||||
|
durations.append(1)
|
||||||
|
else:
|
||||||
|
extra = 1 if remainder > 0 else 0
|
||||||
|
remainder = max(0, remainder - 1)
|
||||||
|
durations.append(base_dur + extra)
|
||||||
|
|
||||||
|
# Scale to match total_frames
|
||||||
|
current_sum = sum(durations)
|
||||||
|
if current_sum > 0 and current_sum != total_frames:
|
||||||
|
scale = total_frames / current_sum
|
||||||
|
scaled = [max(1, round(d * scale)) for d in durations]
|
||||||
|
# Fix rounding errors
|
||||||
|
diff = total_frames - sum(scaled)
|
||||||
|
for i in range(abs(diff)):
|
||||||
|
if diff > 0:
|
||||||
|
scaled[i % len(scaled)] += 1
|
||||||
|
else:
|
||||||
|
if scaled[i % len(scaled)] > 1:
|
||||||
|
scaled[i % len(scaled)] -= 1
|
||||||
|
durations = scaled
|
||||||
|
|
||||||
|
# Ensure no zero durations for non-blank phones
|
||||||
|
for i, pid in enumerate(phone_ids):
|
||||||
|
if pid != 0 and durations[i] < 1:
|
||||||
|
durations[i] = 1
|
||||||
|
|
||||||
|
return durations
|
||||||
|
|
||||||
|
|
||||||
|
def process_hf_dataset(
|
||||||
|
dataset_path: str,
|
||||||
|
output_jsonl: Path,
|
||||||
|
audio_dir: Path | None,
|
||||||
|
subset: str = "default",
|
||||||
|
split: str | None = None,
|
||||||
|
text_key: str = "text",
|
||||||
|
speaker_key: str = "speaker",
|
||||||
|
max_rows: int = 0,
|
||||||
|
voice_id: str = "speaker",
|
||||||
|
) -> int:
|
||||||
|
"""Process a HuggingFace dataset (loaded via `datasets` library)."""
|
||||||
|
from datasets import load_dataset
|
||||||
|
|
||||||
|
print(f"Loading HF dataset: {dataset_path} subset={subset} split={split}")
|
||||||
|
if split:
|
||||||
|
ds = load_dataset(dataset_path, subset, split=split, trust_remote_code=True)
|
||||||
|
else:
|
||||||
|
ds_dict = load_dataset(dataset_path, subset, trust_remote_code=True)
|
||||||
|
splits = list(ds_dict.keys())
|
||||||
|
print(f"Available splits: {splits}")
|
||||||
|
# Use first train split, or first available
|
||||||
|
preferred = [s for s in splits if "train" in s.lower()]
|
||||||
|
ds = ds_dict[preferred[0] if preferred else splits[0]]
|
||||||
|
|
||||||
|
mel_frontend = MelFrontend(HifiGanConfig(variant="v2plus"))
|
||||||
|
output_jsonl.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
with output_jsonl.open("w", encoding="utf-8") as f:
|
||||||
|
for i, row in enumerate(ds):
|
||||||
|
text = str(row.get(text_key, "")).strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get audio path or array
|
||||||
|
audio_info = row.get("audio", row.get("file", None))
|
||||||
|
if audio_info is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(audio_info, dict):
|
||||||
|
# Audio is already loaded as array
|
||||||
|
audio_path = None
|
||||||
|
audio_array = audio_info.get("array")
|
||||||
|
sample_rate = audio_info.get("sampling_rate", 24000)
|
||||||
|
if audio_array is None:
|
||||||
|
continue
|
||||||
|
elif isinstance(audio_info, str):
|
||||||
|
audio_path = audio_info
|
||||||
|
if audio_dir:
|
||||||
|
audio_path = str(audio_dir / Path(audio_info).name)
|
||||||
|
if not Path(audio_path).is_file():
|
||||||
|
continue
|
||||||
|
audio_array = None
|
||||||
|
sample_rate = 24000 # will be detected on load
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
phone_ids, tone_ids, lang_ids = text_to_ids(text)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Skipping row {i}: text-to-ids failed: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not phone_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Estimate durations
|
||||||
|
if audio_path:
|
||||||
|
durations = estimate_durations_uniform(phone_ids, audio_path, mel_frontend)
|
||||||
|
else:
|
||||||
|
# Uniform fallback: 8 frames per phone
|
||||||
|
durations = [8] * len(phone_ids)
|
||||||
|
|
||||||
|
speaker = str(row.get(speaker_key, voice_id))
|
||||||
|
|
||||||
|
row_out = {
|
||||||
|
"phone_ids": phone_ids,
|
||||||
|
"tone_ids": tone_ids,
|
||||||
|
"lang_ids": lang_ids,
|
||||||
|
"hifigan_durations": durations,
|
||||||
|
"target_audio": audio_path or "",
|
||||||
|
"speaker_id": hash(speaker) % 256,
|
||||||
|
"voice_id": speaker,
|
||||||
|
}
|
||||||
|
f.write(json.dumps(row_out, ensure_ascii=False) + "\n")
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if max_rows > 0 and count >= max_rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
if count % 100 == 0:
|
||||||
|
print(f" Processed {count} rows...")
|
||||||
|
|
||||||
|
print(f"Wrote {count} rows to {output_jsonl}")
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def process_local_dir(
|
||||||
|
audio_dir: Path,
|
||||||
|
output_jsonl: Path,
|
||||||
|
ext: str = ".wav",
|
||||||
|
text_ext: str = ".txt",
|
||||||
|
speaker: str = "speaker",
|
||||||
|
max_rows: int = 0,
|
||||||
|
) -> int:
|
||||||
|
"""Process a local directory of .wav files with matching .txt files."""
|
||||||
|
audio_files = sorted(audio_dir.glob(f"*{ext}"))
|
||||||
|
if not audio_files:
|
||||||
|
audio_files = sorted(audio_dir.rglob(f"*{ext}"))
|
||||||
|
|
||||||
|
mel_frontend = MelFrontend(HifiGanConfig(variant="v2plus"))
|
||||||
|
output_jsonl.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
with output_jsonl.open("w", encoding="utf-8") as f:
|
||||||
|
for wav_path in audio_files:
|
||||||
|
txt_path = wav_path.with_suffix(text_ext)
|
||||||
|
if not txt_path.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
text = txt_path.read_text(encoding="utf-8").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
phone_ids, tone_ids, lang_ids = text_to_ids(text)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Skipping {wav_path.name}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
durations = estimate_durations_uniform(phone_ids, str(wav_path), mel_frontend)
|
||||||
|
|
||||||
|
row_out = {
|
||||||
|
"phone_ids": phone_ids,
|
||||||
|
"tone_ids": tone_ids,
|
||||||
|
"lang_ids": lang_ids,
|
||||||
|
"hifigan_durations": durations,
|
||||||
|
"target_audio": str(wav_path.resolve()),
|
||||||
|
"speaker_id": hash(speaker) % 256,
|
||||||
|
"voice_id": speaker,
|
||||||
|
}
|
||||||
|
f.write(json.dumps(row_out, ensure_ascii=False) + "\n")
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if max_rows > 0 and count >= max_rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
if count % 100 == 0:
|
||||||
|
print(f" Processed {count} rows...")
|
||||||
|
|
||||||
|
print(f"Wrote {count} rows to {output_jsonl}")
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def process_metadata_csv(
|
||||||
|
csv_path: Path,
|
||||||
|
audio_dir: Path,
|
||||||
|
output_jsonl: Path,
|
||||||
|
sep: str = "|",
|
||||||
|
header: bool = False,
|
||||||
|
text_col: int = 1,
|
||||||
|
file_col: int = 0,
|
||||||
|
speaker: str = "speaker",
|
||||||
|
max_rows: int = 0,
|
||||||
|
) -> int:
|
||||||
|
"""Process an LJSpeech-style metadata.csv."""
|
||||||
|
mel_frontend = MelFrontend(HifiGanConfig(variant="v2plus"))
|
||||||
|
output_jsonl.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
with csv_path.open("r", encoding="utf-8") as csv_file:
|
||||||
|
lines = csv_file.readlines()
|
||||||
|
if header:
|
||||||
|
lines = lines[1:]
|
||||||
|
|
||||||
|
with output_jsonl.open("w", encoding="utf-8") as out_f:
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = line.split(sep)
|
||||||
|
if len(parts) < max(text_col, file_col) + 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filename = parts[file_col].strip()
|
||||||
|
if not filename.endswith(".wav"):
|
||||||
|
filename += ".wav"
|
||||||
|
wav_path = audio_dir / filename
|
||||||
|
if not wav_path.is_file():
|
||||||
|
print(f" Missing: {wav_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
text = parts[text_col].strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
phone_ids, tone_ids, lang_ids = text_to_ids(text)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Skipping {filename}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
durations = estimate_durations_uniform(phone_ids, str(wav_path), mel_frontend)
|
||||||
|
|
||||||
|
row_out = {
|
||||||
|
"phone_ids": phone_ids,
|
||||||
|
"tone_ids": tone_ids,
|
||||||
|
"lang_ids": lang_ids,
|
||||||
|
"hifigan_durations": durations,
|
||||||
|
"target_audio": str(wav_path.resolve()),
|
||||||
|
"speaker_id": hash(speaker) % 256,
|
||||||
|
"voice_id": speaker,
|
||||||
|
}
|
||||||
|
out_f.write(json.dumps(row_out, ensure_ascii=False) + "\n")
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if max_rows > 0 and count >= max_rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
if count % 100 == 0:
|
||||||
|
print(f" Processed {count} rows...")
|
||||||
|
|
||||||
|
print(f"Wrote {count} rows to {output_jsonl}")
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="Preprocess a speech dataset for Inflect-Nano training.",
|
||||||
|
)
|
||||||
|
|
||||||
|
sub = ap.add_subparsers(dest="mode", required=True)
|
||||||
|
|
||||||
|
# HuggingFace mode
|
||||||
|
p_hf = sub.add_parser("hf", help="Process a HuggingFace dataset")
|
||||||
|
p_hf.add_argument("--dataset", required=True, help="HF dataset path, e.g. MikhailT/cmu-arctic")
|
||||||
|
p_hf.add_argument("--subset", default="default")
|
||||||
|
p_hf.add_argument("--split", default=None, help="e.g. 'rms' for CMU ARCTIC single speaker")
|
||||||
|
p_hf.add_argument("--text-key", default="text")
|
||||||
|
p_hf.add_argument("--speaker-key", default="speaker")
|
||||||
|
p_hf.add_argument("--audio-dir", type=Path, default=None, help="Local dir for audio if HF paths are remote")
|
||||||
|
p_hf.add_argument("--out", type=Path, required=True, help="Output .jsonl path")
|
||||||
|
p_hf.add_argument("--max-rows", type=int, default=0)
|
||||||
|
p_hf.add_argument("--voice-id", default="speaker")
|
||||||
|
|
||||||
|
# Local dir mode
|
||||||
|
p_dir = sub.add_parser("local", help="Process local directory of .wav+.txt pairs")
|
||||||
|
p_dir.add_argument("--audio-dir", type=Path, required=True)
|
||||||
|
p_dir.add_argument("--ext", default=".wav")
|
||||||
|
p_dir.add_argument("--text-ext", default=".txt")
|
||||||
|
p_dir.add_argument("--speaker", default="speaker")
|
||||||
|
p_dir.add_argument("--out", type=Path, required=True)
|
||||||
|
p_dir.add_argument("--max-rows", type=int, default=0)
|
||||||
|
|
||||||
|
# Metadata CSV mode (LJSpeech-style)
|
||||||
|
p_csv = sub.add_parser("csv", help="Process a metadata.csv file")
|
||||||
|
p_csv.add_argument("--csv", type=Path, required=True)
|
||||||
|
p_csv.add_argument("--audio-dir", type=Path, required=True)
|
||||||
|
p_csv.add_argument("--sep", default="|")
|
||||||
|
p_csv.add_argument("--header", action="store_true")
|
||||||
|
p_csv.add_argument("--file-col", type=int, default=0)
|
||||||
|
p_csv.add_argument("--text-col", type=int, default=1)
|
||||||
|
p_csv.add_argument("--speaker", default="speaker")
|
||||||
|
p_csv.add_argument("--out", type=Path, required=True)
|
||||||
|
p_csv.add_argument("--max-rows", type=int, default=0)
|
||||||
|
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.mode == "hf":
|
||||||
|
process_hf_dataset(
|
||||||
|
dataset_path=args.dataset,
|
||||||
|
output_jsonl=args.out,
|
||||||
|
audio_dir=args.audio_dir,
|
||||||
|
subset=args.subset,
|
||||||
|
split=args.split,
|
||||||
|
text_key=args.text_key,
|
||||||
|
speaker_key=args.speaker_key,
|
||||||
|
max_rows=args.max_rows,
|
||||||
|
voice_id=args.voice_id,
|
||||||
|
)
|
||||||
|
elif args.mode == "local":
|
||||||
|
process_local_dir(
|
||||||
|
audio_dir=args.audio_dir,
|
||||||
|
output_jsonl=args.out,
|
||||||
|
ext=args.ext,
|
||||||
|
text_ext=args.text_ext,
|
||||||
|
speaker=args.speaker,
|
||||||
|
max_rows=args.max_rows,
|
||||||
|
)
|
||||||
|
elif args.mode == "csv":
|
||||||
|
process_metadata_csv(
|
||||||
|
csv_path=args.csv,
|
||||||
|
audio_dir=args.audio_dir,
|
||||||
|
output_jsonl=args.out,
|
||||||
|
sep=args.sep,
|
||||||
|
header=args.header,
|
||||||
|
file_col=args.file_col,
|
||||||
|
text_col=args.text_col,
|
||||||
|
speaker=args.speaker,
|
||||||
|
max_rows=args.max_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -5,3 +5,6 @@ numpy
|
|||||||
g2p_en
|
g2p_en
|
||||||
transformers
|
transformers
|
||||||
gradio
|
gradio
|
||||||
|
numba
|
||||||
|
scipy
|
||||||
|
datasets
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Basic smoke test for Inflect-Nano-v1: model loading, text-to-tokens, and synthesis."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Ensure repo root and vendored frontend are on sys.path
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent
|
||||||
|
VENDORED_FRONTEND = REPO_ROOT / "third_party" / "tiny_tts_frontend"
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
sys.path.insert(0, str(VENDORED_FRONTEND))
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from tiny_tts.nn import commons
|
||||||
|
from tiny_tts.text import phonemes_to_ids
|
||||||
|
from tiny_tts.text.english import grapheme_to_phoneme, normalize_text
|
||||||
|
from tiny_tts.utils import ADD_BLANK
|
||||||
|
|
||||||
|
from inflect_nano.text_cleaning import clean_tinytts_text
|
||||||
|
from inflect_nano.vocoder import HifiGanGenerator, make_config
|
||||||
|
from inflect_nano.acoustic import MicroFastSpeech, MicroFastSpeechConfig
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_to_tokens():
|
||||||
|
"""Verify the text -> token pipeline runs end-to-end."""
|
||||||
|
text = "Hello world, this is a test."
|
||||||
|
cleaned = clean_tinytts_text(text)
|
||||||
|
normalized = normalize_text(cleaned)
|
||||||
|
phones, tones, _ = grapheme_to_phoneme(normalized)
|
||||||
|
phone_ids, tone_ids, lang_ids = phonemes_to_ids(phones, tones, "EN")
|
||||||
|
if ADD_BLANK:
|
||||||
|
phone_ids = commons.insert_blanks(phone_ids, 0)
|
||||||
|
tone_ids = commons.insert_blanks(tone_ids, 0)
|
||||||
|
lang_ids = commons.insert_blanks(lang_ids, 0)
|
||||||
|
|
||||||
|
assert len(phone_ids) > 0, "Should produce non-empty phone IDs"
|
||||||
|
assert len(phone_ids) == len(tone_ids) == len(lang_ids), "All token sequences should have same length"
|
||||||
|
print(f" [PASS] text_to_tokens: {len(phone_ids)} tokens from '{text}'")
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_acoustic():
|
||||||
|
"""Verify the acoustic model loads without error."""
|
||||||
|
device = torch.device("cpu")
|
||||||
|
ckpt_path = REPO_ROOT / "weights" / "inflect_nano_v1_acoustic.pt"
|
||||||
|
ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
|
||||||
|
cfg = MicroFastSpeechConfig(**ckpt["config"])
|
||||||
|
model = MicroFastSpeech(cfg).to(device)
|
||||||
|
model.load_state_dict(ckpt["model"])
|
||||||
|
model.eval()
|
||||||
|
|
||||||
|
params = sum(p.numel() for p in model.parameters())
|
||||||
|
print(f" [PASS] load_acoustic: {params:,} parameters loaded")
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_vocoder():
|
||||||
|
"""Verify the vocoder loads without error."""
|
||||||
|
device = torch.device("cpu")
|
||||||
|
ckpt_path = REPO_ROOT / "weights" / "inflect_nano_v1_vocoder.pt"
|
||||||
|
ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
|
||||||
|
cfg = make_config((ckpt.get("config") or {}).get("variant", "snake_v2mid"))
|
||||||
|
model = HifiGanGenerator(cfg).to(device)
|
||||||
|
model.load_state_dict(ckpt["generator"])
|
||||||
|
model.remove_weight_norm()
|
||||||
|
model.eval()
|
||||||
|
|
||||||
|
params = sum(p.numel() for p in model.parameters())
|
||||||
|
print(f" [PASS] load_vocoder: {params:,} parameters loaded")
|
||||||
|
|
||||||
|
|
||||||
|
def test_synthesize():
|
||||||
|
"""End-to-end: produce a waveform from text."""
|
||||||
|
device = torch.device("cpu")
|
||||||
|
|
||||||
|
# Load acoustic
|
||||||
|
acoustic_ckpt = torch.load(REPO_ROOT / "weights" / "inflect_nano_v1_acoustic.pt", map_location=device, weights_only=False)
|
||||||
|
acoustic_cfg = MicroFastSpeechConfig(**acoustic_ckpt["config"])
|
||||||
|
acoustic = MicroFastSpeech(acoustic_cfg).to(device)
|
||||||
|
acoustic.load_state_dict(acoustic_ckpt["model"])
|
||||||
|
acoustic.eval()
|
||||||
|
speakers = acoustic_ckpt.get("speakers") or {"mark": 0}
|
||||||
|
|
||||||
|
# Load vocoder
|
||||||
|
vocoder_ckpt = torch.load(REPO_ROOT / "weights" / "inflect_nano_v1_vocoder.pt", map_location=device, weights_only=False)
|
||||||
|
vocoder_cfg = make_config((vocoder_ckpt.get("config") or {}).get("variant", "snake_v2mid"))
|
||||||
|
vocoder = HifiGanGenerator(vocoder_cfg).to(device)
|
||||||
|
vocoder.load_state_dict(vocoder_ckpt["generator"])
|
||||||
|
vocoder.remove_weight_norm()
|
||||||
|
vocoder.eval()
|
||||||
|
|
||||||
|
# Tokenize
|
||||||
|
text = "This is a quick test."
|
||||||
|
cleaned = clean_tinytts_text(text)
|
||||||
|
normalized = normalize_text(cleaned)
|
||||||
|
phones, tones, _ = grapheme_to_phoneme(normalized)
|
||||||
|
phone_ids, tone_ids, lang_ids = phonemes_to_ids(phones, tones, "EN")
|
||||||
|
if ADD_BLANK:
|
||||||
|
phone_ids = commons.insert_blanks(phone_ids, 0)
|
||||||
|
tone_ids = commons.insert_blanks(tone_ids, 0)
|
||||||
|
lang_ids = commons.insert_blanks(lang_ids, 0)
|
||||||
|
|
||||||
|
phone = torch.LongTensor(phone_ids).unsqueeze(0).to(device)
|
||||||
|
tone = torch.LongTensor(tone_ids).unsqueeze(0).to(device)
|
||||||
|
lang = torch.LongTensor(lang_ids).unsqueeze(0).to(device)
|
||||||
|
speaker = torch.LongTensor([int(speakers.get("mark", 0))]).to(device)
|
||||||
|
|
||||||
|
# Synthesize
|
||||||
|
with torch.inference_mode():
|
||||||
|
mel = acoustic.infer(phone, tone, lang, speaker)
|
||||||
|
wav = vocoder(mel).squeeze().cpu().numpy()
|
||||||
|
|
||||||
|
assert isinstance(wav, np.ndarray), "Output should be a numpy array"
|
||||||
|
assert wav.size > 0, "Waveform should not be empty"
|
||||||
|
assert np.abs(wav).max() <= 1.0, "Waveform should be normalized to [-1, 1]"
|
||||||
|
|
||||||
|
print(f" [PASS] synthesize: {wav.size} samples ({wav.size / 24000:.2f}s) from '{text}'")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Inflect-Nano-v1 smoke tests\n")
|
||||||
|
test_text_to_tokens()
|
||||||
|
test_load_acoustic()
|
||||||
|
test_load_vocoder()
|
||||||
|
test_synthesize()
|
||||||
|
print("\nAll tests passed!")
|
||||||
Reference in New Issue
Block a user