v2 Refactor

This commit is contained in:
Nighthawk
2026-02-20 07:54:34 -05:00
parent 11bc595a40
commit 6b46954e37
47 changed files with 2268 additions and 2440 deletions
+63 -94
View File
@@ -1,129 +1,98 @@
# Soprano-Reforged
# Soprano-Factory (Clean-Room Edition)
Soprano-Reforged is a clean, modular, and robust implementation of the Soprano text-to-speech architecture. It is designed for training custom high-fidelity speech models on Windows and Linux with minimal friction.
A fully rebuilt, production-ready training pipeline for fine-tuning the [Soprano](https://github.com/ekwek1/soprano) Text-to-Speech model.
## Overview
This repository solves the notorious "noise soup" issue present in earlier fine-tuning scripts by implementing **Hidden State Distillation**, strict `int16` audio normalization, and proper `[STOP]` token preservation.
This repository implements a 3-stage training pipeline:
1. **Stage 0 (Codec)**: Trains a custom neural audio codec (Encoder and Decoder) specific to your speaker's voice.
2. **Stage 1 (Tokenization)**: Converts your audio dataset into discrete acoustic tokens.
3. **Stage 2 (Joint Training)**: Fine-tunes the Soprano 1.1 LLM to generate these tokens from text.
## Installation
## Why the Rebuild?
Standard causal language model fine-tuning uses discrete Cross-Entropy loss. However, the Soprano Vocos decoder generates audio from the LLM's *continuous hidden states*. Standard fine-tuning causes these hidden states to drift, resulting in the frozen decoder outputting pure static.
This project is built with `uv` for fast, reliable dependency management.
This clean-room pipeline fixes this by:
1. **Hidden State Distillation**: A frozen "Teacher" model anchors the latent space. A trainable "Student" model learns the new tokens while an MSE loss penalty forces its hidden states to remain compatible with the frozen audio decoder.
2. **Audio Normalization**: Safely scales 16-bit PCM WAV files to `[-1.0, 1.0]` float32 tensors, preventing the Mel Spectrogram energy from exploding and corrupting the discrete audio tokens.
3. **Collation Fixes**: Safely packs sequences without accidentally slicing off the `[STOP]` token, allowing the model to learn when to terminate speech.
### 1. Clone the repository
---
```bash
git clone https://github.com/yourusername/soprano-reforged
cd soprano-reforged
```
## 📦 Installation
### 2. Initialize environment and install dependencies
This project strictly uses `uv` for dependency management to ensure reliable, reproducible builds with the correct CUDA 12.8 bindings.
```bash
uv sync
```
1. **Install `uv`** (if you haven't already):
```bash
curl -LsSf [https://astral.sh/uv/install.sh](https://astral.sh/uv/install.sh) | sh
### Dependencies
Clone and Sync:
Bash
- **Core**: torch, torchaudio, transformers
- **Audio**: soundfile, librosa
- **GUI**: sv-ttk, darkdetect
- **Utils**: pyyaml, tqdm
git clone [https://github.com/yourusername/soprano-factory.git](https://github.com/yourusername/soprano-factory.git)
cd soprano-factory
uv sync
> **Note for Windows Users**: The pipeline includes automatic detection for local FFmpeg binaries in `tools/ffmpeg` to prevent path issues.
Note: The included pyproject.toml automatically configures the PyTorch explicit index for CUDA 12.8.
## Quick Start (GUI)
📂 Project Structure
Plaintext
The easiest way to use Soprano-Reforged is the built-in Training Factory GUI.
soprano-factory/
├── pyproject.toml # uv dependencies and CUDA index
├── config.yaml # Centralized hyperparameters and paths
├── generate_dataset.py # Preprocesses WAVs into quantized tokens
├── train.py # Main training loop with Distillation & TensorBoard
├── test_inference.py # Generates test audio from the fine-tuned model
├── dataset.py # PyTorch Dataset and PackedCollation logic
├── text_normalizer.py # Text cleaning utility
└── example_dataset/ # Your raw data goes here
├── metadata.txt
└── wavs/
```bash
python gui.py
```
🚀 Usage Pipeline
1. Prepare Your Data
This launches a modern, dark-mode interface where you can:
Place your audio files in example_dataset/wavs/ and create an LJSpeech-formatted metadata.txt inside example_dataset/.
- Configure paths for your dataset
- Monitor training logs in real-time
- Execute all 3 stages of the pipeline without touching the command line
Format: filename|Transcript text here.
Plaintext
## Configuration
example1|Soprano is an extremely lightweight text to speech model.
example2|Gabagool? Ova here!
All hyperparameters are centralized in `config/settings.yaml`. You should edit this file to match your hardware capabilities (VRAM) and dataset size.
2. Configure Training
### config/settings.yaml excerpt
Edit config.yaml to set your desired batch size, accumulation steps, learning rate, and distillation weight.
3. Generate the Dataset
```yaml
common:
device: "auto"
Run the data generator to normalize the audio, clean the text, and encode the audio into discrete FSQ tokens.
Bash
codec:
batch_size: 8
epochs: 100
uv run generate_dataset.py --input-dir ./example_dataset
training:
base_model: "ekwek/Soprano-1.1-80M"
batch_size: 4
epochs: 10
```
This will create train.json and val.json in your dataset directory.
4. Train the Model
## The 3-Stage Pipeline (CLI Usage)
Start the training loop. This script utilizes Hidden State Distillation and logs metrics, audio samples, and hidden-state visualizations directly to TensorBoard.
Bash
If you prefer the command line, follow these steps.
uv run train.py --config config.yaml
### Stage 0: Train the Codec
Monitor Training Live:
Open a new terminal and run:
Bash
Learns to compress and reconstruct your specific audio data.
uv run tensorboard --logdir=./runs/soprano_finetune
```bash
python train_codec.py --wav-dir "./my_dataset/wavs/*.wav"
```
5. Test Inference
### Stage 1: Generate Dataset
Once training completes, use the test script to verify that your model generates clear audio and stops correctly. This script automatically bundles the required decoder.pth into your fine-tuned directory.
Bash
Uses the trained encoder to convert audio files into token sequences (`train.json`).
uv run test_inference.py --model-dir ./finetuned_model --text "This is a test of my custom Soprano voice." --output my_test.wav
```bash
python generate_dataset.py --input-dir ./my_dataset --encoder-ckpt ./weights/codec/encoder.pth
```
📄 License
### Stage 2: Joint Training
This project is licensed under the Apache-2.0 license.
Trains the LLM and Decoder together. The LLM learns to predict tokens, and the Decoder learns to reconstruct audio from the LLM's hidden states, fixing the drift problem.
```bash
python train.py --input-dir ./my_dataset
```
## Inference
Once Stage 2 is complete, you can generate speech using your new model.
```bash
python inference.py --text "Soprano Reforged is fully operational." --model-dir ./weights/model/final --output result.wav
```
## Project Structure
```
SopranoReforged/
├── config/ # Configuration files (settings.yaml)
├── model/ # Neural network architecture (Encoder, Decoder, Quantizer)
├── training/ # Training utilities (Loss functions, Collators)
├── utils/ # Helper scripts (Config loader, Text normalizer)
├── gui.py # Main GUI application
├── train_codec.py # Stage 0 script
├── generate_dataset.py # Stage 1 script
├── train.py # Stage 2 script
└── inference.py # Generation script
```
## Credits
- **Original Architecture**: Based on the Soprano model by [ekwek](https://huggingface.co/ekwek)
- **Vocos Backbone**: Utilizes ConvNeXt blocks for high-fidelity audio reconstruction
- **Reforged By**: Nighthawk42
The pipeline is officially clean, robust, and ready to go. Let me know if you'd like to dive into pushing this to a
-54
View File
@@ -1,54 +0,0 @@
# analyze_tokens.py - Diagnostic for LLM Audio Tokens
import torch
import argparse
from transformers import AutoTokenizer, AutoModelForCausalLM
from utils.text_normalizer import normalize_text
from utils.config import cfg
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--text", type=str, required=True)
parser.add_argument("--model-dir", type=str, required=True)
args = parser.parse_args()
tokenizer = AutoTokenizer.from_pretrained(args.model_dir)
llm = AutoModelForCausalLM.from_pretrained(args.model_dir, torch_dtype=torch.float32).to(cfg.device)
norm_text = normalize_text(args.text)
prompt = f"[STOP][TEXT]{norm_text}[START]"
inputs = tokenizer(prompt, return_tensors="pt").to(cfg.device)
print(f"Analyzing LLM response for: {norm_text}")
with torch.no_grad():
output_ids = llm.generate(
inputs["input_ids"],
max_new_tokens=128,
do_sample=True,
top_k=50
)
# Convert to list and find the start of audio
tokens = output_ids[0].tolist()
start_token_id = tokenizer.convert_tokens_to_ids("[START]")
try:
idx = tokens.index(start_token_id)
audio_tokens = tokens[idx+1:]
except:
audio_tokens = tokens
print("-" * 30)
print(f"Total Audio Tokens Generated: {len(audio_tokens)}")
print(f"Unique Tokens: {len(set(audio_tokens))}")
print(f"First 20 Tokens: {audio_tokens[:20]}")
if len(set(audio_tokens)) <= 3 and len(audio_tokens) > 10:
print("CRITICAL: Token Collapse detected. The LLM is repeating itself.")
print("Remedy: Increase training epochs or adjust temperature/top_k.")
else:
print("Status: LLM is producing a varied token stream. The issue is likely the Decoder's projection.")
print("-" * 30)
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
# __init__.py - Initializes the Soprano Factory codec package, which includes the Encoder and Decoder classes for the TTS pipeline.
+50
View File
@@ -0,0 +1,50 @@
import torch
from torch import nn
from encoder.codec import VocosBackbone
class SimpleDecoder(nn.Module):
def __init__(
self,
n_mels=50,
encoder_dim=768,
bottleneck_channels=5,
num_layers=8,
intermediate_dim=None,
upsample_scale=4,
dw_kernel=5,
):
super().__init__()
intermediate_dim = intermediate_dim or encoder_dim * 3
self.upsample_scale = upsample_scale
# project FSQ channels back to model dim
self.in_proj = nn.Linear(bottleneck_channels, encoder_dim)
# ConvNeXt backbone
self.backbone = VocosBackbone(
input_channels=encoder_dim,
dim=encoder_dim,
intermediate_dim=intermediate_dim,
num_layers=num_layers,
input_kernel_size=1,
dw_kernel_size=dw_kernel,
)
# output mel projection
self.out_proj = nn.Conv1d(encoder_dim, n_mels, kernel_size=1)
def forward(self, z):
"""
z: (B, T_latent, bottleneck_channels)
"""
z = self.in_proj(z) # (B, T_latent, D)
z = z.transpose(1, 2) # (B, D, T_latent)
# naive upsampling (good enough for now)
z = z.repeat_interleave(self.upsample_scale, dim=2)
z = self.backbone(z) # (B, D, T_mel)
mel_hat = self.out_proj(z) # (B, n_mels, T_mel)
return mel_hat
+213
View File
@@ -0,0 +1,213 @@
"""
Adapted from https://github.com/gemelo-ai/vocos
"""
from typing import Optional
import torchaudio
import torch
from torch import nn
from .quantizer import FSQSTE
def safe_log(x: torch.Tensor, clip_val: float = 5e-3) -> torch.Tensor:
return torch.log(torch.clip(x, min=clip_val))
class SimpleMLP(nn.Module):
def __init__(self,
dim,
intermediate_dim,
):
super().__init__()
self.pwconv1 = nn.Linear(dim, intermediate_dim)
self.act = nn.GELU()
self.pwconv2 = nn.Linear(intermediate_dim, dim)
def forward(self, x):
x = self.pwconv1(x)
x = self.act(x)
x = self.pwconv2(x)
return x
class ConvNeXtBlock(nn.Module):
"""ConvNeXt Block adapted from https://github.com/facebookresearch/ConvNeXt to 1D audio signal.
Args:
dim (int): Number of input channels.
intermediate_dim (int): Dimensionality of the intermediate layer.
layer_scale_init_value (float, optional): Initial value for the layer scale. None means no scaling.
Defaults to None.
"""
def __init__(
self,
dim: int,
intermediate_dim: int,
layer_scale_init_value: float,
dw_kernel_size: int = 7,
):
super().__init__()
self.dwconv = nn.Conv1d(dim, dim, kernel_size=dw_kernel_size, padding=dw_kernel_size//2, groups=dim) # depthwise conv
self.norm = nn.LayerNorm(dim, eps=1e-6)
self.mlp = SimpleMLP(dim, intermediate_dim)
self.gamma = (
nn.Parameter(layer_scale_init_value * torch.ones(dim), requires_grad=True)
if layer_scale_init_value > 0
else None
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
residual = x
x = self.dwconv(x)
x = x.transpose(1, 2) # (B, C, T) -> (B, T, C)
x = self.norm(x)
x = self.mlp(x)
if self.gamma is not None:
x = self.gamma * x
x = x.transpose(1, 2) # (B, T, C) -> (B, C, T)
x = residual + x
return x
class VocosBackbone(nn.Module):
"""
Vocos backbone module built with ConvNeXt blocks.
Args:
input_channels (int): Number of input features channels.
dim (int): Hidden dimension of the model.
intermediate_dim (int): Intermediate dimension used in ConvNeXtBlock.
num_layers (int): Number of ConvNeXtBlock layers.
layer_scale_init_value (float, optional): Initial value for layer scaling.
"""
def __init__(
self,
input_channels: int,
dim: int,
intermediate_dim: int,
num_layers: int,
input_kernel_size: int = 7,
dw_kernel_size: int = 7,
layer_scale_init_value: Optional[float] = None,
pad: str = 'zeros',
):
super().__init__()
self.input_channels = input_channels
self.dim = dim
self.embed = nn.Conv1d(
input_channels,
dim,
kernel_size=input_kernel_size,
padding=input_kernel_size//2,
padding_mode=pad
)
self.norm = nn.LayerNorm(dim, eps=1e-6)
self.convnext = nn.ModuleList([
ConvNeXtBlock(
dim=dim,
intermediate_dim=intermediate_dim,
dw_kernel_size=dw_kernel_size,
layer_scale_init_value=layer_scale_init_value or 1 / num_layers**0.5,
)
for _ in range(num_layers)
])
self.final_layer_norm = nn.LayerNorm(dim, eps=1e-6)
self.apply(self._init_weights)
def _init_weights(self, m):
if isinstance(m, (nn.Conv1d, nn.Linear)):
nn.init.trunc_normal_(m.weight, std=0.02)
if m.bias is not None:
nn.init.constant_(m.bias, 0)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Args:
x (Tensor): Input tensor of shape (B, C, L), where B is the batch size,
C denotes output features, and L is the sequence length.
Returns:
Tensor: Output of shape (B, L, H), where B is the batch size, L is the sequence length,
and H denotes the model dimension.
"""
x = self.embed(x) # (B, C, L)
x = self.norm(x.transpose(1, 2))
x = x.transpose(1, 2)
for conv_block in self.convnext:
x = conv_block(x)
x = self.final_layer_norm(x.transpose(1, 2))
x = x.transpose(1, 2)
return x
class Encoder(nn.Module):
def __init__(self,
num_input_mels=50,
mel_hop_length=512,
mel_hop_scale=0.25,
encoder_num_layers=8,
encoder_dim=768,
encoder_intermediate_dim=None,
fsq_levels=[8, 8, 5, 5, 5],
dw_kernel=5,
):
super().__init__()
self.downsample_scale = 2048 // mel_hop_length
self.mel_hop_length = mel_hop_length
self.mel_n_fft = int(mel_hop_length/mel_hop_scale)
self.encoder_dim = encoder_dim
self.encoder_intermediate_dim = encoder_intermediate_dim if encoder_intermediate_dim else encoder_dim*3
self.encoder_num_layers = encoder_num_layers
self.encoder_initial_channels = num_input_mels
self.bottleneck_channels = 5
self.mel_spec = torchaudio.transforms.MelSpectrogram(
sample_rate=32000,
n_fft=self.mel_n_fft,
hop_length=self.mel_hop_length,
n_mels=num_input_mels,
center=True,
power=1,
)
self.encoder = VocosBackbone(input_channels=self.encoder_initial_channels,
dim=self.encoder_dim,
intermediate_dim=self.encoder_intermediate_dim,
num_layers=self.encoder_num_layers,
input_kernel_size=1,
dw_kernel_size=dw_kernel,
pad='zeros'
)
self.downsampler = nn.Linear(self.encoder_dim, self.bottleneck_channels)
self.quant = FSQSTE(levels=fsq_levels)
def encode(self, x):
x = self.encoder(x)
# import pdb;pdb.set_trace()
x = x[:, :, ::self.downsample_scale] # What the heck is this? Brute force downsampling from mel -> tokens.
x = x.transpose(1,2)
x = self.downsampler(x)
x = self.quant(x)
return x
def preprocess(self, audio):
if audio.dim() == 2: # raw audio
x = self.mel_spec(audio)
x = safe_log(x)
elif audio.dim() == 3: # mel spectrogram
x = audio
return x
def forward(self, audio):
x = self.preprocess(audio)
# print("done preprocessing: ",x.shape)
# import pdb;pdb.set_trace()
x = self.encode(x)
codes = self.quant.to_codebook_index(x)
return codes
+64
View File
@@ -0,0 +1,64 @@
"""
Adapted from https://github.com/duchenzhuang/FSQ-pytorch/blob/main/quantizers/fsq.py#L41
"""
import torch
from torch import nn
from einops import rearrange
class FSQSTE(nn.Module):
def __init__(self, levels):
super().__init__()
if levels:
self.dim = len(levels)
self._levels = torch.tensor(levels, dtype=torch.int32).view(1, 1, self.dim)
else:
self._levels = levels
_levels = self._levels
self.register_buffer("levels", _levels, persistent=False)
_basis = torch.cumprod(torch.tensor([1] + levels[:-1]),
dim=0,
dtype=torch.int32)
self.register_buffer("_basis", _basis, persistent=False)
def _scale_and_shift(self, zhat_normalized):
half_width = self.levels // 2
return (zhat_normalized * half_width) + half_width
def _scale_and_shift_inverse(self, zhat):
half_width = self.levels // 2
return (zhat - half_width) / half_width
def indices_to_level_indices(self, indices):
""" Converts indices to indices at each level, perhaps needed for a transformer with factorized embeddings """
indices = rearrange(indices, '... -> ... 1')
codes_non_centered = (indices // self._basis) % self.levels
return codes_non_centered
def to_codebook_index(self, zhat):
""" Converts a `code` to an index in the codebook. """
assert zhat.shape[-1] == self.dim
zhat = self._scale_and_shift(zhat)
indices = (zhat * self._basis).sum(dim = -1).round().to(torch.int32)
return indices
def from_codebook_index(self, indices):
""" Inverse of `codes_to_indices`. """
level_indices = self.indices_to_level_indices(indices)
codes = self._scale_and_shift_inverse(level_indices)
return codes
def forward(self, x):
if self.levels is not None:
half_levels = (self.levels - 1) * (1 - 1e-3) / 2
offset = 0.5 - 0.5 * (self.levels % 2)
shift = torch.tan(offset / half_levels)
x = torch.tanh(x + shift) * half_levels - offset
x = x + (x.round() - x).detach()
x = x / (self.levels // 2)
return x
+55
View File
@@ -0,0 +1,55 @@
# ==============================================================================
# S O P R A N O F A C T O R Y
# ==============================================================================
dataset:
input_dir: "./datasets/mio" # Must contain metadata.csv and wavs/
sample_rate: 32000
val_split_prop: 0.1 # 10% of data for validation
model:
base_llm: "ekwek/Soprano-1.1-80M"
llm_save_dir: "./finetuned_llm"
decoder_save_dir: "./finetuned_decoder"
# ------------------------------------------------------------------------------
# STAGE 1: LLM AUTOREGRESSIVE TRAINING
# ------------------------------------------------------------------------------
llm_training:
batch_size: 8
grad_accum_steps: 4 # Effective Batch = 32
seq_len: 1024
max_steps: 25000 # Causal LLMs need time to learn grammar
learning_rate: 0.0001
weight_decay: 0.1
val_freq: 250
save_freq: 1000
# ------------------------------------------------------------------------------
# STAGE 2: DECODER GAN TRAINING
# ------------------------------------------------------------------------------
decoder_training:
batch_size: 4 # GAN training is memory intensive; keep small
max_steps: 20000 # Pre-trained decoders adapt quickly
learning_rate_g: 0.0002
learning_rate_d: 0.0001
segment_size_samples: 32768
val_freq: 500
save_freq: 2000
# ------------------------------------------------------------------------------
# HARDWARE OPTIMIZATIONS
# ------------------------------------------------------------------------------
optimizations:
attn_implementation: "sdpa"
allow_tf32: true
mixed_precision: "bfloat16"
seed: 1337
# ------------------------------------------------------------------------------
# TELEMETRY & LOGGING
# ------------------------------------------------------------------------------
logging:
tensorboard_dir: "./runs"
log_file: "training.log"
log_audio_freq: 500
-55
View File
@@ -1,55 +0,0 @@
# config/config_5090.yaml
################################################################################
# Soprano-Reforged Configuration
# Optimized for NVIDIA RTX 5090 (32GB VRAM)
# Extreme Performance Profile
################################################################################
# --- Common Settings ---
common:
sample_rate: 32000 # Audio sample rate (Hz)
seed: 1337 # Random seed for reproducibility
device: "cuda" # Force CUDA
# --- Stage 0: Codec (Encoder/Decoder) Training ---
codec:
# Architecture
input_mels: 80
dim: 512
layers: 8
bottleneck: 5
# Training
segment_size: 32768 # ~1 sec
batch_size: 128 # 4x standard
epochs: 100
lr: 1.0e-4
save_dir: "weights/codec"
# --- Stage 1: Dataset Generation ---
dataset:
val_split: 0.1
# --- Stage 2: Joint Training (LLM + Decoder) ---
training:
# Model
base_model: "ekwek/Soprano-1.1-80M"
seq_len: 1024 # Context window
# Hyperparameters - RTX 5090 EXTREME
batch_size: 64 # 8x larger than default (32GB VRAM can handle this for 80M model)
epochs: 1500 # Train longer by default
base_lr: 1.0e-4 # Learning rate
decoder_lr: 1.0e-4 # Learning rate for the Decoder
weight_decay: 0.01
grad_accum_steps: 1 # True gradients every step (No simulation needed)
# Joint Training Logic
decoder_step_freq: 1 # Train the Decoder EVERY step (Critical for convergence)
decoder_loss_weight: 1.0
# Checkpointing
checkpoint_freq: 500 # Save more often since it's fast
val_freq: 250 # Validate often
save_dir: "weights/model"
-54
View File
@@ -1,54 +0,0 @@
# config/settings.yaml
################################################################################
# Soprano-Reforged Configuration
# Optimized for RTX 4070 (12GB) - Exclusive Mode (No Background Apps)
################################################################################
# --- Common Settings ---
common:
sample_rate: 32000 # Audio sample rate (Hz)
seed: 1337 # Random seed for reproducibility
device: "auto" # "cuda", "cpu", or "auto" to detect
# --- Stage 0: Codec (Encoder/Decoder) Training ---
codec:
# Architecture
input_mels: 80 # Number of Mel bands for spectrogram
dim: 512 # Hidden dimension size
layers: 8 # Number of ConvNeXt layers
bottleneck: 5 # Bottleneck dimension (matches FSQ levels)
# Training
segment_size: 32768 # Audio segment length (samples) per step (~1 sec)
batch_size: 32 # UPGRADED: 4 -> 32 (Fast training for future runs)
epochs: 100 # Total training epochs
lr: 1.0e-4 # Learning rate
save_dir: "weights/codec" # Where to save encoder.pth / decoder.pth
# --- Stage 1: Dataset Generation ---
dataset:
val_split: 0.1 # Percentage of data to use for validation (0.1 = 10%)
# --- Stage 2: Joint Training (LLM + Decoder) ---
training:
# Model
base_model: "ekwek/Soprano-1.1-80M" # HuggingFace ID of the base LLM
seq_len: 1024 # Context window size (text + audio tokens)
# Hyperparameters - RTX 4070 OPTIMIZED
batch_size: 8 # The Safe Zone
epochs: 10 # Total training epochs
base_lr: 1.0e-4 # Learning rate for the LLM
decoder_lr: 1.0e-4 # Learning rate for the Decoder
weight_decay: 0.01 # Weight decay for optimizer
grad_accum_steps: 4 # The Speed Hack (Simulates batch size 32)
# Joint Training Logic
decoder_step_freq: 1 # Train the Decoder EVERY step (was 5)
decoder_loss_weight: 1.0 # Weight of the audio reconstruction loss
# Checkpointing
checkpoint_freq: 1000 # Save checkpoint every N steps
val_freq: 500 # Validate every N steps
save_dir: "weights/model" # Output directory for the final model
-96
View File
@@ -1,96 +0,0 @@
import sys
import subprocess
import os
import json
import argparse
from pathlib import Path
# Windows UTF-8 Support
sys.stdout.reconfigure(encoding='utf-8')
def run_command(cmd, desc):
print(f"\n[Step 1] Starting: {desc}")
print(f"Command: {' '.join(cmd)}")
try:
subprocess.check_call(cmd)
print(f"[Step 1] Completed: {desc}\n")
except subprocess.CalledProcessError as e:
print(f"[Step 1] Error during {desc}: {e}")
sys.exit(1)
def verify_data_prep(output_dir):
print("[Step 1] Verifying outputs...")
train_json = output_dir / "train.json"
val_json = output_dir / "val.json"
if not train_json.exists():
print(f"[ERROR] train.json not found at {train_json}")
sys.exit(1)
if not val_json.exists():
print(f"[ERROR] val.json not found at {val_json}")
sys.exit(1)
try:
with open(train_json, "r", encoding="utf-8") as f:
train_data = json.load(f)
with open(val_json, "r", encoding="utf-8") as f:
val_data = json.load(f)
print(f"[SUCCESS] Data Prep Complete!")
print(f" - Train Samples: {len(train_data)}")
print(f" - Val Samples: {len(val_data)}")
if len(train_data) == 0:
print("[WARNING] Train dataset is empty! Check your input metadata.")
except Exception as e:
print(f"[ERROR] Failed to read JSON outputs: {e}")
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="Soprano Custom Pipeline - Step 1: Data Prep")
parser.add_argument("--test", action="store_true", help="Run in test mode (limit to 50 samples)")
args = parser.parse_args()
root_dir = Path(os.getcwd())
dataset_dir = root_dir / "mio_dataset"
custom_dir = root_dir / "custom"
encoder_path = root_dir / "weights" / "codec" / "encoder.pth"
# Ensure custom directory exists
custom_dir.mkdir(exist_ok=True)
# Check inputs
if not dataset_dir.exists():
print(f"[ERROR] Dataset directory not found: {dataset_dir}")
print("Please ensure 'mio_dataset' is in the project root.")
sys.exit(1)
if not encoder_path.exists():
print(f"[ERROR] Encoder checkpoint not found: {encoder_path}")
sys.exit(1)
# Run Generation
script_path = root_dir / "generate_dataset.py"
if not script_path.exists():
print(f"[ERROR] generate_dataset.py not found at {script_path}")
sys.exit(1)
cmd = [
sys.executable, str(script_path),
"--input-dir", str(dataset_dir),
"--output-dir", str(custom_dir),
"--encoder-ckpt", str(encoder_path)
]
if args.test:
cmd.extend(["--limit", "50"])
run_command(cmd, "Data Preparation (Audio -> Tokens)")
# Verify
verify_data_prep(custom_dir)
if __name__ == "__main__":
main()
-77
View File
@@ -1,77 +0,0 @@
import sys
import subprocess
import os
import argparse
from pathlib import Path
# Windows UTF-8 Support
sys.stdout.reconfigure(encoding='utf-8')
def run_command(cmd, desc):
print(f"\n[Step 2] Starting: {desc}")
print(f"Command: {' '.join(cmd)}")
try:
subprocess.check_call(cmd)
print(f"[Step 2] Completed: {desc}\n")
except subprocess.CalledProcessError as e:
print(f"[Step 2] Error during {desc}: {e}")
sys.exit(1)
def verify_training(save_dir, epochs):
print("[Step 2] Verifying training artifacts...")
# Check for epoch folder
# Assuming epoch numbering starts at 0
last_epoch_idx = epochs - 1
epoch_dir = save_dir / f"epoch_{last_epoch_idx}"
decoder_pth = epoch_dir / "decoder.pth"
config_json = epoch_dir / "config.json"
if epoch_dir.exists() and decoder_pth.exists() and config_json.exists():
print(f"[SUCCESS] Training completed! Found checkpoint at: {epoch_dir}")
print(f"Artifacts verified: decoder.pth, config.json")
else:
# Fallback check (sometimes epochs might be saved differently or crashed)
print(f"[WARNING] Could not find specific epoch folder: {epoch_dir}")
print(f"Listing {save_dir} contents:")
for item in save_dir.glob("*"):
print(f" - {item.name}")
def main():
parser = argparse.ArgumentParser(description="Soprano Custom Pipeline - Step 2: Training")
parser.add_argument("--epochs", type=int, default=10, help="Number of training epochs")
parser.add_argument("--test", action="store_true", help="Run in test mode (1 epoch)")
args = parser.parse_args()
epochs = 1 if args.test else args.epochs
root_dir = Path(os.getcwd())
custom_dir = root_dir / "custom"
weights_dir = custom_dir / "weights" / "model"
# Check if Step 1 was run
train_json = custom_dir / "train.json"
if not train_json.exists():
print(f"[ERROR] train.json not found in {custom_dir}. Please run Step 1 first.")
sys.exit(1)
# Run Training
train_script = root_dir / "train.py"
if not train_script.exists():
print(f"[ERROR] train.py not found at {train_script}")
sys.exit(1)
cmd = [
sys.executable, str(train_script),
"--input-dir", str(custom_dir), # Read JSONs from custom dir
"--save-dir", str(weights_dir),
"--epochs", str(epochs)
]
run_command(cmd, "LLM Training (Stage 2)")
# Verify
verify_training(weights_dir, epochs)
if __name__ == "__main__":
main()
-82
View File
@@ -1,82 +0,0 @@
import sys
import subprocess
import os
import argparse
from pathlib import Path
# Windows UTF-8 Support
sys.stdout.reconfigure(encoding='utf-8')
def run_command(cmd, desc):
print(f"\n[Step 3] Starting: {desc}")
print(f"Command: {' '.join(cmd)}")
try:
subprocess.check_call(cmd)
print(f"[Step 3] Completed: {desc}\n")
except subprocess.CalledProcessError as e:
print(f"[Step 3] Error during {desc}: {e}")
sys.exit(1)
def verify_inference(output_path):
print("[Step 3] Verifying output audio...")
if not output_path.exists():
print(f"[ERROR] Output file not found: {output_path}")
sys.exit(1)
size = output_path.stat().st_size
if size < 1000: # 1KB is suspiciously small for audio
print(f"[WARNING] Output file is very small ({size} bytes). Generation might have failed or produced silence.")
else:
print(f"[SUCCESS] Audio generated at: {output_path}")
print(f"File Size: {size / 1024:.2f} KB")
def main():
parser = argparse.ArgumentParser(description="Soprano Custom Pipeline - Step 3: Inference")
parser.add_argument("--text", type=str, default="Hello world, this is a test of the custom pipeline.", help="Text to synthesize")
parser.add_argument("--epoch", type=int, default=None, help="Specific epoch number to use (default: latest)")
args = parser.parse_args()
root_dir = Path(os.getcwd())
custom_dir = root_dir / "custom"
weights_dir = custom_dir / "weights" / "model"
samples_dir = custom_dir / "samples"
samples_dir.mkdir(exist_ok=True)
# improved epoch finding logic
if args.epoch is not None:
model_path = weights_dir / f"epoch_{args.epoch}"
else:
# Find latest
epochs = sorted([d for d in weights_dir.glob("epoch_*") if d.is_dir()],
key=lambda x: int(x.name.split('_')[1]) if '_' in x.name else -1)
if epochs:
model_path = epochs[-1]
print(f"[Info] Using latest checkpoint: {model_path.name}")
else:
print(f"[ERROR] No checkpoints found at {weights_dir}")
sys.exit(1)
if not model_path.exists():
print(f"[ERROR] Model path does not exist: {model_path}")
sys.exit(1)
output_wav = samples_dir / "output.wav"
script_path = root_dir / "inference.py"
if not script_path.exists():
print(f"[ERROR] inference.py not found at {script_path}")
sys.exit(1)
cmd = [
sys.executable, str(script_path),
"--text", args.text,
"--model-dir", str(model_path),
"--output", str(output_wav)
]
run_command(cmd, "Inference Generation")
verify_inference(output_wav)
if __name__ == "__main__":
main()
-113
View File
@@ -1,113 +0,0 @@
import argparse
import subprocess
import sys
import os
from pathlib import Path
# Windows UTF-8 Support
sys.stdout.reconfigure(encoding='utf-8')
def run_command(cmd, desc):
print(f"\n[Pipeline] Starting: {desc}")
print(f"Command: {' '.join(cmd)}")
try:
subprocess.check_call(cmd)
print(f"[Pipeline] Completed: {desc}\n")
except subprocess.CalledProcessError as e:
print(f"[Pipeline] Error during {desc}: {e}")
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="Soprano Custom Pipeline Orchestrator")
parser.add_argument("--dataset-dir", type=str, default="mio_dataset", help="Input dataset directory")
parser.add_argument("--custom-dir", type=str, default="custom", help="Directory for pipeline artifacts")
# Flags for stages
parser.add_argument("--run-data-prep", action="store_true", help="Run generate_dataset.py")
parser.add_argument("--run-codec", action="store_true", help="Run train_codec.py (Stage 0)")
parser.add_argument("--run-training", action="store_true", help="Run train.py (Stage 2)")
parser.add_argument("--run-inference", action="store_true", help="Run inference.py")
# Training Loop Args
parser.add_argument("--epochs", type=int, default=10, help="Number of training epochs")
parser.add_argument("--text", type=str, default="Hello world, this is a test of the custom pipeline.", help="Inference text")
args = parser.parse_args()
# Setup Paths
root_dir = Path(os.getcwd())
dataset_dir = root_dir / args.dataset_dir
custom_dir = root_dir / args.custom_dir
weights_dir = custom_dir / "weights"
samples_dir = custom_dir / "samples"
# Ensure directories exist
custom_dir.mkdir(exist_ok=True)
weights_dir.mkdir(exist_ok=True)
samples_dir.mkdir(exist_ok=True)
# Check for Encoder (required for data prep and training)
encoder_path = root_dir / "weights" / "codec" / "encoder.pth"
if not encoder_path.exists():
# Fallback to checking if it's in the standard location
encoder_path = root_dir / "weights" / "codec" / "encoder.pth"
if not encoder_path.exists():
print(f"Warning: Encoder not found at {encoder_path}. Data prep might fail if not downloaded.")
# --- Stage 1: Data Preparation ---
if args.run_data_prep:
cmd = [
sys.executable, "generate_dataset.py",
"--input-dir", str(dataset_dir),
"--output-dir", str(custom_dir), # Save JSONs to custom dir
"--encoder-ckpt", str(encoder_path)
]
run_command(cmd, "Data Preparation")
# --- Stage 0: Codec Training (Optional) ---
if args.run_codec:
codec_save_dir = weights_dir / "codec"
cmd = [
sys.executable, "train_codec.py",
"--wav-dir", str(dataset_dir / "wavs"),
"--save-dir", str(codec_save_dir),
"--epochs", str(args.epochs)
]
run_command(cmd, "Codec Training")
# --- Stage 2: LLM Training ---
if args.run_training:
model_save_dir = weights_dir / "model"
cmd = [
sys.executable, "train.py",
"--input-dir", str(custom_dir), # Read JSONs from custom dir
"--save-dir", str(model_save_dir),
"--epochs", str(args.epochs)
]
run_command(cmd, "LLM Training")
# --- Inference ---
if args.run_inference:
# Find latest epoch or use default
model_dir = weights_dir / "model"
# Try to find the last epoch
epochs = sorted([d for d in model_dir.glob("epoch_*") if d.is_dir()], key=lambda x: int(x.name.split('_')[1]))
if epochs:
latest_model = epochs[-1]
else:
latest_model = model_dir # Fallback
output_wav = samples_dir / "inference_output.wav"
cmd = [
sys.executable, "inference.py",
"--text", args.text,
"--model-dir", str(latest_model),
"--output", str(output_wav)
]
run_command(cmd, "Inference")
if __name__ == "__main__":
main()
+166 -30
View File
@@ -1,42 +1,178 @@
# dataset.py - Audio & Token Dataset Loader
# dataset.py - Unified Dataset and Collators for the 2-Stage Soprano Pipeline.
import json
from typing import List, Tuple, Dict
import numpy as np
import soundfile as sf
import torch
import torchaudio
from torch.utils.data import Dataset
from transformers import PreTrainedTokenizer
class AudioDataset(Dataset):
# Soprano Vocos ratio: 32000Hz / 2048 Total Hop Length
SAMPLES_PER_TOKEN = 2048
# ==============================================================================
# STAGE 1: LLM AUTOREGRESSIVE DATASET & COLLATOR
# ==============================================================================
class SopranoLLMDataset(Dataset):
"""Dataset for Step 1: Training the Causal Language Model."""
def __init__(self, json_path: str):
with open(json_path, 'r', encoding='utf-8') as f:
self.data = json.load(f)
def __len__(self) -> int:
return len(self.data)
def __getitem__(self, idx: int) -> str:
# JSON structure from generate_dataset.py: [text, audio_tokens, path]
text, audio_tokens, _ = self.data[idx]
audio_str = "".join([f"[{token}]" for token in audio_tokens])
formatted_sequence = f"[STOP][TEXT]{text}[START]{audio_str}[STOP]"
return formatted_sequence
class LLMCollator:
"""Dynamically pads and formats sequences for the causal LLM."""
def __init__(self, tokenizer: PreTrainedTokenizer):
self.tokenizer = tokenizer
def __call__(self, texts: List[str]) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
# Dynamic Batching: Pad to the longest in this specific batch
tokenized = self.tokenizer(
texts,
padding=True,
truncation=True,
max_length=2048,
return_tensors='pt',
add_special_tokens=False
)
batch = tokenized['input_ids']
attn_mask = tokenized['attention_mask']
# Shift inputs and targets for causal language modeling
x = batch[:, :-1]
y = batch[:, 1:]
attn_mask = attn_mask[:, :-1]
return x, y, attn_mask
# ==============================================================================
# STAGE 2: DECODER GAN DATASET & COLLATOR
# ==============================================================================
class SopranoDecoderDataset(Dataset):
"""Dataset for Step 2: Training the Vocos GAN to synthesize HD audio."""
def __init__(self, json_path: str, target_sr: int = 32000):
with open(json_path, 'r', encoding='utf-8') as f:
self.data = json.load(f)
self.target_sr = target_sr
def __len__(self) -> int:
return len(self.data)
def __getitem__(self, idx: int) -> Tuple[str, torch.Tensor, int]:
text, audio_tokens, wav_path = self.data[idx]
# Format Text
audio_str = "".join([f"[{token}]" for token in audio_tokens])
formatted_sequence = f"[STOP][TEXT]{text}[START]{audio_str}[STOP]"
# Load Raw Audio using Soundfile (Bypasses Windows torchcodec errors)
audio_np, sr = sf.read(wav_path, dtype='float32')
# Convert to proper Tensor shape: (Channels, Samples)
if audio_np.ndim == 1:
audio_np = np.expand_dims(audio_np, axis=0) # (1, T)
else:
audio_np = audio_np.T # (C, T)
wav = torch.from_numpy(audio_np)
# Mono conversion
if wav.shape[0] > 1:
wav = wav.mean(dim=0, keepdim=True)
# Resample if needed
if sr != self.target_sr:
wav = torchaudio.functional.resample(wav, orig_freq=sr, new_freq=self.target_sr)
wav = wav.squeeze(0) # Shape: (Samples,)
num_audio_tokens = len(audio_tokens)
return formatted_sequence, wav, num_audio_tokens
class DecoderCollator:
"""
Loads the JSON dataset generated by Stage 1.
Formats inputs into the specific text-audio sequence expected by Soprano.
Complex Collator for the GAN: Aligns raw audio samples with the LLM's
discrete tokens so the STFT Discriminators can grade specific slices.
"""
def __init__(self, path):
with open(path, "r", encoding="utf-8") as f:
self.dataset = json.load(f)
print(f"Loaded {len(self.dataset)} samples from {path}")
def __init__(self, tokenizer: PreTrainedTokenizer):
self.tokenizer = tokenizer
# Dynamically fetch boundaries so it doesn't break on tokenizer updates
self.audio_min_id = int(self.tokenizer.convert_tokens_to_ids("[0]")) # type: ignore
self.audio_max_id = int(self.tokenizer.convert_tokens_to_ids("[7999]")) # type: ignore
def __len__(self):
return len(self.dataset)
def __call__(self, batch_in: List[Tuple[str, torch.Tensor, int]]) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
texts = [x[0] for x in batch_in]
wavs = [x[1] for x in batch_in]
aud_token_lens = [x[2] for x in batch_in]
batch_tokens_list = []
batch_audio_list = []
for i in range(len(texts)):
# Tokenize raw
raw_tokens = self.tokenizer(texts[i], padding=False, truncation=False, add_special_tokens=False)['input_ids']
tokens = torch.tensor(raw_tokens, dtype=torch.long)
wav = wavs[i]
num_aud_tokens = aud_token_lens[i]
# Create a blank canvas for the aligned audio
target_length = num_aud_tokens * SAMPLES_PER_TOKEN
aligned_audio = torch.zeros(target_length, dtype=torch.float32)
# Vectorized Audio Slicing (Replaces the slow for-loop from the fork)
actual_length = min(wav.size(0), target_length)
aligned_audio[:actual_length] = wav[:actual_length]
# Validate integrity
is_audio = (tokens >= self.audio_min_id) & (tokens <= self.audio_max_id)
audio_indices = torch.where(is_audio)[0]
assert len(audio_indices) == num_aud_tokens, \
f"Token mismatch! Found {len(audio_indices)} audio tokens but expected {num_aud_tokens}."
batch_tokens_list.append(tokens)
batch_audio_list.append(aligned_audio)
def __getitem__(self, idx):
item = self.dataset[idx]
text = item["text"]
tokens = item["tokens"] # List of integers from Stage 1
wav_path = item["wav_path"]
# Pad sequences
pad_id = self.tokenizer.pad_token_id if self.tokenizer.pad_token_id is not None else 0
batch_tokens = torch.nn.utils.rnn.pad_sequence(batch_tokens_list, batch_first=True, padding_value=pad_id)
batch_audio = torch.nn.utils.rnn.pad_sequence(batch_audio_list, batch_first=True, padding_value=0.0)
# 1. Formatting the Token Sequence
# Soprano uses a specific vocabulary where each audio code is represented
# as a bracketed string: e.g., token 8500 becomes "[8500]".
token_str = "".join([f"[{t}]" for t in tokens])
# Shift inputs and targets
x = batch_tokens[:, :-1]
y = batch_tokens[:, 1:]
# 2. Constructing the Full Training Prompt
# [STOP] is the separator, [TEXT] marks the prompt, [START] marks the audio segment.
# This matches the inference prompt exactly: f"[STOP][TEXT]{norm_text}[START]"
formatted_text = f"[STOP][TEXT]{text}[START]{token_str}[STOP]"
# Crop audio to match the shifted 'x' tensor length
max_len_x = x.size(1)
gt_audio = batch_audio[:, :max_len_x * SAMPLES_PER_TOKEN]
# 3. Return Dictionary
# We return the raw text because the Collator handles tokenization on the fly.
# We return wav_path so the Decoder-step in Stage 2 can load ground-truth audio.
return {
"text": formatted_text,
"wav_path": wav_path,
}
# Create Boolean Mask where output equals audio token
audio_mask = (y >= self.audio_min_id) & (y <= self.audio_max_id)
return x, y, gt_audio, audio_mask
+45
View File
@@ -0,0 +1,45 @@
import torch
from torch import nn
from .models import VocosBackbone
from .heads import ISTFTHead
class SopranoDecoder(nn.Module):
def __init__(self,
num_input_channels=512,
decoder_num_layers=8,
decoder_dim=768,
decoder_intermediate_dim=None,
hop_length=512,
n_fft=2048,
upscale=4,
dw_kernel=3,
):
super().__init__()
self.decoder_initial_channels = num_input_channels
self.num_layers = decoder_num_layers
self.dim = decoder_dim
self.intermediate_dim = decoder_intermediate_dim if decoder_intermediate_dim else decoder_dim*3
self.hop_length = hop_length
self.n_fft = n_fft
self.upscale = upscale
self.dw_kernel = dw_kernel
self.decoder = VocosBackbone(input_channels=self.decoder_initial_channels,
dim=self.dim,
intermediate_dim=self.intermediate_dim,
num_layers=self.num_layers,
input_kernel_size=1,#dw_kernel,
dw_kernel_size=dw_kernel,
)
self.head = ISTFTHead(dim=self.dim,
n_fft=self.n_fft,
hop_length=self.hop_length)
def forward(self, x):
T = x.size(2)
x = torch.nn.functional.interpolate(x, size=self.upscale*(T-1)+1, mode='linear', align_corners=True)
x = self.decoder(x)
reconstructed = self.head(x)
return reconstructed
+143
View File
@@ -0,0 +1,143 @@
import torch
from torch import nn
from torch.nn import functional as F
from torch.nn.utils.parametrizations import weight_norm, spectral_norm
class DiscriminatorP(nn.Module):
def __init__(self, period, kernel_size=5, stride=3, use_spectral_norm=False):
super(DiscriminatorP, self).__init__()
self.period = period
self.use_spectral_norm = use_spectral_norm
norm_f = weight_norm if use_spectral_norm == False else spectral_norm
self.convs = nn.ModuleList([
norm_f(nn.Conv2d(1, 32, (kernel_size, 1), (stride, 1), padding=(2, 0))),
norm_f(nn.Conv2d(32, 128, (kernel_size, 1), (stride, 1), padding=(2, 0))),
norm_f(nn.Conv2d(128, 512, (kernel_size, 1), (stride, 1), padding=(2, 0))),
norm_f(nn.Conv2d(512, 1024, (kernel_size, 1), (stride, 1), padding=(2, 0))),
norm_f(nn.Conv2d(1024, 1024, (kernel_size, 1), 1, padding=(2, 0))),
])
self.conv_post = norm_f(nn.Conv2d(1024, 1, (3, 1), 1, padding=(1, 0)))
def forward(self, x):
fmap = []
# 1d to 2d
b, c, t = x.shape
if t % self.period != 0: # pad valid
n_pad = self.period - (t % self.period)
x = F.pad(x, (0, n_pad), "reflect")
t = t + n_pad
x = x.view(b, c, t // self.period, self.period)
for l in self.convs:
x = l(x)
x = F.leaky_relu(x, 0.1)
fmap.append(x)
x = self.conv_post(x)
fmap.append(x)
x = torch.flatten(x, 1, -1)
return x, fmap
class DiscriminatorS(nn.Module):
def __init__(self, use_spectral_norm=False):
super(DiscriminatorS, self).__init__()
norm_f = weight_norm if use_spectral_norm == False else spectral_norm
self.convs = nn.ModuleList([
norm_f(nn.Conv1d(1, 16, 15, 1, padding=7)),
norm_f(nn.Conv1d(16, 64, 41, 4, groups=4, padding=20)),
norm_f(nn.Conv1d(64, 256, 41, 4, groups=16, padding=20)),
norm_f(nn.Conv1d(256, 1024, 41, 4, groups=64, padding=20)),
norm_f(nn.Conv1d(1024, 1024, 41, 4, groups=256, padding=20)),
norm_f(nn.Conv1d(1024, 1024, 5, 1, padding=2)),
])
self.conv_post = norm_f(nn.Conv1d(1024, 1, 3, 1, padding=1))
def forward(self, x):
fmap = []
for l in self.convs:
x = l(x)
x = F.leaky_relu(x, 0.1)
fmap.append(x)
x = self.conv_post(x)
fmap.append(x)
x = torch.flatten(x, 1, -1)
return x, fmap
class MultiPeriodDiscriminator(nn.Module):
def __init__(self, use_spectral_norm=False):
super(MultiPeriodDiscriminator, self).__init__()
self.discriminators = nn.ModuleList([
DiscriminatorP(2, use_spectral_norm=use_spectral_norm),
DiscriminatorP(3, use_spectral_norm=use_spectral_norm),
DiscriminatorP(5, use_spectral_norm=use_spectral_norm),
DiscriminatorP(7, use_spectral_norm=use_spectral_norm),
DiscriminatorP(11, use_spectral_norm=use_spectral_norm),
])
def forward(self, y, y_hat):
y_d_rs = []
y_d_gs = []
fmap_rs = []
fmap_gs = []
for i, d in enumerate(self.discriminators):
y_d_r, fmap_r = d(y)
y_d_g, fmap_g = d(y_hat)
y_d_rs.append(y_d_r)
y_d_gs.append(y_d_g)
fmap_rs.append(fmap_r)
fmap_gs.append(fmap_g)
return y_d_rs, y_d_gs, fmap_rs, fmap_gs
class MultiScaleDiscriminator(nn.Module):
def __init__(self, use_spectral_norm=False):
super(MultiScaleDiscriminator, self).__init__()
self.discriminators = nn.ModuleList([
DiscriminatorS(use_spectral_norm=use_spectral_norm),
DiscriminatorS(use_spectral_norm=use_spectral_norm),
DiscriminatorS(use_spectral_norm=use_spectral_norm),
])
self.meanpools = nn.ModuleList([
nn.AvgPool1d(4, 2, padding=2),
nn.AvgPool1d(4, 2, padding=2)
])
def forward(self, y, y_hat):
y_d_rs = []
y_d_gs = []
fmap_rs = []
fmap_gs = []
for i, d in enumerate(self.discriminators):
if i != 0:
y = self.meanpools[i-1](y)
y_hat = self.meanpools[i-1](y_hat)
y_d_r, fmap_r = d(y)
y_d_g, fmap_g = d(y_hat)
y_d_rs.append(y_d_r)
y_d_gs.append(y_d_g)
fmap_rs.append(fmap_r)
fmap_gs.append(fmap_g)
return y_d_rs, y_d_gs, fmap_rs, fmap_gs
class Discriminator(nn.Module):
def __init__(self):
super().__init__()
self.mpd = MultiPeriodDiscriminator()
self.msd = MultiScaleDiscriminator()
def forward(self, y, y_hat):
# y: real audio, y_hat: gen audio
# Unsqueeze if needed (B, T) -> (B, 1, T)
if y.ndim == 2: y = y.unsqueeze(1)
if y_hat.ndim == 2: y_hat = y_hat.unsqueeze(1)
y_d_rs_p, y_d_gs_p, fmap_rs_p, fmap_gs_p = self.mpd(y, y_hat)
y_d_rs_s, y_d_gs_s, fmap_rs_s, fmap_gs_s = self.msd(y, y_hat)
return (y_d_rs_p + y_d_rs_s, y_d_gs_p + y_d_gs_s, fmap_rs_p + fmap_rs_s, fmap_gs_p + fmap_gs_s)
+50
View File
@@ -0,0 +1,50 @@
import torch
from torch import nn
from .spectral_ops import ISTFT
class ISTFTHead(nn.Module):
"""
ISTFT Head module for predicting STFT complex coefficients.
Args:
dim (int): Hidden dimension of the model.
n_fft (int): Size of Fourier transform.
hop_length (int): The distance between neighboring sliding window frames, which should align with
the resolution of the input features.
padding (str, optional): Type of padding. Options are "center" or "same". Defaults to "same".
"""
def __init__(self, dim: int, n_fft: int, hop_length: int, padding: str = "center"):
super().__init__()
out_dim = n_fft + 2
self.out = torch.nn.Linear(dim, out_dim)
self.istft = ISTFT(n_fft=n_fft, hop_length=hop_length, win_length=n_fft, padding=padding)
@torch.compiler.disable
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Forward pass of the ISTFTHead module.
Args:
x (Tensor): Input tensor of shape (B, L, H), where B is the batch size,
L is the sequence length, and H denotes the model dimension.
Returns:
Tensor: Reconstructed time-domain audio signal of shape (B, T), where T is the length of the output signal.
"""
x = self.out(x.transpose(1,2)).transpose(1, 2)
mag, p = x.chunk(2, dim=1)
mag = torch.exp(mag)
mag = torch.clip(mag, max=1e2) # safeguard to prevent excessively large magnitudes
# wrapping happens here. These two lines produce real and imaginary value
x = torch.cos(p)
y = torch.sin(p)
# recalculating phase here does not produce anything new
# only costs time
# phase = torch.atan2(y, x)
# S = mag * torch.exp(phase * 1j)
# better directly produce the complex value
S = mag * (x + 1j * y)
audio = self.istft(S)
return audio
+144
View File
@@ -0,0 +1,144 @@
import torch
import torch.nn.functional as F
import torchaudio
def dynamic_range_compression_torch(x, C=1, clip_val: float = 5e-3):
return torch.log(torch.clamp(x, min=clip_val) * C)
def spectral_normalize_torch(magnitudes):
output = dynamic_range_compression_torch(magnitudes)
return output
# Mel Spectrogram initialization logic
# Note: Should we initialize the transform here or pass it in?
# For simplicity, we can provide a factory function or a class.
# But original code used global `mel_transform`.
# To keep it clean, let's wrap it in a class or function that returns the transform.
class MelSpectrogramWrapper(torch.nn.Module):
def __init__(self, sample_rate=32000, n_fft=2048, hop_length=512, n_mels=50):
super().__init__()
self.mel_transform = torchaudio.transforms.MelSpectrogram(
sample_rate=sample_rate, n_fft=n_fft, hop_length=hop_length,
n_mels=n_mels, center=True, power=1
)
def forward(self, audio):
mel_spec = self.mel_transform(audio)
mel_spec = spectral_normalize_torch(mel_spec)
return mel_spec
def feature_matching_loss(fmap_r, fmap_g):
loss = 0
for dr, dg in zip(fmap_r, fmap_g):
for rl, gl in zip(dr, dg):
loss += torch.mean(torch.abs(rl - gl))
return loss * 2
def discriminator_loss(disc_real_outputs, disc_generated_outputs):
loss = 0
r_losses = []
g_losses = []
for dr, dg in zip(disc_real_outputs, disc_generated_outputs):
r_loss = torch.mean((1-dr)**2)
g_loss = torch.mean(dg**2)
loss += (r_loss + g_loss)
r_losses.append(r_loss.item())
g_losses.append(g_loss.item())
return loss, r_losses, g_losses
def generator_loss(disc_outputs):
loss = 0
gen_losses = []
for dg in disc_outputs:
l = torch.mean((1-dg)**2)
gen_losses.append(l)
loss += l
return loss, gen_losses
# -----------------
# Multi-Resolution STFT Loss
# -----------------
def stft(x, fft_size, hop_size, win_length, window):
"""Perform STFT and return linear mag spectogram."""
# x: (B, T)
x_stft = torch.stft(x, fft_size, hop_size, win_length, window, center=True, return_complex=True)
x_mag = torch.abs(x_stft)
return x_mag
def spectral_convergence_loss(x_mag, y_mag):
"""
Spectral convergence loss.
"""
return torch.norm(y_mag - x_mag, p="fro") / (torch.norm(y_mag, p="fro") + 1e-7)
def log_magnitude_loss(x_mag, y_mag):
"""
Log-magnitude L1 loss.
"""
return F.l1_loss(torch.log(x_mag), torch.log(y_mag))
class STFTLoss(torch.nn.Module):
"""
STFT Loss module.
"""
def __init__(self, fft_size, hop_size, win_length, window="hann_window"):
super(STFTLoss, self).__init__()
self.fft_size = fft_size
self.hop_size = hop_size
self.win_length = win_length
self.register_buffer("window", getattr(torch, window)(win_length))
def forward(self, x, y):
"""
Args:
x (Tensor): Predicted audio (B, T).
y (Tensor): Target audio (B, T).
"""
x_mag = stft(x, self.fft_size, self.hop_size, self.win_length, self.window)
y_mag = stft(y, self.fft_size, self.hop_size, self.win_length, self.window)
# Add epsilon to prevent log(0)
x_mag = torch.clamp(x_mag, min=1e-7)
y_mag = torch.clamp(y_mag, min=1e-7)
sc_loss = spectral_convergence_loss(x_mag, y_mag)
mag_loss = log_magnitude_loss(x_mag, y_mag)
return sc_loss, mag_loss
class MultiResolutionSTFTLoss(torch.nn.Module):
"""
Multi-Resolution STFT Loss module.
"""
def __init__(self,
fft_sizes=[1024, 2048, 512],
hop_sizes=[120, 240, 50],
win_lengths=[600, 1200, 240], # Standard HiFiGAN config
window="hann_window", factor_sc=1.0, factor_mag=1.0):
super(MultiResolutionSTFTLoss, self).__init__()
assert len(fft_sizes) == len(hop_sizes) == len(win_lengths)
self.stft_losses = torch.nn.ModuleList()
for fs, hs, wl in zip(fft_sizes, hop_sizes, win_lengths):
self.stft_losses.append(STFTLoss(fs, hs, wl, window))
self.factor_sc = factor_sc
self.factor_mag = factor_mag
def forward(self, x, y):
sc_loss = 0.0
mag_loss = 0.0
for f in self.stft_losses:
sc_l, mag_l = f(x, y)
sc_loss += sc_l
mag_loss += mag_l
sc_loss /= len(self.stft_losses)
mag_loss /= len(self.stft_losses)
return sc_loss, mag_loss
+61
View File
@@ -0,0 +1,61 @@
from typing import Optional
import torch
from torch import nn
from .modules import ConvNeXtBlock
class VocosBackbone(nn.Module):
"""
Vocos backbone module built with ConvNeXt blocks. Supports additional conditioning with Adaptive Layer Normalization
Args:
input_channels (int): Number of input features channels.
dim (int): Hidden dimension of the model.
intermediate_dim (int): Intermediate dimension used in ConvNeXtBlock.
num_layers (int): Number of ConvNeXtBlock layers.
layer_scale_init_value (float, optional): Initial value for layer scaling. Defaults to `1 / num_layers`.
"""
def __init__(
self,
input_channels: int,
dim: int,
intermediate_dim: int,
num_layers: int,
input_kernel_size: int = 9,
dw_kernel_size: int = 9,
layer_scale_init_value: Optional[float] = None,
pad: str = 'zeros',
):
super().__init__()
self.embed = nn.Conv1d(input_channels, dim, kernel_size=input_kernel_size, padding=input_kernel_size//2, padding_mode=pad)
self.norm = nn.LayerNorm(dim, eps=1e-6)
self.convnext = nn.ModuleList(
[
ConvNeXtBlock(
dim=dim,
intermediate_dim=intermediate_dim,
dw_kernel_size=dw_kernel_size,
layer_scale_init_value=layer_scale_init_value or 1 / num_layers**0.5,
)
for _ in range(num_layers)
]
)
self.final_layer_norm = nn.LayerNorm(dim, eps=1e-6)
self.apply(self._init_weights)
def _init_weights(self, m):
if isinstance(m, (nn.Conv1d, nn.Linear)):
nn.init.trunc_normal_(m.weight, std=0.02)
if m.bias is not None: nn.init.constant_(m.bias, 0)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = self.embed(x) # (B, C, L)
x = self.norm(x.transpose(1, 2))
x = x.transpose(1, 2)
for conv_block in self.convnext:
x = conv_block(x)
x = self.final_layer_norm(x.transpose(1, 2))
x = x.transpose(1, 2)
return x
+47
View File
@@ -0,0 +1,47 @@
import torch
from torch import nn
class ConvNeXtBlock(nn.Module):
"""ConvNeXt Block adapted from https://github.com/facebookresearch/ConvNeXt to 1D audio signal.
Args:
dim (int): Number of input channels.
intermediate_dim (int): Dimensionality of the intermediate layer.
layer_scale_init_value (float, optional): Initial value for the layer scale. None means no scaling.
Defaults to None.
"""
def __init__(
self,
dim: int,
intermediate_dim: int,
layer_scale_init_value: float,
dw_kernel_size: int = 9,
):
super().__init__()
self.dwconv = nn.Conv1d(dim, dim, kernel_size=dw_kernel_size, padding=dw_kernel_size//2, groups=dim) # depthwise conv
self.norm = nn.LayerNorm(dim, eps=1e-6)
self.pwconv1 = nn.Linear(dim, intermediate_dim) # pointwise/1x1 convs, implemented with linear layers
self.act = nn.GELU()
self.pwconv2 = nn.Linear(intermediate_dim, dim)
self.gamma = (
nn.Parameter(layer_scale_init_value * torch.ones(dim), requires_grad=True)
if layer_scale_init_value > 0
else None
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
residual = x
x = self.dwconv(x)
x = x.transpose(1, 2) # (B, C, T) -> (B, T, C)
x = self.norm(x)
x = self.pwconv1(x)
x = self.act(x)
x = self.pwconv2(x)
if self.gamma is not None:
x = self.gamma * x
x = x.transpose(1, 2) # (B, T, C) -> (B, C, T)
x = residual + x
return x
+74
View File
@@ -0,0 +1,74 @@
import torch
from torch import nn
class ISTFT(nn.Module):
"""
Custom implementation of ISTFT since torch.istft doesn't allow custom padding (other than `center=True`) with
windowing. This is because the NOLA (Nonzero Overlap Add) check fails at the edges.
See issue: https://github.com/pytorch/pytorch/issues/62323
Specifically, in the context of neural vocoding we are interested in "same" padding analogous to CNNs.
The NOLA constraint is met as we trim padded samples anyway.
Args:
n_fft (int): Size of Fourier transform.
hop_length (int): The distance between neighboring sliding window frames.
win_length (int): The size of window frame and STFT filter.
padding (str, optional): Type of padding. Options are "center" or "same". Defaults to "same".
"""
def __init__(self, n_fft: int, hop_length: int, win_length: int, padding: str = "same"):
super().__init__()
if padding not in ["center", "same"]:
raise ValueError("Padding must be 'center' or 'same'.")
self.padding = padding
self.n_fft = n_fft
self.hop_length = hop_length
self.win_length = win_length
window = torch.hann_window(win_length)
self.register_buffer("window", window)
def forward(self, spec: torch.Tensor) -> torch.Tensor:
"""
Compute the Inverse Short Time Fourier Transform (ISTFT) of a complex spectrogram.
Args:
spec (Tensor): Input complex spectrogram of shape (B, N, T), where B is the batch size,
N is the number of frequency bins, and T is the number of time frames.
Returns:
Tensor: Reconstructed time-domain signal of shape (B, L), where L is the length of the output signal.
"""
if self.padding == "center":
spec[:,0] = 0 # fixes some strange bug where first/last freqs don't matter when bs<16 which causes exploding gradients
spec[:,-1] = 0
# Fallback to pytorch native implementation
return torch.istft(spec, self.n_fft, self.hop_length, self.win_length, self.window, center=True)
elif self.padding == "same":
pad = (self.win_length - self.hop_length) // 2
else:
raise ValueError("Padding must be 'center' or 'same'.")
assert spec.dim() == 3, "Expected a 3D tensor as input"
B, N, T = spec.shape
# Inverse FFT
ifft = torch.fft.irfft(spec, self.n_fft, dim=1, norm="backward")
ifft = ifft * self.window[None, :, None]
# Overlap and Add
output_size = (T - 1) * self.hop_length + self.win_length
y = torch.nn.functional.fold(
ifft, output_size=(1, output_size), kernel_size=(1, self.win_length), stride=(1, self.hop_length),
)[:, 0, 0, pad:-pad]
# Window envelope
window_sq = self.window.square().expand(1, T, -1).transpose(1, 2)
window_envelope = torch.nn.functional.fold(
window_sq, output_size=(1, output_size), kernel_size=(1, self.win_length), stride=(1, self.hop_length),
).squeeze()[pad:-pad]
# Normalize
assert (window_envelope > 1e-11).all()
y = y / window_envelope
return y
-2
View File
@@ -1,2 +0,0 @@
example1|Soprano is an extremely lightweight text to speech model designed to produce highly realistic speech at unprecedented speed.
example2|Gabagool? Ova here!
Binary file not shown.
Binary file not shown.
+137 -139
View File
@@ -1,152 +1,150 @@
# generate_dataset.py
# generate_dataset.py - Preprocesses raw audio and text data into quantized tokens for SopranoTTS training.
import argparse
import json
import os
import sys
import random
# Windows UTF-8 Support
sys.stdout.reconfigure(encoding='utf-8')
import torch
import torchaudio
import soundfile as sf
from tqdm import tqdm
import yaml
from pathlib import Path
# Import our components
from model.encoder import Encoder
from utils.text_normalizer import normalize_text
# Import Config
from utils.config import cfg
import numpy as np
import torch
import torchaudio
from huggingface_hub import hf_hub_download
from scipy.io import wavfile
from tqdm import tqdm
from codec.encoder.codec import Encoder
class DatasetGenerator:
"""
Handles the preprocessing of raw audio and text data into quantized
tokens suitable for training the Soprano causal language model.
"""
def __init__(self, config_path: str):
with open(config_path, 'r') as f:
self.config = yaml.safe_load(f)
self.input_dir = Path(self.config['dataset']['input_dir'])
self.sample_rate = self.config['dataset']['sample_rate']
self.val_prop = self.config['dataset']['val_split_prop']
self.seed = self.config['optimizations']['seed']
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
self.encoder = self._load_encoder()
def _load_encoder(self) -> Encoder:
"""Downloads and loads the frozen Soprano audio encoder."""
print("Loading Pre-trained Base Encoder...")
encoder = Encoder()
encoder_path = hf_hub_download(repo_id='ekwek/Soprano-Encoder', filename='encoder.pth')
encoder.load_state_dict(torch.load(encoder_path, map_location='cpu'))
encoder.to(self.device)
encoder.eval()
return encoder
def _normalize_audio(self, audio_np: np.ndarray) -> torch.Tensor:
"""Safely converts numpy audio to a normalized float32 tensor in [-1.0, 1.0]."""
if audio_np.ndim == 2:
audio_np = audio_np.T
if audio_np.dtype == np.int16:
audio = torch.from_numpy(audio_np).to(torch.float32) / 32768.0
elif audio_np.dtype == np.int32:
audio = torch.from_numpy(audio_np).to(torch.float32) / 2147483648.0
else:
audio = torch.from_numpy(audio_np).to(torch.float32)
if audio.ndim == 1:
audio = audio.unsqueeze(0)
return audio
def generate(self) -> None:
metadata_path = self.input_dir / 'metadata.csv'
if not metadata_path.exists():
raise FileNotFoundError(f"Metadata file not found at {metadata_path}")
print("Reading metadata...")
samples = []
with open(metadata_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line:
continue
parts = line.split('|')
if len(parts) >= 2:
filename = parts[0].strip()
transcript = parts[1].strip()
samples.append({"filename": filename, "text": transcript})
print(f"Processing {len(samples)} audio samples...")
dataset = []
for sample in tqdm(samples):
wav_path = self.input_dir / 'wavs' / f"{sample['filename']}.wav"
if not wav_path.exists():
print(f"\n [!] Missing audio file: {wav_path}. Skipping.")
continue
# Load, normalize, and resample
try:
sr, audio_np = wavfile.read(wav_path)
except Exception as e:
print(f"\n [!] Failed to read {wav_path}: {e}")
continue
audio_tensor = self._normalize_audio(audio_np)
if sr != self.sample_rate:
audio_tensor = torchaudio.functional.resample(
audio_tensor, orig_freq=sr, new_freq=self.sample_rate
)
# Encode to discrete tokens
audio_tensor = audio_tensor.to(self.device)
with torch.no_grad():
audio_tokens = self.encoder(audio_tensor)
# CRITICAL FOR 2-STAGE: Save absolute path for the Decoder GAN
abs_audio_path = str(wav_path.resolve())
# Output format matches the fork's expected dataset format exactly
dataset.append([
sample["text"],
audio_tokens.squeeze(0).cpu().tolist(),
abs_audio_path
])
print("Generating train/test splits...")
random.seed(self.seed)
random.shuffle(dataset)
num_val = int(self.val_prop * len(dataset))
num_val = max(1, min(num_val, 512)) # Ensure at least 1 val sample, cap at 512
val_dataset = dataset[:num_val]
train_dataset = dataset[num_val:]
print(f"Train samples: {len(train_dataset)}")
print(f"Val samples: {len(val_dataset)}")
with open(self.input_dir / 'train.json', 'w', encoding='utf-8') as f:
json.dump(train_dataset, f, indent=2)
with open(self.input_dir / 'val.json', 'w', encoding='utf-8') as f:
json.dump(val_dataset, f, indent=2)
print("Datasets saved successfully.")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--input-dir", type=str, required=True, help="Path to LJSpeech-formatted dataset")
parser.add_argument("--encoder-ckpt", type=str, required=True, help="Path to your trained encoder.pth")
parser.add_argument("--output-dir", type=str, default=None, help="Where to save train.json/val.json")
parser.add_argument("--limit", type=int, default=None, help="Limit number of samples (for testing)")
parser = argparse.ArgumentParser(description="Generate Soprano TTS Dataset")
parser.add_argument("--config", type=str, default="config.yaml", help="Path to config file")
args = parser.parse_args()
# 1. Setup Paths
input_dir = Path(args.input_dir)
output_dir = Path(args.output_dir) if args.output_dir else input_dir
wav_dir = input_dir / "wavs"
meta_path = input_dir / "metadata.txt"
generator = DatasetGenerator(config_path=args.config)
generator.generate()
if not meta_path.exists():
if (input_dir / "metadata.csv").exists():
meta_path = input_dir / "metadata.csv"
else:
raise FileNotFoundError(f"Could not find metadata.txt or metadata.csv at {input_dir}")
# 2. Load Encoder (Strict Float32)
device = cfg.device
print(f"Stage 1: Tokenizing dataset using Float32 precision...")
print(f"Loading Encoder: {args.encoder_ckpt}")
encoder = Encoder(
num_input_mels=cfg.codec['input_mels'],
encoder_dim=cfg.codec['dim'],
encoder_layers=cfg.codec['layers'],
bottleneck_channels=cfg.codec['bottleneck']
).to(device).float() # Explicitly ensure Float32
encoder.load_state_dict(torch.load(args.encoder_ckpt, map_location=device, weights_only=True))
encoder.eval()
# 3. Read Metadata
print(f"Reading metadata from {meta_path.name}...")
samples = []
with open(meta_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line: continue
parts = line.split("|")
if len(parts) >= 2:
file_id = parts[0]
raw_text = parts[1]
clean_text = normalize_text(raw_text)
# Support common Windows path naming conventions
wav_path = wav_dir / f"{file_id}.wav"
if not wav_path.exists():
wav_path = wav_dir / f"{file_id}.WAV"
if not wav_path.exists():
continue
samples.append({
"id": file_id,
"text": clean_text,
"wav_path": str(wav_path.absolute())
})
print(f"Found {len(samples)} valid samples.")
if args.limit:
print(f"[Test Mode] Limiting to first {args.limit} samples.")
samples = samples[:args.limit]
# 4. Process Audio -> Tokens
dataset = []
target_sr = cfg.common['sample_rate']
with torch.no_grad():
for sample in tqdm(samples, desc="Encoding Audio"):
try:
# Load as Float32
wav_np, sr = sf.read(sample["wav_path"])
wav = torch.from_numpy(wav_np).float()
if wav.ndim == 1: wav = wav.unsqueeze(0)
else: wav = wav.t()
# Resample if needed
if sr != target_sr:
wav = torchaudio.functional.resample(wav, sr, target_sr)
# Force Mono
if wav.shape[0] > 1:
wav = wav.mean(dim=0, keepdim=True)
wav = wav.to(device)
# Get integer indices for the LLM to learn
tokens = encoder(wav, return_indices=True)
token_list = tokens.squeeze().cpu().tolist()
# Ensure it's a list even for single-token results
if isinstance(token_list, int):
token_list = [token_list]
dataset.append({
"text": sample["text"],
"tokens": token_list,
"wav_path": sample["wav_path"]
})
except Exception as e:
print(f"Error processing {sample['id']}: {e}")
# 5. Split and Save
random.seed(cfg.common['seed'])
random.shuffle(dataset)
val_split = cfg.dataset['val_split']
split_idx = int(len(dataset) * (1 - val_split))
train_data = dataset[:split_idx]
val_data = dataset[split_idx:]
print(f"Saving {len(train_data)} train and {len(val_data)} validation samples...")
with open(output_dir / "train.json", "w", encoding="utf-8") as f:
json.dump(train_data, f, indent=2)
with open(output_dir / "val.json", "w", encoding="utf-8") as f:
json.dump(val_data, f, indent=2)
print("Stage 1 Complete. Tokens are ready for Stage 2.")
if __name__ == "__main__":
if __name__ == '__main__':
main()
-190
View File
@@ -1,190 +0,0 @@
# gui.pyw - Soprano Reforged V3 Training Factory
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import sv_ttk
import subprocess
import threading
import sys
import queue
import os
class SopranoGUI(tk.Tk):
def __init__(self):
super().__init__()
self.title("Soprano Reforged V3 - Training Factory")
self.geometry("900x700")
# Queue for thread-safe GUI updates
self.log_queue = queue.Queue()
self.is_running = False
# --- Layout ---
self.create_widgets()
# --- Theme ---
sv_ttk.set_theme("dark")
self.refresh_display()
# --- Log Loop ---
self.after(100, self.process_logs)
def refresh_display(self):
self.update_idletasks()
self.wm_attributes("-alpha", 0.99)
self.wm_attributes("-alpha", 1.0)
def create_widgets(self):
main_frame = ttk.Frame(self)
main_frame.pack(fill="both", expand=True, padx=10, pady=10)
# 1. Tabs for Stages
self.notebook = ttk.Notebook(main_frame)
self.notebook.pack(fill="both", expand=True, pady=(0, 10))
self.tab_codec = ttk.Frame(self.notebook, padding=20)
self.tab_dataset = ttk.Frame(self.notebook, padding=20)
self.tab_train = ttk.Frame(self.notebook, padding=20)
self.tab_infer = ttk.Frame(self.notebook, padding=20)
self.notebook.add(self.tab_codec, text="Stage 0: Codec")
self.notebook.add(self.tab_dataset, text="Stage 1: Dataset")
self.notebook.add(self.tab_train, text="Stage 2: Training")
self.notebook.add(self.tab_infer, text="Inference")
self.build_codec_tab()
self.build_dataset_tab()
self.build_train_tab()
self.build_infer_tab()
# 2. Console Output
console_frame = ttk.LabelFrame(main_frame, text="V3 System Output", padding=10)
console_frame.pack(fill="both", expand=True)
self.console = tk.Text(console_frame, height=12, state="disabled", bg="#1c1c1c", fg="#f0f0f0", font=("Consolas", 10))
self.console.pack(fill="both", expand=True, side="left")
scrollbar = ttk.Scrollbar(console_frame, command=self.console.yview)
scrollbar.pack(side="right", fill="y")
self.console.config(yscrollcommand=scrollbar.set)
# --- Tab Builders ---
def build_codec_tab(self):
f = self.tab_codec
ttk.Label(f, text="Stage 0: Audio Codec (Float32)", font=("Segoe UI", 14, "bold")).pack(anchor="w", pady=(0, 10))
ttk.Label(f, text="WAV Directory (e.g. ./mio_dataset/wavs/*.wav)").pack(anchor="w")
self.codec_wav_entry = ttk.Entry(f)
self.codec_wav_entry.pack(fill="x", pady=(5, 10))
self.codec_wav_entry.insert(0, "./mio_dataset/wavs/*.wav")
btn_frame = ttk.Frame(f)
btn_frame.pack(fill="x", pady=10)
ttk.Button(btn_frame, text="Browse Folder", command=lambda: self.browse_folder(self.codec_wav_entry, suffix="/*.wav")).pack(side="left", padx=(0, 10))
ttk.Button(btn_frame, text="Start Codec Training", style="Accent.TButton",
command=lambda: self.run_script("train_codec.py", ["--wav-dir", self.codec_wav_entry.get()])).pack(side="left")
def build_dataset_tab(self):
f = self.tab_dataset
ttk.Label(f, text="Stage 1: Token Generation", font=("Segoe UI", 14, "bold")).pack(anchor="w", pady=(0, 10))
ttk.Label(f, text="Dataset Input Directory").pack(anchor="w")
self.ds_input_entry = ttk.Entry(f)
self.ds_input_entry.pack(fill="x", pady=(5, 10))
self.ds_input_entry.insert(0, "./mio_dataset")
ttk.Label(f, text="Encoder Checkpoint").pack(anchor="w")
self.ds_encoder_entry = ttk.Entry(f)
self.ds_encoder_entry.pack(fill="x", pady=(5, 10))
self.ds_encoder_entry.insert(0, "./weights/codec/encoder.pth")
ttk.Button(f, text="Generate Dataset", style="Accent.TButton",
command=lambda: self.run_script("generate_dataset.py", [
"--input-dir", self.ds_input_entry.get(),
"--encoder-ckpt", self.ds_encoder_entry.get()
])).pack(anchor="w", pady=10)
def build_train_tab(self):
f = self.tab_train
ttk.Label(f, text="Stage 2: Joint V3 Training", font=("Segoe UI", 14, "bold")).pack(anchor="w", pady=(0, 10))
ttk.Label(f, text="Dataset Directory (containing train.json)").pack(anchor="w")
self.train_input_entry = ttk.Entry(f)
self.train_input_entry.pack(fill="x", pady=(5, 10))
self.train_input_entry.insert(0, "./mio_dataset")
ttk.Button(f, text="Start Speed Run", style="Accent.TButton",
command=lambda: self.run_script("train.py", ["--input-dir", self.train_input_entry.get()])).pack(anchor="w")
def build_infer_tab(self):
f = self.tab_infer
ttk.Label(f, text="Inference: Generate Speech", font=("Segoe UI", 14, "bold")).pack(anchor="w", pady=(0, 10))
ttk.Label(f, text="Enter Text:").pack(anchor="w")
self.infer_text = ttk.Entry(f)
self.infer_text.pack(fill="x", pady=(5, 10))
self.infer_text.insert(0, "Soprano V3 is now fully operational.")
ttk.Label(f, text="Model Directory (e.g. weights/model/epoch_9)").pack(anchor="w")
self.infer_model_entry = ttk.Entry(f)
self.infer_model_entry.pack(fill="x", pady=(5, 10))
self.infer_model_entry.insert(0, "./weights/model/epoch_9")
ttk.Button(f, text="Generate .WAV", style="Accent.TButton",
command=lambda: self.run_script("inference.py", [
"--text", self.infer_text.get(),
"--model-dir", self.infer_model_entry.get(),
"--output", "v3_output.wav"
])).pack(anchor="w", pady=10)
# --- Subprocess Logic ---
def browse_folder(self, entry_widget, suffix=""):
path = filedialog.askdirectory()
if path:
entry_widget.delete(0, tk.END)
entry_widget.insert(0, path + suffix)
def log(self, message):
self.log_queue.put(message)
def process_logs(self):
while not self.log_queue.empty():
msg = self.log_queue.get()
self.console.config(state="normal")
self.console.insert(tk.END, msg)
self.console.see(tk.END)
self.console.config(state="disabled")
self.after(100, self.process_logs)
def run_script(self, script_name, args):
if self.is_running:
messagebox.showwarning("Busy", "Process already running.")
return
self.is_running = True
self.console.config(state="normal")
self.console.delete(1.0, tk.END)
self.console.config(state="disabled")
cmd = [sys.executable, script_name] + args
self.log(f"RUNNING: {' '.join(cmd)}\n" + "-"*50 + "\n")
thread = threading.Thread(target=self._execute_subprocess, args=(cmd,))
thread.start()
def _execute_subprocess(self, cmd):
try:
process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, universal_newlines=True
)
for line in process.stdout:
self.log(line)
process.wait()
self.log(f"\nProcess Exit Code: {process.returncode}\n")
except Exception as e:
self.log(f"\nError: {e}\n")
finally:
self.is_running = False
if __name__ == "__main__":
app = SopranoGUI()
app.mainloop()
-132
View File
@@ -1,132 +0,0 @@
# inference.py - Soprano Reforged V3 (Full Precision Inference)
# EMOJI-FREE VERSION for Windows Compatibility
import argparse
import os
import torch
import soundfile as sf
import numpy as np
from transformers import AutoModelForCausalLM, AutoTokenizer
from model.decoder import Decoder
from utils.text_normalizer import normalize_text
from utils.config import cfg
def generate(text, llm, tokenizer, decoder, output_path="output.wav", top_k=50, temperature=0.7, device="cuda"):
"""
Core generation function with integrated Pulse-Check and Audio Normalization.
"""
print(f"--- Inference Start ---")
print(f"Input Text: '{text}'")
# 1. Text Normalization
norm_text = normalize_text(text)
prompt = f"[STOP][TEXT]{norm_text}[START]"
# 2. Tokenization
inputs = tokenizer(prompt, return_tensors="pt").to(device)
input_ids = inputs["input_ids"]
# 3. LLM Generation
print("Running LLM (Brain)...")
with torch.no_grad():
output_ids = llm.generate(
input_ids,
max_new_tokens=cfg.training.get('seq_len', 512),
do_sample=True,
top_k=top_k,
temperature=temperature,
pad_token_id=tokenizer.pad_token_id,
eos_token_id=tokenizer.eos_token_id
)
# 4. Token Slicing (Find the [START] marker)
start_token_id = tokenizer.convert_tokens_to_ids("[START]")
out_list = output_ids[0].tolist()
try:
start_idx = out_list.index(start_token_id)
# Slicing after [START] to get the audio tokens/hidden states
audio_token_ids = output_ids[:, start_idx+1:]
except ValueError:
print("Warning: [START] token not found in output. Using full sequence.")
start_idx = 0
audio_token_ids = output_ids
if audio_token_ids.size(1) == 0:
print("Error: Model generated no audio tokens.")
return
# 5. Decoding (Mouth)
print(f"Decoding {audio_token_ids.size(1)} tokens...")
with torch.no_grad():
# Get hidden states for the specific tokens generated
llm_out = llm(output_ids, output_hidden_states=True)
# We target the last layer's hidden states for the audio portion
hidden_states = llm_out.hidden_states[-1][:, start_idx+1:, :]
# Pulse Check 1: LLM Strength
llm_strength = hidden_states.abs().mean().item()
print(f"LLM Output Strength (Mean Abs): {llm_strength:.6f}")
waveform = decoder(hidden_states)
# Pulse Check 2: Decoder Strength
dec_strength = waveform.abs().mean().item()
print(f"Decoder Output Strength (Mean Abs): {dec_strength:.6f}")
# 6. Post-Processing & Audio Boosting
audio_np = waveform.squeeze().cpu().numpy()
max_amp = np.max(np.abs(audio_np))
if max_amp < 0.01:
print(f"Warning: Output is extremely weak (Max Amp: {max_amp:.6f}). This likely means the model hasn't learned yet.")
print("Skipping normalization to avoid boosting static/noise.")
else:
# Boost to peak 0.9 for clarity without clipping
audio_np = audio_np / max_amp * 0.9
print(f"Normalization: Audio boosted by {0.9/max_amp:.2f}x (Max Amp: {max_amp:.4f})")
# 7. Save to File
sf.write(output_path, audio_np, cfg.common['sample_rate'])
print(f"--- Success: File saved to {output_path} ---")
def main():
parser = argparse.ArgumentParser(description="Soprano Reforged V3 Inference")
parser.add_argument("--text", type=str, required=True, help="Text to synthesize")
parser.add_argument("--model-dir", type=str, required=True, help="Path to epoch folder")
parser.add_argument("--output", type=str, default="inference_output.wav", help="Output filename")
args = parser.parse_args()
device = cfg.device
print(f"Loading Model from {args.model_dir} on {device}...")
# Load Tokenizer & LLM
tokenizer = AutoTokenizer.from_pretrained(args.model_dir)
llm = AutoModelForCausalLM.from_pretrained(
args.model_dir,
torch_dtype=torch.float32,
low_cpu_mem_usage=True
).to(device)
llm.eval()
# Load Decoder (Internalized Adapter)
decoder_path = os.path.join(args.model_dir, "decoder.pth")
if not os.path.exists(decoder_path):
# Fallback to general weights directory if not in epoch folder
decoder_path = os.path.join(cfg.training['save_dir'], "decoder.pth")
decoder = Decoder(
input_channels=llm.config.hidden_size,
decoder_dim=cfg.codec['dim'],
decoder_layers=cfg.codec['layers']
).to(device).float()
print(f"Loading Decoder weights from {decoder_path}...")
decoder.load_state_dict(torch.load(decoder_path, map_location=device, weights_only=True))
decoder.eval()
# Run Generation
generate(args.text, llm, tokenizer, decoder, output_path=args.output, device=device)
if __name__ == "__main__":
main()
-5
View File
@@ -1,5 +0,0 @@
@echo off
cd /d "%~dp0"
call .venv\Scripts\activate
start "" pythonw gui.pyw
exit
-113
View File
@@ -1,113 +0,0 @@
# model/common.py
import torch
from torch import nn
class SimpleMLP(nn.Module):
"""
Simple Multi-Layer Perceptron used within ConvNeXt blocks.
Standardized for Float32 precision.
"""
def __init__(self, dim, intermediate_dim):
super().__init__()
self.pwconv1 = nn.Linear(dim, intermediate_dim)
self.act = nn.GELU()
self.pwconv2 = nn.Linear(intermediate_dim, dim)
def forward(self, x):
return self.pwconv2(self.act(self.pwconv1(x)))
class ConvNeXtBlock(nn.Module):
"""
ConvNeXt Block adapted for 1D audio.
Used by both Encoder and Decoder backbones.
"""
def __init__(
self,
dim: int,
intermediate_dim: int,
layer_scale_init_value: float = 1e-6,
kernel_size: int = 7,
):
super().__init__()
# Depthwise conv: Process spatial (time) info
self.dwconv = nn.Conv1d(dim, dim, kernel_size=kernel_size, padding=kernel_size//2, groups=dim)
# LayerNorm: Essential for stability in Float32
self.norm = nn.LayerNorm(dim, eps=1e-6)
# Pointwise MLP: Process channel info
self.mlp = SimpleMLP(dim, intermediate_dim)
# Layer Scale: Helps with deep network convergence
self.gamma = (
nn.Parameter(layer_scale_init_value * torch.ones(dim), requires_grad=True)
if layer_scale_init_value > 0
else None
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
residual = x
x = self.dwconv(x)
x = x.transpose(1, 2) # [B, C, T] -> [B, T, C] for Norm/MLP
x = self.norm(x)
x = self.mlp(x)
if self.gamma is not None:
x = self.gamma * x
x = x.transpose(1, 2) # [B, T, C] -> [B, C, T]
x = residual + x
return x
class VocosBackbone(nn.Module):
"""
Shared backbone architecture for Encoder/Decoder.
Uses ConvNeXt blocks to extract/reconstruct high-fidelity features.
"""
def __init__(
self,
input_channels: int,
dim: int,
intermediate_dim: int,
num_layers: int,
kernel_size: int = 7,
layer_scale_init_value: float = None,
):
super().__init__()
# Initial projection into hidden dimension
self.embed = nn.Conv1d(
input_channels, dim, kernel_size=kernel_size, padding=kernel_size//2
)
self.norm = nn.LayerNorm(dim, eps=1e-6)
self.layers = nn.ModuleList([
ConvNeXtBlock(
dim=dim,
intermediate_dim=intermediate_dim,
kernel_size=kernel_size,
layer_scale_init_value=layer_scale_init_value or 1 / num_layers**0.5,
)
for _ in range(num_layers)
])
self.final_norm = nn.LayerNorm(dim, eps=1e-6)
self.apply(self._init_weights)
def _init_weights(self, m):
if isinstance(m, (nn.Conv1d, nn.Linear)):
nn.init.trunc_normal_(m.weight, std=0.02)
if m.bias is not None:
nn.init.constant_(m.bias, 0)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# Initial Embedding
x = self.embed(x)
# Apply LayerNorm (requires transpose)
x = self.norm(x.transpose(1, 2)).transpose(1, 2)
# Processing Blocks
for layer in self.layers:
x = layer(x)
# Final Norm
x = self.final_norm(x.transpose(1, 2)).transpose(1, 2)
return x
-117
View File
@@ -1,117 +0,0 @@
# model/decoder.py - Standardized for V3
import torch
from torch import nn
import torch.nn.functional as F
from .common import VocosBackbone
class ISTFTHead(nn.Module):
"""
Inverse STFT head for waveform generation.
Converts hidden states -> complex spectrogram -> waveform.
"""
def __init__(
self,
dim: int,
n_fft: int = 2048,
hop_length: int = 512,
padding: str = "same",
):
super().__init__()
self.n_fft = n_fft
self.hop_length = hop_length
out_dim = n_fft + 2
self.out = nn.Linear(dim, out_dim)
self.padding = padding
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Args:
x: [B, T, C] - hidden states
Returns:
audio: [B, 1, L] - waveform
"""
x = x.float() # Ensure float32
x = self.out(x) # [B, T, n_fft + 2]
x = x.transpose(1, 2) # [B, n_fft + 2, T]
mag, phase = x.chunk(2, dim=1) # each [B, (n_fft+2)//2, T]
mag = torch.exp(mag)
phase = torch.sin(phase)
# Construct complex spectrogram
S = mag * torch.exp(1j * phase)
# Inverse STFT
audio = torch.istft(
S,
n_fft=self.n_fft,
hop_length=self.hop_length,
window=torch.hann_window(self.n_fft).to(x.device),
center=True,
)
return audio.unsqueeze(1) # [B, 1, L]
class Decoder(nn.Module):
def __init__(
self,
input_channels=512,
decoder_dim=512,
decoder_layers=8,
):
super().__init__()
# 1. The Adapter (Projection)
self.input_proj = nn.Linear(input_channels, decoder_dim)
# Standardization Layer
self.post_proj_norm = nn.LayerNorm(decoder_dim)
# 2. Upsampler (Match Encoder's slice::4)
# We need to upsample by 4x to match the Hop=512 resolution
# Encoder: Hop=512 -> Slice(4) -> Effective Hop=2048
# Decoder: Latent(2048) -> Upsample(4) -> Latent(512) -> ISTFT(512) -> Audio
self.upsampler = nn.Sequential(
nn.ConvTranspose1d(decoder_dim, decoder_dim, kernel_size=4, stride=4),
nn.GELU()
)
# 3. Backbone
self.backbone = VocosBackbone(
input_channels=decoder_dim,
dim=decoder_dim,
intermediate_dim=decoder_dim * 3,
num_layers=decoder_layers,
)
# 4. Output Head (ISTFT)
self.head = ISTFTHead(
dim=decoder_dim,
n_fft=2048,
hop_length=512
)
def forward(self, x):
# x shape: [Batch, Time, Channels]
x = x.float() # Ensure float32 everywhere
# Project and Normalize
x = self.input_proj(x)
x = self.post_proj_norm(x)
# Upsample [B, T, C] -> [B, C, T] -> [B, C, T*4]
x = x.transpose(1, 2)
x = self.upsampler(x)
# Backbone ResBlocks [B, C, T*4] -> [B, C, T*4]
# (VocosBackbone expects [B, C, T] and returns [B, C, T])
x = self.backbone(x)
# Prepare for ISTFT Head [B, C, T] -> [B, T, C]
x = x.transpose(1, 2)
# Generate Waveform
x = self.head(x)
return x
-79
View File
@@ -1,79 +0,0 @@
# model/encoder.py - Standardized for V3
import torch
from torch import nn
import torchaudio
from .common import VocosBackbone
from .quantizer import FSQSTE
def safe_log(x: torch.Tensor, clip_val: float = 1e-5) -> torch.Tensor:
"""Prevents log(0) errors. Explicitly Float32."""
return torch.log(torch.clip(x, min=clip_val))
class Encoder(nn.Module):
"""
Encodes audio into discrete quantized tokens.
Standardized for Float32 precision and Windows compatibility.
"""
def __init__(
self,
num_input_mels=80,
encoder_dim=512,
encoder_layers=8,
bottleneck_channels=5,
):
super().__init__()
# 1. Mel Spectrogram Transform
self.mel_spec = torchaudio.transforms.MelSpectrogram(
sample_rate=32000,
n_fft=2048,
hop_length=512,
n_mels=num_input_mels,
normalized=True
)
# 2. Backbone Network
self.backbone = VocosBackbone(
input_channels=num_input_mels,
dim=encoder_dim,
intermediate_dim=encoder_dim * 3,
num_layers=encoder_layers,
)
# 3. Downsampling Projector
self.downsample_factor = 4
self.down_proj = nn.Linear(encoder_dim, bottleneck_channels)
# 4. Quantizer
self.quantizer = FSQSTE(levels=[8, 8, 5, 5, 5])
def preprocess(self, audio):
"""Converts raw audio to log-mel spectrogram (Float32)."""
if audio.dim() == 2:
audio = audio.unsqueeze(1) # Ensure [B, 1, T]
mel = self.mel_spec(audio.float()) # Force Float32 math
return safe_log(mel).squeeze(1)
def forward(self, audio, return_indices=False):
"""
Audio -> Spectrogram -> Features -> Quantized Codes
"""
# 1. Mel Spec
x = self.preprocess(audio)
# 2. Backbone
x = self.backbone(x) # [B, Dim, Frames]
# 3. Downsample (Slicing)
x = x[:, :, ::self.downsample_factor]
# 4. Project to Bottleneck
x = x.transpose(1, 2) # [B, Frames//4, Dim]
x = self.down_proj(x) # [B, Frames//4, 5]
# 5. Quantize
if return_indices:
return self.quantizer.to_codebook_index(x)
return self.quantizer(x)
-59
View File
@@ -1,59 +0,0 @@
# model/quantizer.py
import torch
from torch import nn
class FSQSTE(nn.Module):
"""
Finite Scalar Quantization with Straight-Through Estimator.
Unicode-safe and explicitly optimized for Float32 precision.
"""
def __init__(self, levels=[8, 8, 5, 5, 5]):
super().__init__()
# Convert levels to float32 for calculation stability
levels_tensor = torch.tensor(levels, dtype=torch.float32)
self.dim = len(levels)
self.register_buffer("_levels", levels_tensor)
# Calculate quantization parameters in full precision
self.register_buffer("half_levels", (self._levels - 1) * (1 - 1e-3) / 2)
self.register_buffer("offset", 0.5 - 0.5 * (self._levels % 2))
self.register_buffer("shift", torch.tan(self.offset / self.half_levels))
# Basis for converting indices <-> vectors
basis = torch.cumprod(torch.tensor([1] + levels[:-1], dtype=torch.float32), dim=0).to(torch.int32)
self.register_buffer("_basis", basis)
def _scale_and_shift(self, zhat_normalized):
half_width = self._levels // 2
return (zhat_normalized * half_width) + half_width
def to_codebook_index(self, zhat):
"""Converts continuous vectors to integer indices."""
# Ensure input is float32
zhat = zhat.float()
zhat = self._scale_and_shift(zhat)
indices = (zhat * self._basis).sum(dim=-1).round().to(torch.int32)
return indices
def forward(self, x):
"""
Quantizes input `x` using the implicit codebook via STE.
Forces all math to Float32.
"""
# 1. Ensure float32 for mathematical stability
x = x.float()
# 2. Quantize with tanh trick
# Maps input to valid discrete levels
x = torch.tanh(x + self.shift) * self.half_levels - self.offset
# 3. Straight-Through Estimator:
# Forward pass uses rounded values; backward pass uses un-rounded gradients.
x = x + (x.round() - x).detach()
# 4. Normalize to [-1, 1] for backbone stability
x = x / (self._levels // 2)
return x
-114
View File
@@ -1,114 +0,0 @@
# monitor.py - Soprano V3 System Observer
import os
import sys
import time
import subprocess
import requests
import glob
# Windows UTF-8 Support
sys.stdout.reconfigure(encoding='utf-8')
# =================CONFIGURATION=================
NTFY_TOPIC = "changeme"
TEST_PROMPT = "This is Epoch {}. Checking systems. The vocal reconstruction is proceeding as planned."
MODEL_DIR = "weights/model"
INFERENCE_SCRIPT = "inference.py"
LOG_FILE = "training.log"
# ===============================================
print(f"Monitoring {MODEL_DIR} for new epochs...")
print(f"Notifications: https://ntfy.sh/{NTFY_TOPIC}")
processed_epochs = set()
def get_latest_loss():
try:
if not os.path.exists(LOG_FILE): return "N/A"
with open(LOG_FILE, 'r', encoding='utf-8') as f:
lines = f.readlines()
for line in reversed(lines):
if "Ep" in line and "|" in line:
return line.strip().split("|")[-1].strip()
except: return "Unknown"
return "N/A"
def send_notification(epoch_num, wav_path):
loss_stat = get_latest_loss()
print(f"Sending notification for Epoch {epoch_num}...")
try:
# 1. Send Text Update
requests.post(f"https://ntfy.sh/{NTFY_TOPIC}",
data=f"Epoch {epoch_num} Complete!\nLoss: {loss_stat}",
headers={"Title": f"Epoch {epoch_num} Done", "Tags": "tada"})
# 2. Upload Audio File (Proper Header for local files)
if os.path.exists(wav_path):
with open(wav_path, 'rb') as f:
requests.put(f"https://ntfy.sh/{NTFY_TOPIC}", data=f,
headers={
"Filename": f"ep{epoch_num}.wav",
"Title": f"Audio Sample: Epoch {epoch_num}"
})
print("Notification Sent Successfully!")
except Exception as e:
print(f"Network error: {e}")
def run_inference(epoch_folder, epoch_num):
output_wav = f"monitor_epoch_{epoch_num}.wav"
prompt = TEST_PROMPT.format(epoch_num)
# Use the current python executable
python_exe = sys.executable
# Command matches the new inference.py arguments
cmd = [
python_exe, INFERENCE_SCRIPT,
"--model-dir", epoch_folder,
"--text", prompt,
"--output", output_wav
]
# OPTIONAL: Force CPU for monitor inference to save 5090 VRAM for training
# env = os.environ.copy()
# env["CUDA_VISIBLE_DEVICES"] = ""
print(f"Generating sample for Epoch {epoch_num}...")
try:
# Run and capture output to prevent terminal clutter
result = subprocess.run(
cmd,
check=True,
capture_output=True,
text=True
)
return output_wav
except subprocess.CalledProcessError as e:
print(f"Inference Failed for Epoch {epoch_num}")
print(f"Error details: {e.stderr}")
return None
# Main Loop
while True:
# Look for epoch folders (e.g., weights/model/epoch_0)
epoch_dirs = glob.glob(os.path.join(MODEL_DIR, "epoch_*"))
for d in epoch_dirs:
try:
folder_name = os.path.basename(d)
epoch_num = int(folder_name.split("_")[1])
if epoch_num not in processed_epochs:
# Wait 10 seconds to ensure the OS has finished writing the .pth files
time.sleep(10)
print(f"\nProcessing Epoch {epoch_num}")
wav_file = run_inference(d, epoch_num)
if wav_file:
send_notification(epoch_num, wav_file)
processed_epochs.add(epoch_num)
except Exception as e:
pass
time.sleep(60)
+43 -38
View File
@@ -1,48 +1,53 @@
[project]
name = "soprano-factory"
version = "0.1.0"
description = "Soprano-Reforged: Clean training environment"
readme = "README.md"
# We explicitly pin to 3.12 to match stable Torch/Audio wheels
requires-python = ">=3.12, <3.13"
dependencies = [
# Core Data & ML
"numpy>=1.26.0",
"scipy",
"tqdm",
"einops",
"huggingface_hub",
"transformers",
"accelerate",
# Audio Processing (Stable Windows Stack)
"librosa", # General audio manipulation
"soundfile", # The stable backend for Torchaudio on Windows
"ffmpeg-python", # Fallback/Helper (requires ffmpeg.exe in PATH)
# Torch Ecosystem
"torch>=2.6.0",
"torchaudio>=2.6.0",
"sv-ttk>=2.6.1",
"darkdetect>=0.8.0",
"pywinstyles>=1.8",
"requests>=2.32.5",
description = "A clean-room training pipeline for fine-tuning Soprano TTS models."
authors = [
{ name = "Your Name", email = "your.email@example.com" }
]
requires-python = ">=3.12"
readme = "README.md"
license = { text = "Apache-2.0" }
[tool.uv]
# Force Windows to grab CUDA-enabled PyTorch
[[tool.uv.index]]
name = "pytorch-cuda"
url = "https://download.pytorch.org/whl/cu126"
explicit = true
[tool.uv.sources]
torch = [{ index = "pytorch-cuda" }]
torchaudio = [{ index = "pytorch-cuda" }]
torchvision = [{ index = "pytorch-cuda" }]
# Core dependencies required for data generation, training, and inference testing
dependencies = [
"einops>=0.8.2",
"hf-xet>=1.2.0",
"huggingface-hub>=0.20.0",
"matplotlib>=3.10.8",
"numpy>=1.26.0",
"pyyaml",
"safetensors>=0.7.0",
"scipy>=1.11.0",
"soprano-tts>=0.2.0",
"soundfile>=0.13.1",
"tensorboard",
"torch>=2.8.0",
"torchaudio>=2.8.0",
"tqdm>=4.66.0",
"transformers>=4.51.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# We tell hatchling that the code is in the 'model' folder
[tool.hatch.build.targets.wheel]
packages = ["model"]
# ------------------------------------------------------------------------------
# uv-Specific Configuration
# ------------------------------------------------------------------------------
# Define the PyTorch CUDA 12.8 index
[[tool.uv.index]]
name = "pytorch-cu128"
url = "https://download.pytorch.org/whl/cu128"
explicit = true
# Route torch-related packages exclusively to the CUDA 12.8 index
[tool.uv.sources]
torch = { index = "pytorch-cu128" }
torchaudio = { index = "pytorch-cu128" }
[tool.uv]
managed = true
# This tells uv we just want an environment for scripts, not a buildable package
package = false
+111
View File
@@ -0,0 +1,111 @@
# start_pipeline.py - Interactive CLI to run Soprano Factory's 2-stage TTS training pipeline with color-coded output and error handling.
import os
import subprocess
import sys
from pathlib import Path
class Colors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
def print_banner():
banner = f"""{Colors.OKCYAN}{Colors.BOLD}
===================================================================
S O P R A N O F A C T O R Y
===================================================================
{Colors.ENDC}"""
print(banner)
def check_prerequisites():
"""Checks if the required config and dataset folders exist."""
print(f"{Colors.HEADER}Checking Pipeline Prerequisites...{Colors.ENDC}")
if not Path("config.yaml").exists():
print(f"{Colors.FAIL}[!] config.yaml not found! Please create it before starting.{Colors.ENDC}")
sys.exit(1)
print(f"{Colors.OKGREEN}[✓] config.yaml found.{Colors.ENDC}")
# Note: We aren't strictly checking the dataset path here because it relies on reading the config,
# but the individual scripts will catch missing data safely.
def run_script(script_name: str, description: str):
"""Runs a python script via uv and handles interruptions gracefully."""
print(f"\n{Colors.OKBLUE}{Colors.BOLD}>>> {description}{Colors.ENDC}")
print(f"{Colors.WARNING}Press Ctrl+C to stop this step and return to the main menu.{Colors.ENDC}\n")
try:
# Using 'uv run' to ensure it executes in the correct environment
subprocess.run(["uv", "run", script_name], check=True)
except subprocess.CalledProcessError as e:
print(f"\n{Colors.FAIL}[!] Process '{script_name}' exited with error code {e.returncode}.{Colors.ENDC}")
except KeyboardInterrupt:
print(f"\n{Colors.WARNING}[!] Process '{script_name}' interrupted by user. Returning to menu...{Colors.ENDC}")
def start_tensorboard():
"""Spins up TensorBoard in the background or a new shell."""
print(f"\n{Colors.OKBLUE}>>> Starting TensorBoard...{Colors.ENDC}")
print("TensorBoard will be available at: http://localhost:6006/")
print(f"{Colors.WARNING}(Note: You may need to open this URL in your browser manually. Press Ctrl+C in this terminal to stop TensorBoard.){Colors.ENDC}\n")
try:
subprocess.run(["uv", "run", "tensorboard", "--logdir=./runs"])
except KeyboardInterrupt:
print(f"\n{Colors.OKGREEN}Closed TensorBoard.{Colors.ENDC}")
def interactive_menu():
while True:
print_banner()
print("Please select an action:")
print(f" {Colors.BOLD}1.{Colors.ENDC} Generate Dataset (Tokenization)")
print(f" {Colors.BOLD}2.{Colors.ENDC} Train Stage 1: LLM (Autoregressive Text-to-Tokens)")
print(f" {Colors.BOLD}3.{Colors.ENDC} Train Stage 2: Decoder (GAN HD Audio Synthesis)")
print(f" {Colors.BOLD}4.{Colors.ENDC} Run Full Automated Pipeline (Steps 1 -> 2 -> 3)")
print(f" {Colors.BOLD}5.{Colors.ENDC} Start TensorBoard Monitoring")
print(f" {Colors.BOLD}6.{Colors.ENDC} Test Fine-Tuned Model Inference")
print(f" {Colors.BOLD}0.{Colors.ENDC} Exit")
choice = input(f"\n{Colors.BOLD}Enter your choice (0-6): {Colors.ENDC}").strip()
if choice == '1':
run_script("generate_dataset.py", "Generating Dataset")
elif choice == '2':
run_script("train_llm.py", "Running Stage 1: LLM Training")
elif choice == '3':
run_script("train_decoder.py", "Running Stage 2: Decoder GAN Training")
elif choice == '4':
print(f"\n{Colors.HEADER}Starting Full Automated Pipeline...{Colors.ENDC}")
run_script("generate_dataset.py", "Step 1/3: Generating Dataset")
run_script("train_llm.py", "Step 2/3: Stage 1 LLM Training")
run_script("train_decoder.py", "Step 3/3: Stage 2 Decoder GAN Training")
print(f"\n{Colors.OKGREEN}{Colors.BOLD}Full Pipeline Execution Complete!{Colors.ENDC}")
elif choice == '5':
start_tensorboard()
elif choice == '6':
run_script("test_inference.py", "Testing Model Inference")
elif choice == '0':
print(f"{Colors.OKGREEN}Exiting Soprano Factory. Goodbye!{Colors.ENDC}")
break
else:
print(f"{Colors.FAIL}Invalid choice. Please enter a number between 0 and 6.{Colors.ENDC}")
input(f"\n{Colors.BOLD}Press Enter to return to the main menu...{Colors.ENDC}")
if __name__ == "__main__":
# Clear terminal screen for a clean UI
os.system('cls' if os.name == 'nt' else 'clear')
check_prerequisites()
interactive_menu()
-130
View File
@@ -1,130 +0,0 @@
# train_codec.py - Stage 0: Codec Training (Standardized for V3 - Unicode Safe)
import argparse
import glob
import os
import torch
import torch.nn.functional as F
import torchaudio
import soundfile as sf
from torch.utils.data import DataLoader, Dataset
from torch.optim import AdamW
from tqdm import tqdm
# Import our architecture
from model.encoder import Encoder
from model.decoder import Decoder
from utils.config import cfg
# --- Windows / Audio Backend Setup ---
def setup_audio_backend():
cwd = os.getcwd()
local_ffmpeg = os.path.join(cwd, "tools", "ffmpeg")
if os.path.exists(local_ffmpeg) and os.path.isdir(local_ffmpeg):
if local_ffmpeg not in os.environ["PATH"]:
print(f"[Setup] Found local FFmpeg at: {local_ffmpeg}")
os.environ["PATH"] = local_ffmpeg + os.pathsep + os.environ["PATH"]
setup_audio_backend()
class SpectralLoss(torch.nn.Module):
def __init__(self):
super().__init__()
self.mel = torchaudio.transforms.MelSpectrogram(
sample_rate=cfg.common['sample_rate'],
n_mels=cfg.codec['input_mels'],
n_fft=2048, hop_length=512
)
def forward(self, pred, target):
pred, target = pred.float(), target.float()
min_len = min(pred.shape[-1], target.shape[-1])
pred = pred[..., :min_len]
target = target[..., :min_len]
loss_time = F.l1_loss(pred, target)
if self.mel.mel_scale.fb.device != pred.device:
self.mel = self.mel.to(pred.device)
loss_mel = F.l1_loss(self.mel(pred), self.mel(target))
return loss_time + loss_mel
class WavDataset(Dataset):
def __init__(self, glob_pattern, segment_size):
self.files = glob.glob(glob_pattern, recursive=True)
self.segment_size = segment_size
print(f"Stage 0: Found {len(self.files)} wav files for training.")
def __len__(self):
return len(self.files)
def __getitem__(self, idx):
try:
wav_np, sr = sf.read(self.files[idx])
wav = torch.from_numpy(wav_np).float()
if wav.ndim == 1: wav = wav.unsqueeze(0)
else: wav = wav.t()
if sr != cfg.common['sample_rate']:
wav = torchaudio.functional.resample(wav, sr, cfg.common['sample_rate'])
if wav.shape[0] > 1: wav = wav.mean(dim=0, keepdim=True)
if wav.size(-1) < self.segment_size:
wav = F.pad(wav, (0, self.segment_size - wav.size(-1)))
if wav.size(-1) > self.segment_size:
start = torch.randint(0, wav.size(-1) - self.segment_size, (1,))
wav = wav[..., start : start + self.segment_size]
return wav
except Exception:
return torch.zeros(1, self.segment_size)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--wav-dir", type=str, required=True, help="Path to wavs")
parser.add_argument("--epochs", type=int, default=cfg.codec['epochs'])
args = parser.parse_args()
save_dir = cfg.codec['save_dir']
os.makedirs(save_dir, exist_ok=True)
device = cfg.device
print(f"Launching Codec Training (Float32) on {device}...")
encoder = Encoder(
num_input_mels=cfg.codec['input_mels'],
encoder_dim=cfg.codec['dim'],
encoder_layers=cfg.codec['layers'],
bottleneck_channels=cfg.codec['bottleneck']
).to(device).float()
decoder = Decoder(
input_channels=cfg.codec['bottleneck'],
decoder_dim=cfg.codec['dim'],
decoder_layers=cfg.codec['layers']
).to(device).float()
opt = AdamW(list(encoder.parameters()) + list(decoder.parameters()), lr=float(cfg.codec['lr']))
criterion = SpectralLoss()
ds = WavDataset(args.wav_dir, segment_size=cfg.codec['segment_size'])
dl = DataLoader(ds, batch_size=cfg.codec['batch_size'], shuffle=True, num_workers=0, pin_memory=True)
for epoch in range(args.epochs):
encoder.train()
decoder.train()
pbar = tqdm(dl)
for wav in pbar:
wav = wav.to(device)
z = encoder(wav)
rec = decoder(z)
loss = criterion(rec, wav)
opt.zero_grad()
loss.backward()
opt.step()
pbar.set_description(f"Ep {epoch+1}/{args.epochs} | Loss: {loss.item():.4f}")
if (epoch + 1) % 5 == 0 or epoch == args.epochs - 1:
torch.save(encoder.state_dict(), f"{save_dir}/encoder.pth")
torch.save(decoder.state_dict(), f"{save_dir}/decoder.pth")
print(f"Weights saved to {save_dir}")
if __name__ == "__main__":
main()
+141
View File
@@ -0,0 +1,141 @@
import argparse
import time
import yaml
from pathlib import Path
import torch
import torchaudio
from transformers import AutoModelForCausalLM, AutoTokenizer
# Assumes the decoder folder is in your root directory
from decoder.decoder import SopranoDecoder
class SopranoInferencer:
"""
Stitches together the fine-tuned LLM (Stage 1) and the fine-tuned
Vocos GAN Decoder (Stage 2) to generate custom audio from text.
"""
def __init__(self, config_path: str, custom_text: str = None):
with open(config_path, 'r') as f:
self.config = yaml.safe_load(f)
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
self.dtype = getattr(torch, self.config['optimizations']['mixed_precision'])
# Determine Paths
self.llm_dir = Path(self.config['model']['llm_save_dir']) / "final_llm"
self.decoder_path = Path(self.config['model']['decoder_save_dir']) / "final_decoder" / "decoder.pth"
# Load Components
self.tokenizer = self._load_tokenizer()
self.llm = self._load_llm()
self.decoder = self._load_decoder()
# Token Boundaries
self.audio_min_id = int(self.tokenizer.convert_tokens_to_ids("[0]")) # type: ignore
self.audio_max_id = int(self.tokenizer.convert_tokens_to_ids("[7999]")) # type: ignore
def _load_tokenizer(self):
print(f"Loading Tokenizer from {self.llm_dir}...")
tokenizer = AutoTokenizer.from_pretrained(self.llm_dir)
tokenizer.padding_side = 'right'
return tokenizer
def _load_llm(self):
print(f"Loading Fine-Tuned LLM from {self.llm_dir}...")
llm = AutoModelForCausalLM.from_pretrained(
self.llm_dir,
attn_implementation=self.config['optimizations']['attn_implementation']
)
llm.to(self.dtype).to(self.device)
llm.eval()
return llm
def _load_decoder(self):
print(f"Loading Fine-Tuned GAN Decoder from {self.decoder_path}...")
if not self.decoder_path.exists():
raise FileNotFoundError(f"Decoder weights not found at {self.decoder_path}. Did Stage 2 finish?")
decoder = SopranoDecoder()
decoder.load_state_dict(torch.load(self.decoder_path, map_location='cpu'))
decoder.to(self.device)
decoder.eval()
return decoder
@torch.no_grad()
def generate(self, text: str, output_path: str = "custom_output.wav"):
print(f"\n--- Generating Audio ---")
print(f"Text: \"{text}\"")
start_time = time.time()
# 1. Format the Prompt exactly as seen in training
prompt = f"[TEXT]{text}[START]"
inputs = self.tokenizer(prompt, return_tensors="pt").to(self.device)
# 2. Autoregressive Generation (Generate discrete tokens)
print("1. Generating discrete audio tokens...")
generation_output = self.llm.generate(
input_ids=inputs['input_ids'],
attention_mask=inputs.get('attention_mask'),
max_new_tokens=1024,
do_sample=True,
temperature=0.8, # Slightly creative but focused
top_k=50,
top_p=0.95,
pad_token_id=self.tokenizer.pad_token_id,
eos_token_id=self.tokenizer.eos_token_id,
repetition_penalty=1.2
)
# 3. Extract Continuous Hidden States (The "Single Forward Pass" Fix)
print("2. Extracting continuous hidden states...")
with torch.autocast(device_type=self.device.type, dtype=self.dtype):
hidden_out = self.llm(generation_output, output_hidden_states=True).hidden_states[-1].to(torch.float32)
# 4. Filter for only the audio tokens
sequence = generation_output[0]
audio_mask = (sequence >= self.audio_min_id) & (sequence <= self.audio_max_id)
num_audio_tokens = audio_mask.sum().item()
print(f" -> Found {num_audio_tokens} audio tokens.")
if num_audio_tokens == 0:
print("[!] The LLM generated no audio tokens. Try tweaking generation temperature or training longer.")
return
# Gather latents: Shape changes from (1, T_total, Dim) -> (1, Dim, T_audio) for Decoder
audio_latents = hidden_out[0][audio_mask].unsqueeze(0).transpose(1, 2)
# 5. Synthesize Waveform via GAN Decoder
print("3. Synthesizing HD waveform via Vocos GAN...")
waveform = self.decoder(audio_latents).squeeze(1) # (1, Samples)
waveform = waveform.cpu()
# 6. Save Audio
torchaudio.save(output_path, waveform, self.config['dataset']['sample_rate'])
dt = time.time() - start_time
print(f"\n[✓] Success! Audio saved to {output_path} in {dt:.2f} seconds.")
def main():
parser = argparse.ArgumentParser(description="Test Soprano 2-Stage Generation")
parser.add_argument("--config", type=str, default="config.yaml", help="Path to config file")
parser.add_argument(
"--text",
type=str,
default="This is a test of the fully rebuilt, two stage Soprano factory.",
help="The text you want the model to speak."
)
parser.add_argument("--output", type=str, default="output.wav", help="Output filename")
args = parser.parse_args()
inferencer = SopranoInferencer(config_path=args.config)
inferencer.generate(text=args.text, output_path=args.output)
if __name__ == '__main__':
main()
-81
View File
@@ -1,81 +0,0 @@
# verify_voice.py - Pure ASCII, Standardized for V3
import argparse
import torch
import soundfile as sf
import torchaudio
import os
from model.encoder import Encoder
from model.decoder import Decoder
from utils.config import cfg
def setup_audio_backend():
cwd = os.getcwd()
local_ffmpeg = os.path.join(cwd, "tools", "ffmpeg")
if os.path.exists(local_ffmpeg) and os.path.isdir(local_ffmpeg):
if local_ffmpeg not in os.environ["PATH"]:
os.environ["PATH"] = local_ffmpeg + os.pathsep + os.environ["PATH"]
setup_audio_backend()
def main():
parser = argparse.ArgumentParser(description="Verify Codec Reconstruction")
parser.add_argument("--input", type=str, required=True, help="Single wav file")
parser.add_argument("--output", type=str, default="verify_out.wav")
args = parser.parse_args()
device = cfg.device
print(f"Running verification on {device}")
# Initialize Encoder
encoder = Encoder(
num_input_mels=cfg.codec['input_mels'],
encoder_dim=cfg.codec['dim'],
encoder_layers=cfg.codec['layers'],
bottleneck_channels=cfg.codec['bottleneck']
).to(device).float()
# Initialize Decoder (5 channels for Stage 0 weights)
decoder = Decoder(
input_channels=cfg.codec['bottleneck'],
decoder_dim=cfg.codec['dim'],
decoder_layers=cfg.codec['layers']
).to(device).float()
# Load weights
e_path = os.path.join(cfg.codec['save_dir'], "encoder.pth")
d_path = os.path.join(cfg.codec['save_dir'], "decoder.pth")
if not os.path.exists(e_path) or not os.path.exists(d_path):
print(f"Error: Weights not found in {cfg.codec['save_dir']}")
return
encoder.load_state_dict(torch.load(e_path, map_location=device, weights_only=True))
decoder.load_state_dict(torch.load(d_path, map_location=device, weights_only=True))
encoder.eval()
decoder.eval()
# Load Audio
wav, sr = sf.read(args.input)
wav = torch.from_numpy(wav).float()
if wav.ndim == 1: wav = wav.unsqueeze(0)
else: wav = wav.t()
if sr != cfg.common['sample_rate']:
wav = torchaudio.functional.resample(wav, sr, cfg.common['sample_rate'])
if wav.shape[0] > 1: wav = wav.mean(dim=0, keepdim=True)
wav = wav.unsqueeze(0).to(device)
# Process
with torch.no_grad():
z = encoder(wav)
out = decoder(z)
# Save
out_np = out.squeeze().cpu().numpy()
sf.write(args.output, out_np, cfg.common['sample_rate'])
print(f"Verification complete: {args.output}")
if __name__ == "__main__":
main()
-217
View File
@@ -1,217 +0,0 @@
# train.py - Soprano Reforged V3 (Standardized Precision & Joint Training)
# EMOJI-FREE VERSION for Windows Compatibility
import argparse
import os
import sys
import random
# Windows UTF-8 Support
sys.stdout.reconfigure(encoding='utf-8')
import numpy as np
import torch
import soundfile as sf
from torch.utils.data import DataLoader
from torch.optim import AdamW
from transformers import AutoModelForCausalLM, AutoTokenizer
from tqdm import tqdm
# Import modular components
from dataset import AudioDataset
from model.decoder import Decoder
from training.collator import SopranoCollator
from training.loss import AudioReconstructionLoss
from utils.config import cfg
def set_seed(seed):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--input-dir", type=str, default="./mio_dataset", help="Path containing train.json")
parser.add_argument("--epochs", type=int, default=cfg.training['epochs'])
parser.add_argument("--save-dir", type=str, default=None, help="Override default save directory")
args = parser.parse_args()
set_seed(cfg.common['seed'])
save_dir = args.save_dir if args.save_dir else cfg.training['save_dir']
os.makedirs(save_dir, exist_ok=True)
os.makedirs(os.path.join(save_dir, "samples"), exist_ok=True)
device = cfg.device
# 1. Load Tokenizer & LLM (FORCED FLOAT32)
base_model = cfg.training['base_model']
print(f"[System] Launching Stage 2 Training in Float32...")
print(f"[Model] Loading Base LLM: {base_model}...")
tokenizer = AutoTokenizer.from_pretrained(base_model)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
llm = AutoModelForCausalLM.from_pretrained(
base_model,
dtype=torch.float32,
attn_implementation="sdpa"
).to(device)
llm.train()
# 2. Load Decoder
print(f"[Model] Initializing Decoder for Joint Training (Dim: {llm.config.hidden_size})...")
decoder = Decoder(
input_channels=llm.config.hidden_size,
decoder_dim=cfg.codec['dim'],
decoder_layers=cfg.codec['layers']
).to(device)
decoder_ckpt = f"{cfg.codec['save_dir']}/decoder.pth"
if os.path.exists(decoder_ckpt):
print(f"[Checkpoint] Loading pretrained backbone from Stage 0...")
state_dict = torch.load(decoder_ckpt, map_location=device, weights_only=True)
# Skip input_proj as it resizes from 5 to 512
filtered_dict = {k: v for k, v in state_dict.items() if "input_proj" not in k}
decoder.load_state_dict(filtered_dict, strict=False)
decoder.train()
# 3. Data Setup
collator = SopranoCollator(tokenizer, seq_len=cfg.training['seq_len'])
train_ds = AudioDataset(f"{args.input_dir}/train.json")
llm_loader = DataLoader(
train_ds, batch_size=cfg.training['batch_size'], shuffle=True,
collate_fn=collator.pack_for_llm, num_workers=0
)
dec_loader = DataLoader(
train_ds, batch_size=2, shuffle=True,
collate_fn=collator.collate_for_decoder, num_workers=0
)
dec_iter = iter(dec_loader)
# 4. Optimizer & Loss
optimizer = AdamW([
{"params": llm.parameters(), "lr": float(cfg.training['base_lr'])},
{"params": decoder.parameters(), "lr": float(cfg.training['decoder_lr'])}
], weight_decay=cfg.training['weight_decay'])
dec_criterion = AudioReconstructionLoss(sample_rate=cfg.common['sample_rate'])
# 5. Training Loop
global_step = 0
grad_accum = cfg.training['grad_accum_steps']
for epoch in range(args.epochs):
pbar = tqdm(llm_loader, ascii=True) # Ensure tqdm doesn't use unicode blocks
for batch_idx, (input_ids, target_ids) in enumerate(pbar):
if input_ids is None: continue
input_ids, target_ids = input_ids.to(device), target_ids.to(device)
outputs = llm(input_ids, labels=target_ids)
llm_loss = outputs.loss
(llm_loss / grad_accum).backward()
# --- Decoder Step (Joint Training) ---
dec_loss_val = 0.0
if (global_step + 1) % cfg.training['decoder_step_freq'] == 0:
try:
try:
d_input_ids, wav_paths = next(dec_iter)
except StopIteration:
dec_iter = iter(dec_loader)
d_input_ids, wav_paths = next(dec_iter)
with torch.no_grad():
llm_out = llm(d_input_ids.to(device), output_hidden_states=True)
hidden_states_full = llm_out.hidden_states[-1]
# --- Slicing Logic: Only decode tokens AFTER [START] ---
start_token_id = tokenizer.convert_tokens_to_ids("[START]")
valid_hidden_states = []
target_audio_list = []
# We need to process each sample in the batch individually because start_idx varies
for i in range(len(d_input_ids)):
row_ids = d_input_ids[i].tolist()
try:
# varying start position
start_idx = row_ids.index(start_token_id)
# Slice after [START]
audio_latent = hidden_states_full[i, start_idx+1:, :]
valid_hidden_states.append(audio_latent)
except ValueError:
# Start token not found? Should not happen with correct data prep
# Fallback: use whole sequence (garbage in, garbage out, but avoids crash)
valid_hidden_states.append(hidden_states_full[i])
# Load corresponding audio target
path = wav_paths[i]
w, _ = sf.read(path)
w = torch.from_numpy(w).float().to(device)
if w.ndim == 1: w = w.unsqueeze(0)
else: w = w.t()
target_audio_list.append(w)
# Pad the hidden states to create a batch for the decoder
from torch.nn.utils.rnn import pad_sequence
# pad_sequence expects [L, D], and our audio_latent is [L, D]
# batch_first=True -> [B, Max_L, D]
dec_input = pad_sequence(valid_hidden_states, batch_first=True, padding_value=0.0)
pred_audio = decoder(dec_input)
# Align pred_audio and target_audio lengths
# pred_audio: [B, 1, L_pred]
# target_audio needs to be the same list-based loss or padded
# Because targets are variable length, we should compute loss per sample or pad targets
# Let's pad targets to match pred_audio
max_len = pred_audio.shape[-1]
padded_targets = []
for t in target_audio_list:
if t.shape[-1] < max_len:
t = torch.nn.functional.pad(t, (0, max_len - t.shape[-1]))
elif t.shape[-1] > max_len:
t = t[..., :max_len]
padded_targets.append(t)
target_audio = torch.stack(padded_targets)
dec_loss = dec_criterion(pred_audio, target_audio)
(dec_loss * cfg.training['decoder_loss_weight'] / grad_accum).backward()
dec_loss_val = dec_loss.item()
if global_step % 100 == 0:
print(f"[Debug] Decoder Step {global_step} Run. Loss: {dec_loss_val}")
except Exception as e:
with open("training_errors.log", "a", encoding="utf-8") as f:
f.write(f"Step {global_step} Error: {str(e)}\n")
# --- Optimization ---
if (batch_idx + 1) % grad_accum == 0:
torch.nn.utils.clip_grad_norm_(llm.parameters(), 1.0)
torch.nn.utils.clip_grad_norm_(decoder.parameters(), 1.0)
optimizer.step()
optimizer.zero_grad()
global_step += 1
pbar.set_description(f"Epoch {epoch} | LLM Loss: {llm_loss.item():.4f} | Dec Loss: {dec_loss_val:.4f}")
if global_step % 500 == 0 and dec_loss_val > 0:
s_path = f"{save_dir}/samples/step_{global_step}.wav"
sf.write(s_path, pred_audio[0].detach().cpu().squeeze().numpy(), 32000)
epoch_path = f"{save_dir}/epoch_{epoch}"
os.makedirs(epoch_path, exist_ok=True)
llm.save_pretrained(epoch_path)
tokenizer.save_pretrained(epoch_path)
torch.save(decoder.state_dict(), f"{epoch_path}/decoder.pth")
print("[Done] Training Complete.")
if __name__ == "__main__":
main()
-154
View File
@@ -1,154 +0,0 @@
# train_codec.py - Stage 0: Codec Training (Standardized for V3)
import argparse
import glob
import os
import sys
# Windows UTF-8 Support
sys.stdout.reconfigure(encoding='utf-8')
import torch
import torch.nn.functional as F
import torchaudio
import soundfile as sf
from torch.utils.data import DataLoader, Dataset
from torch.optim import AdamW
from tqdm import tqdm
# Import our architecture
from model.encoder import Encoder
from model.decoder import Decoder
from utils.config import cfg
# --- Windows / Audio Backend Setup ---
def setup_audio_backend():
cwd = os.getcwd()
local_ffmpeg = os.path.join(cwd, "tools", "ffmpeg")
if os.path.exists(local_ffmpeg) and os.path.isdir(local_ffmpeg):
if local_ffmpeg not in os.environ["PATH"]:
os.environ["PATH"] = local_ffmpeg + os.pathsep + os.environ["PATH"]
setup_audio_backend()
class SpectralLoss(torch.nn.Module):
"""
Computes time-domain L1 loss and frequency-domain Mel loss.
Forced to Float32 for numerical stability.
"""
def __init__(self):
super().__init__()
self.mel = torchaudio.transforms.MelSpectrogram(
sample_rate=cfg.common['sample_rate'],
n_mels=cfg.codec['input_mels'],
n_fft=2048, hop_length=512
)
def forward(self, pred, target):
pred, target = pred.float(), target.float()
min_len = min(pred.shape[-1], target.shape[-1])
pred = pred[..., :min_len]
target = target[..., :min_len]
loss_time = F.l1_loss(pred, target)
if self.mel.mel_scale.fb.device != pred.device:
self.mel = self.mel.to(pred.device)
loss_mel = F.l1_loss(self.mel(pred), self.mel(target))
return loss_time + loss_mel
class WavDataset(Dataset):
def __init__(self, glob_pattern, segment_size):
self.files = glob.glob(glob_pattern, recursive=True)
self.segment_size = segment_size
print(f"Stage 0: Found {len(self.files)} wav files for training.")
def __len__(self):
return len(self.files)
def __getitem__(self, idx):
try:
wav_np, sr = sf.read(self.files[idx])
wav = torch.from_numpy(wav_np).float()
if wav.ndim == 1: wav = wav.unsqueeze(0)
else: wav = wav.t()
if sr != cfg.common['sample_rate']:
wav = torchaudio.functional.resample(wav, sr, cfg.common['sample_rate'])
if wav.shape[0] > 1: wav = wav.mean(dim=0, keepdim=True)
if wav.size(-1) < self.segment_size:
wav = F.pad(wav, (0, self.segment_size - wav.size(-1)))
if wav.size(-1) > self.segment_size:
start = torch.randint(0, wav.size(-1) - self.segment_size, (1,))
wav = wav[..., start : start + self.segment_size]
return wav
except Exception as e:
return torch.zeros(1, self.segment_size)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--wav-dir", type=str, required=True, help="Path to wavs")
parser.add_argument("--epochs", type=int, default=cfg.codec['epochs'])
parser.add_argument("--save-dir", type=str, default=None, help="Override default save directory")
args = parser.parse_args()
save_dir = args.save_dir if args.save_dir else cfg.codec['save_dir']
os.makedirs(save_dir, exist_ok=True)
device = cfg.device
print(f"Launching Codec Training (Float32) on {device}...")
# 1. Initialize Encoder
encoder = Encoder(
num_input_mels=cfg.codec['input_mels'],
encoder_dim=cfg.codec['dim'],
encoder_layers=cfg.codec['layers'],
bottleneck_channels=cfg.codec['bottleneck']
).to(device).float()
# 2. Initialize Decoder (SET INPUT TO 5 FOR STAGE 0)
decoder = Decoder(
input_channels=cfg.codec['bottleneck'], # <--- Specifically uses the 5 bottleneck channels
decoder_dim=cfg.codec['dim'],
decoder_layers=cfg.codec['layers']
).to(device).float()
opt = AdamW(list(encoder.parameters()) + list(decoder.parameters()), lr=float(cfg.codec['lr']))
criterion = SpectralLoss()
ds = WavDataset(args.wav_dir, segment_size=cfg.codec['segment_size'])
dl = DataLoader(ds, batch_size=cfg.codec['batch_size'], shuffle=True, num_workers=0, pin_memory=True)
# 3. Training Loop
for epoch in range(args.epochs):
encoder.train()
decoder.train()
pbar = tqdm(dl)
for wav in pbar:
wav = wav.to(device)
# Forward
z = encoder(wav)
rec = decoder(z)
loss = criterion(rec, wav)
# Backward
opt.zero_grad()
loss.backward()
opt.step()
pbar.set_description(f"Ep {epoch+1}/{args.epochs} | Loss: {loss.item():.4f}")
# Save checkpoints
if (epoch + 1) % 5 == 0 or epoch == args.epochs - 1:
torch.save(encoder.state_dict(), f"{save_dir}/encoder.pth")
torch.save(decoder.state_dict(), f"{save_dir}/decoder.pth")
print(f"Weights saved to {save_dir}")
if __name__ == "__main__":
main()
+370
View File
@@ -0,0 +1,370 @@
# train_decoder.py - Trains the SopranoDecoder using a frozen LLM backbone with GAN and MR-STFT losses for high-fidelity audio synthesis.
import argparse
import logging
import os
import random
import time
from pathlib import Path
from typing import Tuple, Dict, Any, List
import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn.functional as F
import yaml
from huggingface_hub import hf_hub_download
from torch.utils.data import DataLoader
from torch.utils.tensorboard import SummaryWriter
from tqdm import tqdm
from transformers import AutoModelForCausalLM, AutoTokenizer, PreTrainedTokenizer
from dataset import SopranoDecoderDataset, DecoderCollator, SAMPLES_PER_TOKEN
from decoder.decoder import SopranoDecoder
from decoder.discriminator import Discriminator
from decoder.losses import (
MelSpectrogramWrapper, feature_matching_loss, discriminator_loss,
generator_loss, MultiResolutionSTFTLoss
)
def setup_logger(log_file: str):
logger = logging.getLogger("SopranoDecoder")
logger.setLevel(logging.INFO)
if logger.hasHandlers():
logger.handlers.clear()
formatter = logging.Formatter('%(asctime)s - %(message)s')
fh = logging.FileHandler(log_file)
fh.setFormatter(formatter)
logger.addHandler(fh)
ch = logging.StreamHandler()
ch.setFormatter(logging.Formatter('%(message)s'))
logger.addHandler(ch)
return logger
class SopranoDecoderTrainer:
"""
Stage 2: Freezes the LLM, extracts hidden states, and trains a Vocos
decoder with MR-STFT and GAN losses to synthesize high-fidelity audio.
"""
def __init__(self, config: Dict[str, Any], use_disc: bool = True):
self.config = config
self.use_disc = use_disc
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
self.logger = setup_logger(self.config['logging'].get('log_file', 'decoder_training.log'))
self.logger.info("Initializing Soprano Stage-2 Decoder GAN Trainer...")
# Setup Hardware & Seeds
self.seed = self.config['optimizations']['seed']
torch.manual_seed(self.seed)
random.seed(self.seed)
np.random.seed(self.seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(self.seed)
self.allow_tf32 = self.config['optimizations']['allow_tf32']
torch.set_float32_matmul_precision('high' if self.allow_tf32 else 'highest')
self.dtype = getattr(torch, self.config['optimizations']['mixed_precision'])
# Setup TensorBoard
tb_dir = Path(self.config['logging']['tensorboard_dir']) / "decoder_stage"
self.writer = SummaryWriter(log_dir=str(tb_dir))
# Load Components
self.tokenizer = self._setup_tokenizer()
self.llm, self.decoder, self.discriminator = self._setup_models()
self.train_loader, self.val_loader = self._setup_dataloaders()
# Setup Losses
self.mel_fn = MelSpectrogramWrapper().to(self.device)
self.mr_stft = MultiResolutionSTFTLoss().to(self.device)
# Loss Weights
self.lambda_mel = 45.0
self.lambda_fm = 2.0
self.lambda_gen = 1.0
self.lambda_stft = 1.0
# Optimizers
self.max_lr_g = self.config['decoder_training']['learning_rate_g']
self.max_lr_d = self.config['decoder_training']['learning_rate_d']
self.opt_g = torch.optim.AdamW(self.decoder.parameters(), lr=self.max_lr_g, betas=(0.8, 0.99), weight_decay=0.1)
if self.use_disc:
self.opt_d = torch.optim.AdamW(self.discriminator.parameters(), lr=self.max_lr_d, betas=(0.8, 0.99), weight_decay=0.1)
def _setup_tokenizer(self) -> PreTrainedTokenizer:
self.logger.info("Loading Tokenizer...")
tokenizer = AutoTokenizer.from_pretrained(self.config['model']['base_llm'], use_fast=True)
tokenizer.padding_side = 'right'
return tokenizer
def _setup_models(self):
# 1. Load the FINE-TUNED LLM from Stage 1
llm_path = Path(self.config['model']['llm_save_dir']) / "final_llm"
if not llm_path.exists():
# Fallback to the base model if the user skipped Stage 1 for some reason
self.logger.warning(f"Fine-tuned LLM not found at {llm_path}. Falling back to base model.")
llm_path = self.config['model']['base_llm']
self.logger.info(f"Loading LLM from {llm_path} (Frozen)...")
llm = AutoModelForCausalLM.from_pretrained(llm_path, attn_implementation=self.config['optimizations']['attn_implementation'])
llm.to(self.dtype).to(self.device)
llm.eval()
for param in llm.parameters():
param.requires_grad = False
# 2. Load the base Vocos Decoder
self.logger.info("Loading Base Vocos Decoder (Trainable)...")
decoder = SopranoDecoder()
decoder_path = hf_hub_download(repo_id=self.config['model']['base_llm'], filename='decoder.pth')
decoder.load_state_dict(torch.load(decoder_path, map_location='cpu'))
decoder.to(self.device)
decoder.train()
# 3. Initialize GAN Discriminator
discriminator = None
if self.use_disc:
self.logger.info("Initializing GAN Discriminator...")
discriminator = Discriminator()
discriminator.to(self.device)
discriminator.train()
return llm, decoder, discriminator
def _setup_dataloaders(self) -> Tuple[DataLoader, DataLoader]:
input_dir = Path(self.config['dataset']['input_dir'])
batch_size = self.config['decoder_training']['batch_size']
collator = DecoderCollator(tokenizer=self.tokenizer)
train_ds = SopranoDecoderDataset(str(input_dir / 'train.json'), target_sr=self.config['dataset']['sample_rate'])
val_ds = SopranoDecoderDataset(str(input_dir / 'val.json'), target_sr=self.config['dataset']['sample_rate'])
train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=4, collate_fn=collator, drop_last=True)
val_loader = DataLoader(val_ds, batch_size=max(1, batch_size // 2), shuffle=False, num_workers=2, collate_fn=collator, drop_last=True)
return train_loader, val_loader
def _crop_for_gan(self, real_audio: torch.Tensor, fake_audio: torch.Tensor, valid_lens: List[int]) -> Tuple[torch.Tensor, torch.Tensor]:
"""Randomly crops audio segments for the Discriminator to grade."""
seg_size = self.config['decoder_training']['segment_size_samples']
bsz = real_audio.size(0)
real_crops, fake_crops = [], []
for b in range(bsz):
v_len = min(valid_lens[b], real_audio.size(1), fake_audio.size(1))
if v_len <= seg_size:
pad_len = seg_size - v_len
r_c = F.pad(real_audio[b, :v_len], (0, pad_len))
f_c = F.pad(fake_audio[b, :v_len], (0, pad_len))
else:
start_idx = random.randint(0, v_len - seg_size)
r_c = real_audio[b, start_idx : start_idx + seg_size]
f_c = fake_audio[b, start_idx : start_idx + seg_size]
real_crops.append(r_c)
fake_crops.append(f_c)
real_tensor = torch.stack(real_crops).unsqueeze(1) # (B, 1, T)
fake_tensor = torch.stack(fake_crops).unsqueeze(1)
return real_tensor, fake_tensor
@torch.no_grad()
def evaluate(self, step: int):
self.decoder.eval()
if self.use_disc: self.discriminator.eval()
val_mel, val_stft = 0.0, 0.0
val_steps = min(5, len(self.val_loader))
val_iter = iter(self.val_loader)
for i in range(val_steps):
x, y, gt_audio, audio_mask = next(val_iter)
x, y, gt_audio, audio_mask = x.to(self.device), y.to(self.device), gt_audio.to(self.device), audio_mask.to(self.device)
with torch.autocast(device_type=self.device.type, dtype=self.dtype):
outputs = self.llm(x, output_hidden_states=True)
hidden_states = outputs.hidden_states[-1].to(torch.float32)
# Gather and pad latents
gathered_list = [hidden_states[b][audio_mask[b]] for b in range(hidden_states.size(0))]
decoder_in = torch.nn.utils.rnn.pad_sequence(gathered_list, batch_first=True).transpose(1, 2)
fake_audio = self.decoder(decoder_in).squeeze(1)
min_len = min(fake_audio.size(1), gt_audio.size(1))
fake_audio, real_audio = fake_audio[:, :min_len], gt_audio[:, :min_len]
# Generate Mel Images on first batch
if i == 0:
gen_mel = self.mel_fn(fake_audio[0:1]).squeeze(0).cpu().numpy()
real_mel = self.mel_fn(real_audio[0:1]).squeeze(0).cpu().numpy()
fig, ax = plt.subplots(2, 1, figsize=(10, 6))
ax[0].imshow(real_mel, aspect='auto', origin='lower')
ax[0].set_title("Ground Truth Mel")
ax[1].imshow(gen_mel, aspect='auto', origin='lower')
ax[1].set_title(f"Generated Mel (Step {step})")
plt.tight_layout()
self.writer.add_figure("Val/Mel_Spectrogram", fig, step)
self.writer.add_audio("Val/Generated_Audio", fake_audio[0], step, sample_rate=self.config['dataset']['sample_rate'])
self.writer.flush()
sc_loss, mag_loss = self.mr_stft(fake_audio, real_audio)
val_stft += (sc_loss + mag_loss).item()
val_stft /= val_steps
self.writer.add_scalar("Val/STFT_Loss", val_stft, step)
self.logger.info(f"[Val Step {step}] MR-STFT Loss: {val_stft:.3f}")
self.decoder.train()
if self.use_disc: self.discriminator.train()
def save_checkpoint(self, step: int, name: str = None):
save_name = name if name else f"checkpoint_{step}"
path = Path(self.config['model']['decoder_save_dir']) / save_name
os.makedirs(path, exist_ok=True)
torch.save(self.decoder.state_dict(), path / "decoder.pth")
if self.use_disc:
torch.save(self.discriminator.state_dict(), path / "discriminator.pth")
self.logger.info(f"Saved Decoder checkpoint to {path}")
def train(self):
max_steps = self.config['decoder_training']['max_steps']
val_freq = self.config['decoder_training']['val_freq']
save_freq = self.config['decoder_training']['save_freq']
self.logger.info(f"Starting Decoder GAN Training for {max_steps} steps...")
train_iter = iter(self.train_loader)
pbar = tqdm(range(max_steps), ncols=150, dynamic_ncols=True)
for step in pbar:
start_time = time.time()
try:
x, y, gt_audio, audio_mask = next(train_iter)
except StopIteration:
train_iter = iter(self.train_loader)
x, y, gt_audio, audio_mask = next(train_iter)
x, y, gt_audio, audio_mask = x.to(self.device), y.to(self.device), gt_audio.to(self.device), audio_mask.to(self.device)
# 1. Forward LLM (Frozen)
with torch.no_grad():
with torch.autocast(device_type=self.device.type, dtype=self.dtype):
hidden_states = self.llm(x, output_hidden_states=True).hidden_states[-1].to(torch.float32)
# 2. Gather active audio latent states and pad for Decoder
gathered_list = []
valid_lens = []
for b_idx in range(hidden_states.size(0)):
states = hidden_states[b_idx][audio_mask[b_idx]]
gathered_list.append(states)
valid_lens.append(states.size(0) * SAMPLES_PER_TOKEN)
decoder_in = torch.nn.utils.rnn.pad_sequence(gathered_list, batch_first=True).transpose(1, 2)
# =======================================================
# TRAIN DISCRIMINATOR
# =======================================================
d_loss_item = 0.0
if self.use_disc:
self.opt_d.zero_grad()
with torch.no_grad(): # Detach generator graph
fake_audio = self.decoder(decoder_in).squeeze(1)
min_len = min(fake_audio.size(1), gt_audio.size(1))
fake_audio, real_audio = fake_audio[:, :min_len], gt_audio[:, :min_len]
real_crops, fake_crops = self._crop_for_gan(real_audio, fake_audio, valid_lens)
y_d_rs, y_d_gs, _, _ = self.discriminator(real_crops, fake_crops)
d_loss, _, _ = discriminator_loss(y_d_rs, y_d_gs)
d_loss.backward()
torch.nn.utils.clip_grad_norm_(self.discriminator.parameters(), 1.0)
self.opt_d.step()
d_loss_item = d_loss.item()
# =======================================================
# TRAIN GENERATOR (DECODER)
# =======================================================
self.opt_g.zero_grad()
fake_audio = self.decoder(decoder_in).squeeze(1)
min_len = min(fake_audio.size(1), gt_audio.size(1))
fake_audio, real_audio = fake_audio[:, :min_len], gt_audio[:, :min_len]
# Mel & STFT Losses (Audio reconstruction)
pred_mel, gt_mel = self.mel_fn(fake_audio), self.mel_fn(real_audio)
# Masking for Mel Loss based on valid sequence length
mel_loss = 0.0
frames_per_token = SAMPLES_PER_TOKEN // 512
for b in range(fake_audio.size(0)):
v_mel_len = min((valid_lens[b] // 512), pred_mel.size(2))
if v_mel_len > 0:
mel_loss += F.l1_loss(pred_mel[b, :, :v_mel_len], gt_mel[b, :, :v_mel_len])
mel_loss /= fake_audio.size(0)
sc_loss, mag_loss = self.mr_stft(fake_audio, real_audio)
# GAN Generator Losses
loss_fm, loss_gen = torch.tensor(0.0, device=self.device), torch.tensor(0.0, device=self.device)
if self.use_disc:
real_crops_g, fake_crops_g = self._crop_for_gan(real_audio, fake_audio, valid_lens)
y_d_rs, y_d_gs, fmap_rs, fmap_gs = self.discriminator(real_crops_g, fake_crops_g)
loss_fm = feature_matching_loss(fmap_rs, fmap_gs)
loss_gen, _ = generator_loss(y_d_gs)
total_loss_g = (self.lambda_mel * mel_loss) + (self.lambda_stft * (sc_loss + mag_loss)) + (self.lambda_gen * loss_gen) + (self.lambda_fm * loss_fm)
total_loss_g.backward()
torch.nn.utils.clip_grad_norm_(self.decoder.parameters(), 1.0)
self.opt_g.step()
# Logging
self.writer.add_scalar("Train/Mel_Loss", mel_loss.item(), step)
self.writer.add_scalar("Train/MR_STFT_Loss", (sc_loss + mag_loss).item(), step)
if self.use_disc:
self.writer.add_scalar("Train/D_Loss", d_loss_item, step)
self.writer.add_scalar("Train/G_Loss", loss_gen.item(), step)
dt_ms = (time.time() - start_time) * 1000
pbar.set_description(
f"STFT: {(sc_loss+mag_loss).item():.3f} | Mel: {mel_loss.item():.3f} | "
f"G: {loss_gen.item():.3f} | D: {d_loss_item:.3f} | {dt_ms:.1f}ms"
)
if step > 0 and step % val_freq == 0:
self.evaluate(step)
if step > 0 and step % save_freq == 0:
self.save_checkpoint(step)
self.save_checkpoint(max_steps, name="final_decoder")
self.writer.close()
self.logger.info("Stage 2 Decoder Training Complete.")
def main():
parser = argparse.ArgumentParser(description="Train Soprano Vocos Decoder (GAN)")
parser.add_argument("--config", type=Path, default=Path("config.yaml"))
parser.add_argument("--no-disc", action="store_true", help="Disable GAN Discriminator")
args = parser.parse_args()
with open(args.config, 'r') as f:
config = yaml.safe_load(f)
trainer = SopranoDecoderTrainer(config, use_disc=not args.no_disc)
trainer.train()
if __name__ == '__main__':
main()
+290
View File
@@ -0,0 +1,290 @@
# train_llm.py - Trains the SopranoLLM backbone to predict discrete audio tokens from text prompts.
import argparse
import logging
import os
import time
from pathlib import Path
from typing import Tuple, Dict, Any
import torch
import torch.nn.functional as F
import yaml
from torch.utils.data import DataLoader
from torch.utils.tensorboard import SummaryWriter
from tqdm import tqdm
from transformers import AutoModelForCausalLM, AutoTokenizer, PreTrainedTokenizer
from dataset import SopranoLLMDataset, LLMCollator
def setup_logger(log_file: str):
"""Configures a persistent file and console logger."""
logger = logging.getLogger("SopranoLLM")
logger.setLevel(logging.INFO)
if logger.hasHandlers():
logger.handlers.clear()
formatter = logging.Formatter('%(asctime)s - %(message)s')
fh = logging.FileHandler(log_file)
fh.setFormatter(formatter)
logger.addHandler(fh)
ch = logging.StreamHandler()
ch.setFormatter(logging.Formatter('%(message)s'))
logger.addHandler(ch)
return logger
class SopranoLLMTrainer:
"""
Stage 1: Trains the Causal LLM to predict discrete audio tokens from text.
Uses pure Autoregressive Cross-Entropy (No Teacher Distillation).
"""
def __init__(self, config: Dict[str, Any]):
self.config = config
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
self.logger = setup_logger(self.config['logging'].get('log_file', 'llm_training.log'))
self.logger.info("Initializing Soprano Stage-1 LLM Trainer...")
# Hardware Optimizations
torch.manual_seed(self.config['optimizations']['seed'])
if torch.cuda.is_available():
torch.cuda.manual_seed(self.config['optimizations']['seed'])
self.allow_tf32 = self.config['optimizations']['allow_tf32']
torch.set_float32_matmul_precision('high' if self.allow_tf32 else 'highest')
if torch.cuda.is_available():
torch.backends.cuda.matmul.allow_tf32 = self.allow_tf32
torch.backends.cudnn.allow_tf32 = self.allow_tf32
self.dtype = getattr(torch, self.config['optimizations']['mixed_precision'])
self.attn_impl = self.config['optimizations']['attn_implementation']
# Setup TensorBoard
tb_dir = Path(self.config['logging']['tensorboard_dir']) / "llm_stage"
self.writer = SummaryWriter(log_dir=str(tb_dir))
# Load Components
self.tokenizer = self._setup_tokenizer()
self.model = self._setup_model()
# Token Boundaries
self.audio_min_id = int(self.tokenizer.convert_tokens_to_ids("[0]")) # type: ignore
self.audio_max_id = int(self.tokenizer.convert_tokens_to_ids("[7999]")) # type: ignore
self.stop_id = int(self.tokenizer.convert_tokens_to_ids("[STOP]")) # type: ignore
# Dataloaders
self.train_loader, self.val_loader = self._setup_dataloaders()
# Optimizer
self.max_lr = self.config['llm_training']['learning_rate']
self.opt = torch.optim.AdamW(
self.model.parameters(),
lr=self.max_lr,
weight_decay=self.config['llm_training']['weight_decay'],
fused=(self.device.type == "cuda")
)
# WSD Scheduler Params
self.max_steps = self.config['llm_training']['max_steps']
self.warmup_steps = int(self.max_steps * 0.1) # 10% warmup
self.cooldown_steps = int(self.max_steps * 0.1) # 10% cooldown
self.min_lr = 0.1 * self.max_lr
def _setup_tokenizer(self) -> PreTrainedTokenizer:
self.logger.info("Loading Tokenizer...")
tokenizer = AutoTokenizer.from_pretrained(self.config['model']['base_llm'], use_fast=True)
if tokenizer.pad_token_id is None:
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = 'right'
return tokenizer
def _setup_model(self) -> torch.nn.Module:
self.logger.info(f"Loading Student LLM (Attention: {self.attn_impl}, Dtype: {self.dtype})...")
model = AutoModelForCausalLM.from_pretrained(
self.config['model']['base_llm'],
attn_implementation=self.attn_impl
)
model.to(self.dtype).to(self.device)
model.train()
return model
def _setup_dataloaders(self) -> Tuple[DataLoader, DataLoader]:
self.logger.info("Initializing DataLoaders...")
input_dir = Path(self.config['dataset']['input_dir'])
batch_size = self.config['llm_training']['batch_size']
collator = LLMCollator(tokenizer=self.tokenizer)
train_dataset = SopranoLLMDataset(str(input_dir / 'train.json'))
val_dataset = SopranoLLMDataset(str(input_dir / 'val.json'))
train_loader = DataLoader(
train_dataset, batch_size=batch_size, shuffle=True,
num_workers=4, collate_fn=collator, drop_last=True
)
val_loader = DataLoader(
val_dataset, batch_size=batch_size, shuffle=False,
num_workers=2, collate_fn=collator, drop_last=True
)
return train_loader, val_loader
def get_lr(self, step: int) -> float:
"""Warmup-Stable-Decay (WSD) Learning Rate Schedule."""
if step < self.warmup_steps:
return self.max_lr * (step + 1) / self.warmup_steps
if step < self.max_steps - self.cooldown_steps:
return self.max_lr
decay_ratio = (self.max_steps - step) / self.cooldown_steps
return self.min_lr + (self.max_lr - self.min_lr) * decay_ratio
def compute_loss(self, logits: torch.Tensor, targets: torch.Tensor, mask: torch.Tensor, text_factor: float = 0.5):
"""Calculates split loss for Text vs Audio tokens."""
pred = logits.view(-1, logits.size(-1))
labels = targets.reshape(-1)
flat_mask = mask.reshape(-1)
loss = F.cross_entropy(pred, labels, reduction='none') * flat_mask
# Audio mask includes all acoustic tokens and the STOP token
is_audio_cond = (labels >= self.audio_min_id) & (labels <= self.audio_max_id) | (labels == self.stop_id)
audio_mask = is_audio_cond & (flat_mask > 0)
text_mask = (~is_audio_cond) & (flat_mask > 0)
# Averages
audio_loss = loss[audio_mask].mean() if audio_mask.any() else torch.tensor(0.0, device=self.device)
text_loss = loss[text_mask].mean() if text_mask.any() else torch.tensor(0.0, device=self.device)
# Accuracy (Audio only)
predictions = logits.argmax(dim=-1).view(-1)
acc = (predictions == labels)[audio_mask].to(torch.float32).mean() if audio_mask.any() else torch.tensor(0.0, device=self.device)
return audio_loss, text_loss, acc
@torch.no_grad()
def evaluate(self, step: int):
self.model.eval()
val_audio_loss, val_text_loss, val_acc = 0.0, 0.0, 0.0
val_steps = 10
val_iter = iter(self.val_loader)
for i in range(val_steps):
try:
x, y, attn_mask = next(val_iter)
except StopIteration:
break
x, y, attn_mask = x.to(self.device), y.to(self.device), attn_mask.to(self.device)
with torch.autocast(device_type=self.device.type, dtype=self.dtype):
logits = self.model(x, attention_mask=attn_mask).logits
a_loss, t_loss, acc = self.compute_loss(logits, y, attn_mask)
val_audio_loss += a_loss.item()
val_text_loss += t_loss.item()
val_acc += acc.item()
val_audio_loss /= val_steps
val_text_loss /= val_steps
val_acc /= val_steps
self.writer.add_scalar("Val/Audio_Loss", val_audio_loss, step)
self.writer.add_scalar("Val/Text_Loss", val_text_loss, step)
self.writer.add_scalar("Val/Accuracy", val_acc, step)
self.logger.info(f"[Val Step {step}] Audio CE: {val_audio_loss:.3f} | Text CE: {val_text_loss:.3f} | Acc: {val_acc:.4f}")
self.model.train()
def save_checkpoint(self, step: int, name: str = None):
save_name = name if name else f"checkpoint_{step}"
path = Path(self.config['model']['llm_save_dir']) / save_name
os.makedirs(path, exist_ok=True)
self.logger.info(f"Saving LLM Checkpoint to: {path}")
self.model.save_pretrained(path)
self.tokenizer.save_pretrained(path)
def train(self):
val_freq = self.config['llm_training']['val_freq']
save_freq = self.config['llm_training']['save_freq']
grad_accum = self.config['llm_training']['grad_accum_steps']
text_factor = 0.5 # Weight text loss less than audio loss
self.logger.info(f"Starting LLM Stage 1 for {self.max_steps} steps...")
train_iter = iter(self.train_loader)
pbar = tqdm(range(self.max_steps), ncols=150, dynamic_ncols=True)
for step in pbar:
start_time = time.time()
self.opt.zero_grad(set_to_none=True)
accum_a_loss = torch.tensor(0.0, device=self.device)
accum_t_loss = torch.tensor(0.0, device=self.device)
accum_acc = torch.tensor(0.0, device=self.device)
for _ in range(grad_accum):
try:
x, y, attn_mask = next(train_iter)
except StopIteration:
train_iter = iter(self.train_loader)
x, y, attn_mask = next(train_iter)
x, y, attn_mask = x.to(self.device), y.to(self.device), attn_mask.to(self.device)
with torch.autocast(device_type=self.device.type, dtype=self.dtype):
logits = self.model(x, attention_mask=attn_mask).logits
a_loss, t_loss, acc = self.compute_loss(logits, y, attn_mask, text_factor)
total_loss = (a_loss + (text_factor * t_loss)) / grad_accum
total_loss.backward()
accum_a_loss += a_loss.detach() / grad_accum
accum_t_loss += t_loss.detach() / grad_accum
accum_acc += acc.detach() / grad_accum
torch.nn.utils.clip_grad_norm_(self.model.parameters(), 1.0)
lr = self.get_lr(step)
for param_group in self.opt.param_groups:
param_group['lr'] = lr
self.opt.step()
# Logging
self.writer.add_scalar("Train/Audio_Loss", accum_a_loss.item(), step)
self.writer.add_scalar("Train/Text_Loss", accum_t_loss.item(), step)
self.writer.add_scalar("Train/Accuracy", accum_acc.item(), step)
self.writer.add_scalar("Train/Learning_Rate", lr, step)
dt_ms = (time.time() - start_time) * 1000
pbar.set_description(
f"A-Loss: {accum_a_loss.item():.3f} | T-Loss: {accum_t_loss.item():.3f} | "
f"Acc: {accum_acc.item():.4f} | {dt_ms:.1f}ms"
)
if step > 0 and step % val_freq == 0:
self.evaluate(step)
if step > 0 and step % save_freq == 0:
self.save_checkpoint(step)
self.save_checkpoint(self.max_steps, name="final_llm")
self.writer.close()
self.logger.info("Stage 1 LLM Training Complete.")
def main():
parser = argparse.ArgumentParser(description="Train Soprano LLM Backbone")
parser.add_argument("--config", type=Path, default=Path("config.yaml"), help="Path to config")
args = parser.parse_args()
with open(args.config, 'r') as f:
config = yaml.safe_load(f)
trainer = SopranoLLMTrainer(config)
trainer.train()
if __name__ == '__main__':
main()
-41
View File
@@ -1,41 +0,0 @@
# training/collator.py
import torch
import random
class SopranoCollator:
def __init__(self, tokenizer, seq_len=1024):
self.tokenizer = tokenizer
self.seq_len = seq_len
self.pad_token_id = tokenizer.pad_token_id or tokenizer.eos_token_id
def pack_for_llm(self, batch):
texts = [item["text"] for item in batch]
encodings = self.tokenizer(texts, add_special_tokens=False, padding=False, truncation=False)
input_ids_list = encodings["input_ids"]
packed_batch = []
buffer = []
buffer_len = 0
random.shuffle(input_ids_list)
for ids in input_ids_list:
ids = torch.tensor(ids, dtype=torch.long)
if buffer_len + len(ids) > self.seq_len:
full_seq = torch.cat(buffer)
if len(full_seq) < self.seq_len + 1:
padding = torch.full((self.seq_len + 1 - len(full_seq),), self.pad_token_id, dtype=torch.long)
full_seq = torch.cat([full_seq, padding])
packed_batch.append(full_seq[:self.seq_len + 1])
buffer, buffer_len = [], 0
buffer.append(ids)
buffer_len += len(ids)
if not packed_batch: return None, None
batch_tensor = torch.stack(packed_batch)
return batch_tensor[:, :-1], batch_tensor[:, 1:]
def collate_for_decoder(self, batch):
texts = [item["text"] for item in batch]
wav_paths = [item["wav_path"] for item in batch]
encodings = self.tokenizer(texts, padding=True, truncation=True, max_length=self.seq_len, return_tensors="pt", add_special_tokens=False)
return encodings["input_ids"], wav_paths
-41
View File
@@ -1,41 +0,0 @@
# training/loss.py
import torch
import torch.nn as nn
import torchaudio
class MultiResolutionSTFTLoss(nn.Module):
def __init__(self, fft_sizes=[1024, 2048, 512], hop_sizes=[120, 240, 50], win_lengths=[600, 1200, 240]):
super().__init__()
self.fft_sizes, self.hop_sizes, self.win_lengths = fft_sizes, hop_sizes, win_lengths
def stft(self, x, fft_size, hop_size, win_length):
window = torch.hann_window(win_length).to(x.device).float()
return torch.stft(
x.float(), n_fft=fft_size, hop_length=hop_size, win_length=win_length,
window=window, return_complex=True, center=True
)
def forward(self, x, y):
loss = 0.0
for n_fft, hop, win in zip(self.fft_sizes, self.hop_sizes, self.win_lengths):
x_stft, y_stft = self.stft(x, n_fft, hop, win), self.stft(y, n_fft, hop, win)
sc_loss = (x_stft.abs() - y_stft.abs()).norm(p="fro") / (y_stft.abs().norm(p="fro") + 1e-7)
mag_loss = (torch.log(x_stft.abs() + 1e-7) - torch.log(y_stft.abs() + 1e-7)).abs().mean()
loss += sc_loss + mag_loss
return loss / len(self.fft_sizes)
class AudioReconstructionLoss(nn.Module):
def __init__(self, sample_rate=32000):
super().__init__()
self.mel = torchaudio.transforms.MelSpectrogram(sample_rate=sample_rate, n_mels=80, n_fft=2048, hop_length=512, normalized=True)
self.stft = MultiResolutionSTFTLoss()
def forward(self, pred, target):
pred, target = pred.squeeze(1).float(), target.squeeze(1).float()
min_len = min(pred.shape[-1], target.shape[-1])
pred, target = pred[..., :min_len], target[..., :min_len]
if self.mel.mel_scale.fb.device != pred.device: self.mel = self.mel.to(pred.device)
mel_pred = torch.log(self.mel(pred) + 1e-5)
mel_target = torch.log(self.mel(target) + 1e-5)
loss_mel = (mel_pred - mel_target).abs().mean()
return loss_mel + self.stft(pred, target)
-40
View File
@@ -1,40 +0,0 @@
# utils/config.py
import yaml
import torch
import os
from pathlib import Path
class Config:
def __init__(self, config_path=None):
if config_path is None:
config_path = os.environ.get("SOPRANO_CONFIG", "config/settings.yaml")
# Resolve the absolute path to avoid "File Not Found" errors when running from subdirectories
self.path = Path(config_path).absolute()
if not self.path.exists():
# Fallback for localized execution
self.path = Path(os.getcwd()) / Path(config_path).name
if not self.path.exists():
raise FileNotFoundError(f"Config not found at: {self.path}")
with open(self.path, "r", encoding="utf-8") as f:
self.cfg = yaml.safe_load(f)
# Shortcuts for cleaner code access
self.common = self.cfg.get("common", {})
self.codec = self.cfg.get("codec", {})
self.dataset = self.cfg.get("dataset", {})
self.training = self.cfg.get("training", {})
@property
def device(self):
"""Resolves 'auto' to 'cuda' (GPU) if available, otherwise 'cpu'."""
d = self.common.get("device", "auto")
if d == "auto":
return "cuda" if torch.cuda.is_available() else "cpu"
return d
# Global instance for easy importing: from utils.config import cfg
cfg = Config()
-29
View File
@@ -1,29 +0,0 @@
# utils/text_normalizer.py
import re
import unicodedata
def normalize_text(text: str) -> str:
"""
Standardizes text for training and inference.
- Lowercases everything.
- Removes accents (e.g., é -> e).
- Strips unknown symbols.
- Collapses extra whitespace.
"""
if not isinstance(text, str):
return ""
# 1. Standardize Case
text = text.lower()
# 2. Decompose characters and remove non-spacing marks (accents)
text = unicodedata.normalize('NFD', text)
text = "".join([c for c in text if unicodedata.category(c) != 'Mn'])
# 3. Filter characters: keep only basic English letters, numbers, spaces, and core punctuation
text = re.sub(r"[^a-z0-9\s.,!?'-]", "", text)
# 4. Collapse multiple spaces into one
text = re.sub(r"\s+", " ", text)
return text.strip()
-64
View File
@@ -1,64 +0,0 @@
import torch
import torchaudio
from model.encoder import Encoder
from model.decoder import Decoder
from train_codec import SpectralLoss
def verify():
print("Verifying Decoder Pipeline...")
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using device: {device}")
# Initialize models
try:
encoder = Encoder().to(device).float()
decoder = Decoder(
input_channels=5, # Bottleneck channels
decoder_dim=512,
decoder_layers=8
).to(device).float()
print("Models initialized successfully.")
except Exception as e:
print(f"Failed to initialize models: {e}")
return
# Create dummy audio [1, 1, 32000] (1 second at 32kHz)
audio = torch.randn(1, 1, 32000).to(device).float()
print(f"Input audio shape: {audio.shape}")
# Forward pass
try:
# Encoder
z = encoder(audio)
print(f"Encoder output shape: {z.shape}")
# Decoder
rec = decoder(z)
print(f"Decoder output shape: {rec.shape}")
# Check shapes
if rec.shape != audio.shape:
print(f"WARNING: Shape mismatch! Input: {audio.shape}, Output: {rec.shape}")
# It might be slightly off due to padding/striding, but should be close.
# ISTFT usually returns exact length if configured right, or slightly more.
else:
print("Shapes match exactly!")
except Exception as e:
print(f"Forward pass failed: {e}")
return
# Loss computation
try:
criterion = SpectralLoss().to(device)
loss = criterion(rec, audio)
print(f"Loss computed successfully: {loss.item()}")
except Exception as e:
print(f"Loss computation failed: {e}")
return
print("Verification passed!")
if __name__ == "__main__":
verify()