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:
Michael Treadgold
2026-06-18 19:46:40 +12:00
parent 1a75163b18
commit 6e1da4ddfb
7 changed files with 1478 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
*.wav
.venv/
+281
View File
@@ -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
View File
@@ -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)
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()
def synthesize(
text: str,
@@ -88,6 +113,9 @@ def synthesize(
length_scale: float = 1.0,
pitch_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:
phone, tone, lang = text_to_tokens(text)
phone = phone.unsqueeze(0).to(device)
@@ -102,9 +130,15 @@ def synthesize(
length_scale=float(length_scale),
pitch_scale=float(pitch_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()
return normalize_audio(wav)
wav = normalize_audio(wav)
if lowpass_hz > 0:
wav = apply_lowpass(wav, lowpass_hz)
return wav
def main() -> None:
@@ -117,6 +151,12 @@ def main() -> None:
ap.add_argument("--length-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("--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()
device = torch.device(args.device)
@@ -131,6 +171,9 @@ def main() -> None:
length_scale=args.length_scale,
pitch_scale=args.pitch_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)
sf.write(str(args.out), audio, 24000, subtype="PCM_16")
+594
View File
@@ -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()
+426
View File
@@ -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()
+3
View File
@@ -5,3 +5,6 @@ numpy
g2p_en
transformers
gradio
numba
scipy
datasets
+126
View File
@@ -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!")