Download training/extract_voicepack.py from hamdallah/sofelia-tts-checkpoints: direct link, hf CLI and curl.
- Browser
- Download file 17.7 kB
-
https://huggingface.co/hamdallah/sofelia-tts-checkpoints/resolve/main/training/extract_voicepack.py
- Command line
-
hf download hf://hamdallah/sofelia-tts-checkpoints/training/extract_voicepack.py
-
curl -L -o extract_voicepack.py https://huggingface.co/hamdallah/sofelia-tts-checkpoints/resolve/main/training/extract_voicepack.py
17.7 kB
| #!/usr/bin/env python3 | |
| """ | |
| Kokoro German: Extract Voicepack | |
| ================================= | |
| Extracts a voicepack (.pt) from a fine-tuned StyleTTS2 checkpoint by running | |
| both style encoders (acoustic + prosodic) on representative utterances and | |
| averaging the resulting style vectors. | |
| Usage: | |
| # Single checkpoint (Stage 1 only β uses style_encoder for both halves) | |
| python scripts/extract_voicepack.py \ | |
| --model StyleTTS2/logs/kokoro_german/epoch_1st_00002.pth \ | |
| --audio-dir dataset/audio/dm_daniel \ | |
| --output voices/dm_daniel.pt | |
| # Two checkpoints (recommended after Stage 2 training): | |
| # style_encoder from Stage 1, predictor_encoder from Stage 2 | |
| python scripts/extract_voicepack.py \ | |
| --model StyleTTS2/logs/kokoro_german/epoch_2nd_00001.pth \ | |
| --style-encoder-model StyleTTS2/logs/kokoro_german/epoch_1st_00002.pth \ | |
| --audio-dir dataset/audio/dm_daniel \ | |
| --output voices/dm_daniel.pt | |
| # CPU (slower but works without GPU / while GPU is busy training) | |
| python scripts/extract_voicepack.py \ | |
| --model StyleTTS2/logs/kokoro_german/epoch_1st_00002.pth \ | |
| --audio-dir dataset/audio/dm_daniel \ | |
| --output voices/dm_daniel.pt \ | |
| --device cpu | |
| Voicepack format: tensor of shape [510, 1, 256] (float32) | |
| - 510 = max phoneme sequence length | |
| - 256 = style_dim * 2 (128 for decoder/timbre + 128 for predictor/prosody) | |
| - First 128 dims come from style_encoder (acoustic/timbre) | |
| - Last 128 dims come from predictor_encoder (prosody) | |
| """ | |
| import argparse | |
| import math | |
| import sys | |
| from pathlib import Path | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| from torch.nn.utils.parametrizations import spectral_norm | |
| # ββ StyleTTS2 model components (standalone, no external imports) βββββββββ | |
| class LearnedDownSample(nn.Module): | |
| def __init__(self, layer_type, dim_in): | |
| super().__init__() | |
| self.layer_type = layer_type | |
| if self.layer_type == "none": | |
| self.conv = nn.Identity() | |
| elif self.layer_type == "timepreserve": | |
| self.conv = spectral_norm( | |
| nn.Conv2d( | |
| dim_in, | |
| dim_in, | |
| kernel_size=(3, 1), | |
| stride=(2, 1), | |
| groups=dim_in, | |
| padding=(1, 0), | |
| ) | |
| ) | |
| elif self.layer_type == "half": | |
| self.conv = spectral_norm( | |
| nn.Conv2d( | |
| dim_in, | |
| dim_in, | |
| kernel_size=(3, 3), | |
| stride=(2, 2), | |
| groups=dim_in, | |
| padding=1, | |
| ) | |
| ) | |
| else: | |
| raise RuntimeError(f"Unexpected downsample type: {self.layer_type}") | |
| def forward(self, x): | |
| return self.conv(x) | |
| class DownSample(nn.Module): | |
| def __init__(self, layer_type): | |
| super().__init__() | |
| self.layer_type = layer_type | |
| def forward(self, x): | |
| if self.layer_type == "none": | |
| return x | |
| elif self.layer_type == "timepreserve": | |
| return F.avg_pool2d(x, (2, 1)) | |
| elif self.layer_type == "half": | |
| if x.shape[-1] % 2 != 0: | |
| x = torch.cat([x, x[..., -1].unsqueeze(-1)], dim=-1) | |
| return F.avg_pool2d(x, 2) | |
| else: | |
| raise RuntimeError(f"Unexpected downsample type: {self.layer_type}") | |
| class ResBlk(nn.Module): | |
| def __init__( | |
| self, | |
| dim_in, | |
| dim_out, | |
| actv=nn.LeakyReLU(0.2), | |
| normalize=False, | |
| downsample="none", | |
| ): | |
| super().__init__() | |
| self.actv = actv | |
| self.normalize = normalize | |
| self.downsample = DownSample(downsample) | |
| self.downsample_res = LearnedDownSample(downsample, dim_in) | |
| self.learned_sc = dim_in != dim_out | |
| self._build_weights(dim_in, dim_out) | |
| def _build_weights(self, dim_in, dim_out): | |
| self.conv1 = spectral_norm(nn.Conv2d(dim_in, dim_in, 3, 1, 1)) | |
| self.conv2 = spectral_norm(nn.Conv2d(dim_in, dim_out, 3, 1, 1)) | |
| if self.normalize: | |
| self.norm1 = nn.InstanceNorm2d(dim_in, affine=True) | |
| self.norm2 = nn.InstanceNorm2d(dim_in, affine=True) | |
| if self.learned_sc: | |
| self.conv1x1 = spectral_norm( | |
| nn.Conv2d(dim_in, dim_out, 1, 1, 0, bias=False) | |
| ) | |
| def _shortcut(self, x): | |
| if self.learned_sc: | |
| x = self.conv1x1(x) | |
| if self.downsample: | |
| x = self.downsample(x) | |
| return x | |
| def _residual(self, x): | |
| if self.normalize: | |
| x = self.norm1(x) | |
| x = self.actv(x) | |
| x = self.conv1(x) | |
| x = self.downsample_res(x) | |
| if self.normalize: | |
| x = self.norm2(x) | |
| x = self.actv(x) | |
| x = self.conv2(x) | |
| return x | |
| def forward(self, x): | |
| x = self._shortcut(x) + self._residual(x) | |
| return x / math.sqrt(2) | |
| class StyleEncoder(nn.Module): | |
| def __init__(self, dim_in=48, style_dim=48, max_conv_dim=384): | |
| super().__init__() | |
| blocks = [] | |
| blocks += [spectral_norm(nn.Conv2d(1, dim_in, 3, 1, 1))] | |
| repeat_num = 4 | |
| for _ in range(repeat_num): | |
| dim_out = min(dim_in * 2, max_conv_dim) | |
| blocks += [ResBlk(dim_in, dim_out, downsample="half")] | |
| dim_in = dim_out | |
| blocks += [nn.LeakyReLU(0.2)] | |
| blocks += [spectral_norm(nn.Conv2d(dim_out, dim_out, 5, 1, 0))] | |
| blocks += [nn.AdaptiveAvgPool2d(1)] | |
| blocks += [nn.LeakyReLU(0.2)] | |
| self.shared = nn.Sequential(*blocks) | |
| self.unshared = nn.Linear(dim_out, style_dim) | |
| def forward(self, x): | |
| h = self.shared(x) | |
| h = h.view(h.size(0), -1) | |
| s = self.unshared(h) | |
| return s | |
| # ββ Main extraction logic βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def extract_voicepack( | |
| model_path: str, | |
| audio_dir: str, | |
| output_path: str, | |
| num_samples: int = 200, | |
| device: str = "auto", | |
| style_encoder_model: str = None, | |
| ): | |
| import random | |
| import soundfile as sf | |
| import torchaudio | |
| # ββ Resolve device βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if device == "auto": | |
| if torch.cuda.is_available(): | |
| device = "cuda" | |
| else: | |
| device = "cpu" | |
| print(f"Using device: {device}") | |
| # ββ Collect audio files ββββββββββββββββββββββββββββββββββββββββββββββ | |
| audio_path = Path(audio_dir) | |
| wav_files = sorted(audio_path.glob("*.wav")) | |
| if not wav_files: | |
| print(f"ERROR: No WAV files found in {audio_dir}") | |
| sys.exit(1) | |
| print(f"Found {len(wav_files):,} WAV files in {audio_dir}") | |
| # Sample a subset for embedding | |
| rng = random.Random(42) | |
| if len(wav_files) > num_samples: | |
| wav_files = rng.sample(wav_files, num_samples) | |
| print(f"Using {len(wav_files)} samples for voicepack extraction") | |
| # ββ Build style encoders and load weights ββββββββββββββββββββββββββββ | |
| def strip_prefix(state_dict, prefix="module."): | |
| """Strip DataParallel 'module.' prefix from state dict keys if present.""" | |
| if any(k.startswith(prefix) for k in state_dict.keys()): | |
| return { | |
| k[len(prefix) :] if k.startswith(prefix) else k: v | |
| for k, v in state_dict.items() | |
| } | |
| return state_dict | |
| print(f"Loading checkpoint: {model_path}") | |
| checkpoint = torch.load(model_path, map_location="cpu", weights_only=False) | |
| net = checkpoint["net"] | |
| # Kokoro-82M architecture: dim_in=64, style_dim=128, hidden_dim=512 | |
| style_encoder = StyleEncoder(dim_in=64, style_dim=128, max_conv_dim=512) | |
| predictor_encoder = StyleEncoder(dim_in=64, style_dim=128, max_conv_dim=512) | |
| # Load style_encoder weights β from a separate checkpoint if provided, | |
| # otherwise from the main checkpoint. Stage 2 training can degrade the | |
| # style_encoder (spectral_norm buffer drift), so loading from Stage 1 | |
| # is recommended when using a Stage 2 checkpoint. | |
| if style_encoder_model: | |
| print(f"Loading style_encoder from separate checkpoint: {style_encoder_model}") | |
| se_checkpoint = torch.load( | |
| style_encoder_model, map_location="cpu", weights_only=False | |
| ) | |
| se_net = se_checkpoint["net"] | |
| style_encoder.load_state_dict(strip_prefix(se_net["style_encoder"])) | |
| se_epoch = se_checkpoint.get("epoch", "?") | |
| print(f" style_encoder loaded (epoch {se_epoch})") | |
| else: | |
| style_encoder.load_state_dict(strip_prefix(net["style_encoder"])) | |
| # predictor_encoder is only trained in Stage 2 (train_second.py). | |
| # After Stage 1 only, its weights are randomly initialized and produce | |
| # exploded outputs even though parameter norms look reasonable. | |
| # Detect this by checking output norm on a dummy input. If exploded, | |
| # fall back to using style_encoder for both halves of the voicepack | |
| # (same initialization Stage 2 uses β it deep-copies style_encoder). | |
| predictor_encoder_trained = True | |
| try: | |
| predictor_encoder.load_state_dict(strip_prefix(net["predictor_encoder"])) | |
| # Test output norm on a dummy mel-like input | |
| with torch.no_grad(): | |
| dummy = torch.randn(1, 1, 80, 200) | |
| test_out = predictor_encoder(dummy) | |
| if test_out.norm().item() > 1e3: | |
| predictor_encoder_trained = False | |
| except Exception: | |
| predictor_encoder_trained = False | |
| if not predictor_encoder_trained: | |
| print(" predictor_encoder appears untrained (Stage 1 only)") | |
| print(" Using style_encoder for both acoustic and prosodic embeddings") | |
| predictor_encoder.load_state_dict( | |
| strip_prefix(se_net["style_encoder"]) | |
| if style_encoder_model | |
| else strip_prefix(net["style_encoder"]) | |
| ) | |
| style_encoder = style_encoder.to(device).eval() | |
| predictor_encoder = predictor_encoder.to(device).eval() | |
| # Validate style encoder output norms | |
| with torch.no_grad(): | |
| dummy = torch.randn(1, 1, 80, 200).to(device) | |
| se_norm = style_encoder(dummy).norm().item() | |
| pe_norm = predictor_encoder(dummy).norm().item() | |
| print(f" style_encoder output norm (dummy): {se_norm:.4f}") | |
| print(f" predictor_encoder output norm (dummy): {pe_norm:.4f}") | |
| if se_norm < 0.5: | |
| print(f" WARNING: style_encoder norm is very low ({se_norm:.4f}).") | |
| print(f" This may indicate a collapsed encoder. Consider using") | |
| print(f" --style-encoder-model to load from a Stage 1 checkpoint.") | |
| epoch = checkpoint.get("epoch", "?") | |
| val_loss = checkpoint.get("val_loss", 0) | |
| print(f"Loaded encoders from checkpoint") | |
| print(f" Epoch: {epoch}, Val loss: {val_loss:.4f}") | |
| # ββ Mel spectrogram transform ββββββββββββββββββββββββββββββββββββββββ | |
| # Must match StyleTTS2's training preprocessing exactly: | |
| # mel = to_mel(waveform) # n_fft=2048, win=1200, hop=300, n_mels=80 | |
| # mel = (log(1e-5 + mel) - mean) / std # mean=-4, std=4 | |
| mel_transform = torchaudio.transforms.MelSpectrogram( | |
| sample_rate=24000, | |
| n_fft=2048, | |
| win_length=1200, | |
| hop_length=300, | |
| n_mels=80, | |
| ).to(device) | |
| mel_mean = -4 | |
| mel_std = 4 | |
| # ββ Extract style vectors ββββββββββββββββββββββββββββββββββββββββββββ | |
| acoustic_styles = [] | |
| prosodic_styles = [] | |
| skipped = 0 | |
| print(f"\nExtracting style vectors...") | |
| with torch.no_grad(): | |
| for i, wav_path in enumerate(wav_files): | |
| # Load audio via soundfile (avoids torchcodec dependency) | |
| try: | |
| data, sr = sf.read(str(wav_path), dtype="float32") | |
| except Exception as e: | |
| print(f" Skipping unreadable {wav_path}: {e}") | |
| skipped += 1 | |
| continue | |
| if data.ndim > 1: | |
| data = data.mean(axis=1) # mono | |
| if sr != 24000: | |
| # Resample using torchaudio | |
| waveform = torch.from_numpy(data).unsqueeze(0) | |
| waveform = torchaudio.functional.resample(waveform, sr, 24000) | |
| else: | |
| waveform = torch.from_numpy(data).unsqueeze(0) # [1, samples] | |
| waveform = waveform.to(device) | |
| # Compute mel spectrogram (same as StyleTTS2's preprocess()) | |
| mel = mel_transform(waveform) # [1, 80, T] | |
| mel = (torch.log(1e-5 + mel) - mel_mean) / mel_std # normalize | |
| # Skip clips with very short mels (style encoder needs >= 80 frames) | |
| if mel.shape[-1] < 80: | |
| skipped += 1 | |
| continue | |
| mel_input = mel.unsqueeze(1) # [1, 1, 80, T] β add channel dim | |
| # Run both style encoders | |
| s_acoustic = style_encoder(mel_input) # [1, 128] | |
| s_prosodic = predictor_encoder(mel_input) # [1, 128] | |
| acoustic_styles.append(s_acoustic.cpu()) | |
| prosodic_styles.append(s_prosodic.cpu()) | |
| if (i + 1) % 50 == 0: | |
| print(f" Processed {i + 1}/{len(wav_files)} files...") | |
| if skipped > 0: | |
| print(f" Skipped {skipped} files (too short)") | |
| print(f" Extracted {len(acoustic_styles)} style vectors") | |
| if not acoustic_styles: | |
| print("ERROR: No style vectors extracted. Check audio files.") | |
| sys.exit(1) | |
| # ββ Average and build voicepack ββββββββββββββββββββββββββββββββββββββ | |
| avg_acoustic = torch.cat(acoustic_styles, dim=0).mean(dim=0) # [128] | |
| avg_prosodic = torch.cat(prosodic_styles, dim=0).mean(dim=0) # [128] | |
| # Kokoro voicepack format: [510, 1, 256] | |
| # First 128 dims = acoustic (decoder conditioning / timbre) | |
| # Last 128 dims = prosodic (predictor conditioning / prosody) | |
| combined = torch.cat([avg_acoustic, avg_prosodic], dim=0) # [256] | |
| voicepack = combined.unsqueeze(0).unsqueeze(0).expand(510, 1, 256).clone() | |
| print(f"\nVoicepack shape: {tuple(voicepack.shape)}") | |
| print(f" Acoustic style norm: {avg_acoustic.norm():.4f}") | |
| print(f" Prosodic style norm: {avg_prosodic.norm():.4f}") | |
| # ββ Save βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| output = Path(output_path) | |
| output.parent.mkdir(parents=True, exist_ok=True) | |
| torch.save(voicepack, str(output)) | |
| print(f"\nSaved voicepack: {output} ({output.stat().st_size / 1024:.1f} KB)") | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Extract voicepack from fine-tuned Kokoro model", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| Examples: | |
| # Stage 1 only (uses style_encoder for both halves) | |
| python scripts/extract_voicepack.py \\ | |
| --model StyleTTS2/logs/kokoro_german/epoch_1st_00002.pth \\ | |
| --audio-dir dataset/audio/dm_daniel \\ | |
| --output voices/dm_daniel.pt | |
| # After Stage 2 (recommended: style_encoder from Stage 1, predictor_encoder from Stage 2) | |
| python scripts/extract_voicepack.py \\ | |
| --model StyleTTS2/logs/kokoro_german/epoch_2nd_00001.pth \\ | |
| --style-encoder-model StyleTTS2/logs/kokoro_german/epoch_1st_00002.pth \\ | |
| --audio-dir dataset/audio/dm_daniel \\ | |
| --output voices/dm_daniel.pt | |
| # CPU (while GPU is busy training) | |
| python scripts/extract_voicepack.py \\ | |
| --model StyleTTS2/logs/kokoro_german/epoch_1st_00002.pth \\ | |
| --device cpu | |
| """, | |
| ) | |
| parser.add_argument( | |
| "--model", | |
| required=True, | |
| help="Path to fine-tuned StyleTTS2 checkpoint (.pth). Used for predictor_encoder " | |
| "(and style_encoder unless --style-encoder-model is specified).", | |
| ) | |
| parser.add_argument( | |
| "--style-encoder-model", | |
| default=None, | |
| help="Path to a separate checkpoint for loading the style_encoder weights. " | |
| "Recommended: use a Stage 1 checkpoint here when --model is a Stage 2 " | |
| "checkpoint, because Stage 2 can degrade the style_encoder.", | |
| ) | |
| parser.add_argument( | |
| "--audio-dir", | |
| required=True, | |
| help="Directory containing speaker's WAV files", | |
| ) | |
| parser.add_argument( | |
| "--output", | |
| required=True, | |
| help="Output voicepack path (.pt)", | |
| ) | |
| parser.add_argument( | |
| "--num-samples", | |
| type=int, | |
| default=200, | |
| help="Number of audio samples to use for extraction (default: 200)", | |
| ) | |
| parser.add_argument( | |
| "--device", | |
| default="auto", | |
| choices=["auto", "cpu", "cuda"], | |
| help="Device to run on: auto (default), cpu, or cuda", | |
| ) | |
| args = parser.parse_args() | |
| extract_voicepack( | |
| model_path=args.model, | |
| audio_dir=args.audio_dir, | |
| output_path=args.output, | |
| num_samples=args.num_samples, | |
| device=args.device, | |
| style_encoder_model=args.style_encoder_model, | |
| ) | |
| if __name__ == "__main__": | |
| main() | |