Spaces:
Running on Zero
Running on Zero
| """ | |
| SVS preprocessor: converts raw SVS data to training-ready format. | |
| Contains :class:`SVSPreprocessor` (AudioVAE encoding + SVS token sequence | |
| construction) and :func:`create_lightweight_preprocessor` (token-maps-only | |
| variant for inference scripts). | |
| """ | |
| from pathlib import Path | |
| from typing import Dict, List, Tuple | |
| import torch | |
| import torch.nn as nn | |
| from einops import rearrange | |
| class SVSPreprocessor: | |
| """ | |
| Preprocessor that converts raw SVS data to training-ready format. | |
| This mirrors the logic in BatchProcessor + AudioFeatureProcessingPacker, | |
| but processes all data at once and saves to disk. | |
| Optimized to only load AudioVAE and tokenizer (not the full 800M+ model). | |
| """ | |
| def __init__( | |
| self, | |
| pretrained_path: str, | |
| sample_rate: int = 44100, | |
| device: str = "cuda", | |
| ): | |
| self.sample_rate = sample_rate | |
| self.device = torch.device(device if torch.cuda.is_available() else "cpu") | |
| # Load config | |
| config_path = Path(pretrained_path) / "config.json" | |
| print(f"Loading config from {config_path}...") | |
| with open(config_path, 'r') as f: | |
| import json as json_lib | |
| config_dict = json_lib.load(f) | |
| self.patch_size = config_dict.get("patch_size", 4) | |
| self.feat_dim = config_dict.get("feat_dim", 64) | |
| # Load AudioVAE only (not the full model!) | |
| # Auto-detect architecture: V1 uses AudioVAE, V2 uses AudioVAEV2 | |
| architecture = config_dict.get("architecture", "voxcpm").lower() | |
| print(f"Loading AudioVAE from {pretrained_path} (architecture: {architecture})...") | |
| if architecture == "voxcpm2": | |
| from vocalrender.modules.audiovae.audio_vae_v2 import AudioVAE as AudioVAEV2, AudioVAEConfig as AudioVAEConfigV2 | |
| audio_vae_config_dict = config_dict.get("audio_vae_config", None) | |
| if audio_vae_config_dict: | |
| audio_vae_config = AudioVAEConfigV2(**audio_vae_config_dict) | |
| self.audio_vae = AudioVAEV2(config=audio_vae_config) | |
| else: | |
| self.audio_vae = AudioVAEV2() | |
| else: | |
| from vocalrender.modules.audiovae.audio_vae import AudioVAE, AudioVAEConfig | |
| audio_vae_config_dict = config_dict.get("audio_vae_config", None) | |
| if audio_vae_config_dict: | |
| audio_vae_config = AudioVAEConfig(**audio_vae_config_dict) | |
| self.audio_vae = AudioVAE(config=audio_vae_config) | |
| else: | |
| self.audio_vae = AudioVAE() | |
| # Load VAE weights - support both safetensors and pytorch formats | |
| vae_safetensors_path = Path(pretrained_path) / "audiovae.safetensors" | |
| vae_pth_path = Path(pretrained_path) / "audiovae.pth" | |
| if vae_safetensors_path.exists(): | |
| try: | |
| from safetensors.torch import load_file | |
| vae_state_dict = load_file(str(vae_safetensors_path), device="cpu") | |
| print(f" Loaded AudioVAE from safetensors: {vae_safetensors_path}") | |
| except ImportError: | |
| vae_state_dict = torch.load(vae_pth_path, map_location="cpu", weights_only=True)["state_dict"] | |
| print(f" safetensors not available, loaded from: {vae_pth_path}") | |
| elif vae_pth_path.exists(): | |
| checkpoint = torch.load(vae_pth_path, map_location="cpu", weights_only=True) | |
| vae_state_dict = checkpoint.get("state_dict", checkpoint) | |
| print(f" Loaded AudioVAE from: {vae_pth_path}") | |
| else: | |
| raise FileNotFoundError(f"AudioVAE checkpoint not found at {pretrained_path}") | |
| self.audio_vae.load_state_dict(vae_state_dict) | |
| self.audio_vae.to(self.device).to(torch.float32) | |
| self.audio_vae.eval() | |
| self.patch_len = self.audio_vae.hop_length * self.patch_size | |
| # Load tokenizer only | |
| print(f"Loading tokenizer from {pretrained_path}...") | |
| from transformers import LlamaTokenizerFast | |
| self.tokenizer = LlamaTokenizerFast.from_pretrained(pretrained_path) | |
| # Special token IDs | |
| self.audio_start_id = 101 | |
| self.audio_end_id = 102 | |
| self.audio_prompt_start_id = 103 | |
| self.audio_prompt_end_id = 104 | |
| # Add SVS tokens to tokenizer | |
| self._setup_svs_tokens() | |
| print(f"Preprocessor initialized on {self.device}") | |
| print(f" Patch size: {self.patch_size}, Feat dim: {self.feat_dim}") | |
| def _setup_svs_tokens(self): | |
| """Add SVS tokens to tokenizer and build lookup maps.""" | |
| from vocalrender.model.svs_utils import get_svs_token_maps | |
| pitch_tokens, note_tokens, bpm_tokens, dur_units, special_tokens = get_svs_token_maps() | |
| new_tokens = pitch_tokens + note_tokens + bpm_tokens + special_tokens | |
| num_added = self.tokenizer.add_tokens(new_tokens) | |
| print(f"Added {num_added} SVS tokens to tokenizer") | |
| # Build lookup maps | |
| self.pitch_to_id = {} | |
| for pt in pitch_tokens: | |
| pid = self.tokenizer.convert_tokens_to_ids(pt) | |
| if pid != self.tokenizer.unk_token_id: | |
| try: | |
| val = int(pt.split('_')[1][:-1]) | |
| self.pitch_to_id[val] = pid | |
| except Exception: | |
| pass | |
| # Build note_to_id mapping: note token string -> token id | |
| # Also build note_str_to_idx for encoding in build_text_tensor | |
| self.note_to_id = {} # note_token_str -> tokenizer id | |
| self.note_idx_to_id = {} # note_idx -> tokenizer id | |
| self.note_str_to_idx = {} # note_token_str -> note_idx | |
| for idx, nt in enumerate(note_tokens): | |
| nid = self.tokenizer.convert_tokens_to_ids(nt) | |
| if nid != self.tokenizer.unk_token_id: | |
| self.note_to_id[nt] = nid | |
| self.note_idx_to_id[idx] = nid | |
| self.note_str_to_idx[nt] = idx | |
| self.bpm_to_id = {} | |
| for bt in bpm_tokens: | |
| bid = self.tokenizer.convert_tokens_to_ids(bt) | |
| if bid != self.tokenizer.unk_token_id: | |
| try: | |
| val = int(bt.split('_')[1][:-1]) | |
| self.bpm_to_id[val] = bid | |
| except Exception: | |
| pass | |
| self.svs_mask_token_id = None | |
| if "<SVS_MASK>" in special_tokens: | |
| mask_id = self.tokenizer.convert_tokens_to_ids("<SVS_MASK>") | |
| if mask_id != self.tokenizer.unk_token_id: | |
| self.svs_mask_token_id = mask_id | |
| def encode_audio(self, wav: torch.Tensor) -> torch.Tensor: | |
| """Encode audio waveform to VAE latent features.""" | |
| wav = wav.to(self.device) | |
| if wav.dim() == 1: | |
| wav = wav.unsqueeze(0).unsqueeze(0) # [1, 1, T] | |
| elif wav.dim() == 2: | |
| wav = wav.unsqueeze(1) # [B, 1, T] | |
| wav_len = wav.size(-1) | |
| if wav_len % self.patch_len != 0: | |
| padding_size = self.patch_len - wav_len % self.patch_len | |
| wav = torch.nn.functional.pad(wav, (0, padding_size)) | |
| with torch.no_grad(): | |
| z = self.audio_vae.encode(wav, self.audio_vae.in_sample_rate) # [B, D, T'] | |
| feat = z.transpose(1, 2) # [B, T', D] | |
| return feat.cpu() | |
| def extract_audio_feats(self, audio_waveform: torch.Tensor) -> Tuple[torch.Tensor, float]: | |
| """Extract and reshape audio features for training.""" | |
| audio_feats = self.encode_audio(audio_waveform) # [1, T', D] | |
| return self._reshape_audio_feats(audio_feats) | |
| def _reshape_audio_feats(self, audio_feats: torch.Tensor) -> Tuple[torch.Tensor, float]: | |
| """Reshape VAE latent features into patch format for training. | |
| Args: | |
| audio_feats: [1, T', D] or [T', D] raw VAE latent features | |
| Returns: | |
| (audio_feats, audio_duration): [T, P, D] reshaped features and duration in seconds | |
| """ | |
| if audio_feats.dim() == 2: | |
| audio_feats = audio_feats.unsqueeze(0) # [1, T', D] | |
| if audio_feats.size(1) % self.patch_size != 0: | |
| audio_feats_ = audio_feats.transpose(1, 2) | |
| padding = nn.functional.pad( | |
| audio_feats_, | |
| (0, self.patch_size - audio_feats.size(1) % self.patch_size) | |
| ) | |
| audio_feats = padding.transpose(1, 2) | |
| audio_duration = audio_feats.size(1) / 25.0 | |
| audio_feats = rearrange(audio_feats, "b (t p) c -> b t p c", p=self.patch_size) | |
| return audio_feats.squeeze(0), audio_duration # [T, P, D], float | |
| def encode_audio_batch(self, wavs: list) -> list: | |
| """Batch encode variable-length audio waveforms through AudioVAE. | |
| Args: | |
| wavs: List of 1D tensors [T_i] with different lengths | |
| Returns: | |
| List of (audio_feats, audio_duration) tuples, same format as extract_audio_feats | |
| """ | |
| if not wavs: | |
| return [] | |
| # 1. Compute per-sample padded lengths (aligned to patch_len) | |
| padded_lens = [] | |
| for wav in wavs: | |
| wav_len = wav.size(-1) | |
| if wav_len % self.patch_len != 0: | |
| padded_len = wav_len + (self.patch_len - wav_len % self.patch_len) | |
| else: | |
| padded_len = wav_len | |
| padded_lens.append(padded_len) | |
| max_len = max(padded_lens) | |
| # 2. Pad all waveforms to max_len and stack into batch [B, 1, max_len] | |
| batch = torch.zeros(len(wavs), 1, max_len) | |
| for i, wav in enumerate(wavs): | |
| batch[i, 0, :wav.size(-1)] = wav | |
| batch = batch.to(self.device) | |
| # 3. VAE encode entire batch | |
| with torch.no_grad(): | |
| z = self.audio_vae.encode(batch, self.audio_vae.in_sample_rate) # [B, D, T_max'] | |
| feats_all = z.transpose(1, 2).cpu() # [B, T_max', D] | |
| # 4. Extract per-sample features and reshape | |
| results = [] | |
| for i, padded_len in enumerate(padded_lens): | |
| feat_len = padded_len // self.audio_vae.hop_length | |
| sample_feats = feats_all[i, :feat_len, :] # [T'_i, D] | |
| results.append(self._reshape_audio_feats(sample_feats)) | |
| return results | |
| def build_svs_sequence( | |
| self, | |
| text_tensor: torch.Tensor, | |
| is_prompt: bool = False, | |
| has_score: bool = True, | |
| ) -> torch.Tensor: | |
| """ | |
| Build SVS token sequence from text_tensor. | |
| Args: | |
| text_tensor: [L, N] where columns are [text_ids..., pitch, note, bpm] | |
| is_prompt: Whether this is a prompt sample | |
| has_score: Whether this sample has full score annotations | |
| Returns: | |
| Token sequence tensor [S] containing all tokens + audio_start | |
| """ | |
| L = text_tensor.shape[0] | |
| num_cols = text_tensor.shape[1] | |
| # Layout: [text_ids..., pitch, note, bpm, word_idx] | |
| num_text_cols = num_cols - 4 | |
| if num_text_cols < 1: | |
| num_text_cols = 1 | |
| pitch_col = num_text_cols | |
| note_col = num_text_cols + 1 | |
| bpm_col = num_text_cols + 2 | |
| word_idx_col = num_text_cols + 3 | |
| full_seq_ids = [] | |
| # BPM token (global, from first row) | |
| bpm_val = 120 | |
| if bpm_col < num_cols: | |
| bpm_val = int(text_tensor[0, bpm_col].item()) | |
| if not has_score and self.svs_mask_token_id is not None: | |
| # Weak label: use <SVS_MASK> for BPM | |
| full_seq_ids.append(self.svs_mask_token_id) | |
| elif bpm_val in self.bpm_to_id: | |
| full_seq_ids.append(self.bpm_to_id[bpm_val]) | |
| elif 120 in self.bpm_to_id: | |
| full_seq_ids.append(self.bpm_to_id[120]) | |
| prev_word_idx = None | |
| for i in range(L): | |
| text_ids = [] | |
| for tc in range(num_text_cols): | |
| tid = int(text_tensor[i, tc].item()) | |
| if tid != 0: | |
| text_ids.append(tid) | |
| pitch_val = int(text_tensor[i, pitch_col].item()) | |
| note_idx = int(text_tensor[i, note_col].item()) | |
| cur_word_idx = int(text_tensor[i, word_idx_col].item()) | |
| # Melisma = same original word (word_idx) across consecutive rows | |
| is_melisma = prev_word_idx is not None and cur_word_idx == prev_word_idx | |
| pitch_id = self.pitch_to_id.get(pitch_val, None) | |
| note_id = self.note_idx_to_id.get(note_idx, None) | |
| # Weak label: replace pitch/note with <SVS_MASK> | |
| if not has_score and self.svs_mask_token_id is not None: | |
| pitch_id = self.svs_mask_token_id | |
| note_id = self.svs_mask_token_id | |
| # Aggregated layout: word_text_tokens + (pitch, note); melisma | |
| # rows reuse the previous word's text tokens. | |
| if not is_melisma: | |
| full_seq_ids.extend(text_ids) | |
| if pitch_id is not None: | |
| full_seq_ids.append(pitch_id) | |
| if note_id is not None: | |
| full_seq_ids.append(note_id) | |
| prev_word_idx = cur_word_idx | |
| # Add audio start token | |
| audio_start = self.audio_prompt_start_id if is_prompt else self.audio_start_id | |
| full_seq_ids.append(audio_start) | |
| return torch.tensor(full_seq_ids, dtype=torch.int32) | |
| def process_sample( | |
| self, | |
| text_tensor: torch.Tensor, | |
| audio_waveform: torch.Tensor = None, | |
| is_prompt: bool = False, | |
| precomputed_audio: Tuple[torch.Tensor, float] = None, | |
| has_score: bool = True, | |
| ) -> Dict[str, torch.Tensor]: | |
| """ | |
| Process a single sample into training-ready format. | |
| Args: | |
| text_tensor: [L, N] text tensor | |
| audio_waveform: Raw audio waveform (used if precomputed_audio is None) | |
| is_prompt: Whether this is a prompt sample | |
| precomputed_audio: Optional (audio_feats, audio_duration) tuple from | |
| encode_audio_batch, skips VAE encoding if provided | |
| has_score: Whether this sample has full score annotations | |
| Returns dict with: | |
| - packed_text_tokens: [T_total] int32 | |
| - audio_feats: [T_audio, P, D] float32 | |
| - text_mask: [T_total] int32 | |
| - audio_mask: [T_total] int32 | |
| - loss_mask: [T_total] int32 | |
| - labels: [T_total] int32 | |
| - audio_duration: float | |
| - text_token_count: int | |
| """ | |
| # 1. Build SVS token sequence | |
| svs_seq = self.build_svs_sequence(text_tensor, is_prompt=is_prompt, has_score=has_score) | |
| text_length = svs_seq.shape[0] | |
| # 2. Extract audio features (use precomputed if available) | |
| if precomputed_audio is not None: | |
| audio_feats, audio_duration = precomputed_audio | |
| else: | |
| audio_feats, audio_duration = self.extract_audio_feats(audio_waveform) | |
| audio_length = audio_feats.shape[0] | |
| # 3. Build packed text tokens | |
| text_pad = torch.zeros(audio_length, dtype=torch.int32) | |
| audio_end = self.audio_prompt_end_id if is_prompt else self.audio_end_id | |
| packed_text = torch.cat([ | |
| svs_seq, | |
| text_pad, | |
| torch.tensor([audio_end], dtype=torch.int32), | |
| ]) | |
| # 4. Pad audio features | |
| audio_pad_before = torch.zeros( | |
| (text_length, self.patch_size, audio_feats.size(-1)), | |
| dtype=torch.float32, | |
| ) | |
| audio_pad_after = torch.zeros( | |
| (1, self.patch_size, audio_feats.size(-1)), | |
| dtype=torch.float32, | |
| ) | |
| padded_audio_feats = torch.cat([audio_pad_before, audio_feats, audio_pad_after], dim=0) | |
| # 5. Build masks | |
| text_mask = torch.cat([ | |
| torch.ones(text_length, dtype=torch.int32), | |
| torch.zeros(audio_length, dtype=torch.int32), | |
| torch.ones(1, dtype=torch.int32), | |
| ]) | |
| audio_mask = torch.cat([ | |
| torch.zeros(text_length, dtype=torch.int32), | |
| torch.ones(audio_length, dtype=torch.int32), | |
| torch.zeros(1, dtype=torch.int32), | |
| ]) | |
| loss_mask = torch.cat([ | |
| torch.zeros(text_length, dtype=torch.int32), | |
| torch.zeros(audio_length, dtype=torch.int32) if is_prompt else torch.ones(audio_length, dtype=torch.int32), | |
| torch.zeros(1, dtype=torch.int32), | |
| ]) | |
| # 6. Build labels | |
| labels = torch.zeros(text_length + audio_length + 1, dtype=torch.int32) | |
| labels[-2] = 1 # Stop token position | |
| return { | |
| "packed_text_tokens": packed_text, | |
| "audio_feats": padded_audio_feats, | |
| "text_mask": text_mask, | |
| "audio_mask": audio_mask, | |
| "loss_mask": loss_mask, | |
| "labels": labels, | |
| "audio_duration": audio_duration, | |
| "text_token_count": text_tensor.shape[0], | |
| "total_length": packed_text.shape[0], | |
| } | |
| def create_lightweight_preprocessor(tokenizer): | |
| """Create an SVSPreprocessor with only token maps (no VAE/model). | |
| Useful for inference scripts that need to rebuild SVS prompts | |
| from metadata without loading the full preprocessing pipeline. | |
| """ | |
| p = SVSPreprocessor.__new__(SVSPreprocessor) | |
| p.tokenizer = tokenizer | |
| p.audio_start_id = 101 | |
| p.audio_end_id = 102 | |
| p.audio_prompt_start_id = 103 | |
| p.audio_prompt_end_id = 104 | |
| p._setup_svs_tokens() | |
| return p | |