File size: 17,393 Bytes
5ed07ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
"""
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