-
Notifications
You must be signed in to change notification settings - Fork 3.6k
Expand file tree
/
Copy pathduplex_ear_tts.py
More file actions
1769 lines (1469 loc) · 73.5 KB
/
Copy pathduplex_ear_tts.py
File metadata and controls
1769 lines (1469 loc) · 73.5 KB
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
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import copy
import os
import time
from collections import Counter
from contextlib import contextmanager
import librosa
import torch
import torch.nn as nn
import torch.nn.functional as F
from lightning import LightningModule
from omegaconf import DictConfig
from peft import PeftModel
from torch.distributed.fsdp import fully_shard
from torch.distributed.tensor import Replicate, Shard
from torch.distributed.tensor.parallel import (
ColwiseParallel,
PrepareModuleInput,
RowwiseParallel,
SequenceParallel,
loss_parallel,
parallelize_module,
)
from nemo.collections.audio.parts.utils.transforms import resample
from nemo.collections.common.tokenizers import AutoTokenizer
from nemo.collections.speechlm2.data.utils import get_pad_id
from nemo.collections.speechlm2.modules.ear_tts_model import RVQEARTTSModel
from nemo.collections.speechlm2.modules.ear_tts_vae_codec import RVQVAEModel
from nemo.collections.speechlm2.parts.hf_hub import HFHubMixin
from nemo.collections.speechlm2.parts.metrics.asr_bleu import ASRBLEU
from nemo.collections.speechlm2.parts.metrics.asr_cer_wer import Intelligibility
from nemo.collections.speechlm2.parts.metrics.results_logger import ResultsLogger
from nemo.collections.speechlm2.parts.metrics.secs import SECS
from nemo.collections.speechlm2.parts.optim_setup import (
configure_optimizers,
configure_optimizers_exclude_norm_from_wd,
is_frozen,
)
from nemo.collections.speechlm2.parts.precision import fp32_precision
from nemo.collections.speechlm2.parts.pretrained import (
load_checkpoint,
load_pretrained_hf,
set_model_dict_for_partial_init,
)
from nemo.utils import logging
class DuplexEARTTS(LightningModule, HFHubMixin):
def __init__(self, cfg: dict) -> None:
assert isinstance(cfg, dict), (
"You must pass the config to DuplexEARTTS as a Python dict to support hyperparameter serialization "
f"in PTL checkpoints (we got: '{type(cfg)=}')."
)
super().__init__()
self.save_hyperparameters()
# convert dict to config
cfg = DictConfig(cfg)
self.trainer_config = cfg.get("trainer", None)
self.data_cfg = cfg.data
self.cfg = cfg.model
self.target_sample_rate = cfg.data.target_sample_rate
self.source_sample_rate = cfg.data.source_sample_rate
self.normalize_text = cfg.data.get("normalize_text", False)
self.validation_save_path = os.path.join(cfg.exp_manager.explicit_log_dir, "validation_logs")
# move back text channel by x, in inference it advance the text channel prediction by x frames
self.advance_text_channel_by = self.cfg.get("advance_text_channel_by", None)
# Load ForCausalLM
if self.cfg.tts_config.context_hidden_size is not None:
self.language_model = self._load_language_model(self.cfg)
self.embed_tokens = self._load_embed_tokens(self.cfg)
# delete llm because we use it only to get the embbeding tokens
del self.language_model
# get codec run precision
self.audio_codec_run_dtype = getattr(torch, self.cfg.get("audio_codec_run_dtype", "float32"), torch.float32)
# Load tokenizer
tokenizer_src = self.cfg.get("tokenizer_path", None) or self.cfg.pretrained_lm_name
self.tokenizer = AutoTokenizer(
tokenizer_src,
use_fast=True,
trust_remote_code=self.cfg.get("trust_remote_code", False),
bos_token=self.cfg.get("bos_token", None),
eos_token=self.cfg.get("eos_token", None),
pad_token=self.cfg.get("pad_token", None),
) # Note that we are using fast tokenizer
# Instantiate TTS model
self.tts_model = RVQEARTTSModel(DictConfig(self.cfg.tts_config), tokenizer=self.tokenizer)
# Load and initialize audio codec, and bind RVQ embeddings to the TTS model
setup_audio_codec(self)
self._codebook_size = self.tts_model.config.codebook_size
# compute samples per frame
self.source_samples_per_frame = int(self.source_sample_rate * cfg.data.frame_length)
# get codec silence tokens
codec_silence_tokens = self.get_codec_silence_frame()
self.register_buffer("codec_silence_tokens", codec_silence_tokens)
# cached for quicker audio decoding
self.register_buffer(
"_control_codes",
torch.tensor([self.speech_bos_id, self.speech_eos_id, self.speech_pad_id], device=self.device),
)
self._use_fsdp = False
self._use_tp = False
self.audio_prompt_latents = nn.ParameterDict()
def get_codec_silence_frame_last_one(self):
audio = torch.zeros(1, 10 * self.target_sample_rate).float().to(self.device)
audio_len = torch.tensor([audio.size(-1)]).long()
audio, audio_len = self.pad_audio_to_factor(audio, audio_len, self.target_samples_per_frame)
with ensures_target_precision(self.audio_codec_run_dtype), torch.no_grad():
sil_codes, sil_codes_lens = self.audio_codec.encode(
audio.unsqueeze(1).to(self.audio_codec_run_dtype), audio_len
)
return sil_codes[0, -1]
def get_codec_silence_frame(self):
# Generate long zero waveform (silence)
audio = torch.zeros(1, 10 * self.target_sample_rate).float().to(self.device)
audio_len = torch.tensor([audio.size(-1)]).long()
audio, audio_len = self.pad_audio_to_factor(audio, audio_len, self.target_samples_per_frame)
with ensures_target_precision(self.audio_codec_run_dtype), torch.no_grad():
sil_codes, _ = self.audio_codec.encode(
audio.unsqueeze(1).to(self.audio_codec_run_dtype), audio_len
) # [1, T, C]
sil_codes = sil_codes[0] # [T, C]
# Convert each frame (C tokens) into a tuple
combos = [tuple(row.tolist()) for row in sil_codes]
# Count frequencies
counter = Counter(combos)
# Pick the most common combination
most_common_combo, freq = counter.most_common(1)[0]
# Return as tensor [C]
return torch.tensor(most_common_combo, device=self.device, dtype=torch.long)
def _load_embed_tokens(self, cfg) -> nn.Embedding:
"""Load token embedding layer for RVQ-EAR-TTS."""
if self.language_model:
assert callable(self.language_model.get_input_embeddings)
embed_tokens: nn.Embedding = self.language_model.get_input_embeddings()
else:
embed_tokens_state_dict = torch.load(
cfg.pretrained_lm_embedding_path, map_location="cpu", weights_only=True
)
# Create token embedding layer
vocab_size, hidden_size = embed_tokens_state_dict["weight"].size()
embed_tokens = nn.Embedding(vocab_size, hidden_size, dtype=torch.bfloat16)
embed_tokens.load_state_dict(embed_tokens_state_dict)
return embed_tokens
def _load_language_model(self, cfg):
"""Load language model for RVQ-EAR-TTS."""
if cfg.pretrained_lm_name:
language_model = load_pretrained_hf(
self.cfg.pretrained_lm_name,
pretrained_weights=True,
trust_remote_code=self.cfg.get("trust_remote_code", False),
).eval()
else:
language_model = None
return language_model
def restore_from_pretrained_checkpoint(self, checkpoint_path):
"""
Loads model weights a pretrained checkpoint file, supporting partial loading from safetensor and PyTorch formats.
Args:
checkpoint_path (str): Path to checkpoint file.
Returns:
None. The model is updated in-place.
"""
if checkpoint_path is not None:
checkpoint_state = load_checkpoint(checkpoint_path)
checkpoint_state = set_model_dict_for_partial_init(checkpoint_state, self.state_dict())
if self.cfg.get("rescale_pretrained_weights", None):
checkpoint_state = rescale_state_dict(
checkpoint_state, first_n_layers=self.cfg.get("rescale_first_n_layers", None)
)
self.load_state_dict(checkpoint_state, strict=True)
logging.info(f"Model restored from the checkpoint: {checkpoint_path} !")
@property
def device(self):
return next(self.parameters()).device
@property
def speech_vocab_size(self):
"""Return the size of the audio codec codebook including extra speech BOS and EOS tokens."""
if self.use_local_transformer and self.local_transformer_type == "nar": # add extra token for mask
return self._codebook_size + 4
return self._codebook_size + 3
@property
def speech_bos_id(self) -> int:
"""Indicates start of utterance generation (not start of inference!)."""
if self.cfg.get("custom_speech_bos_id", None):
return self.cfg.get("custom_speech_bos_id")
return self._codebook_size + 2
@property
def speech_eos_id(self) -> int:
"""Indicates end of utterance generation."""
if self.cfg.get("custom_speech_eos_id", None):
return self.cfg.get("custom_speech_eos_id")
return self._codebook_size + 1
@property
def speech_pad_id(self) -> int:
"""Indicates start of inference (the very first frame)."""
if self.cfg.get("custom_speech_pad_id", None):
return self.cfg.get("custom_speech_pad_id")
return self._codebook_size
@property
def text_vocab_size(self):
"""Return the size of the text tokenizer."""
return self.tokenizer.vocab_size
@property
def text_bos_id(self) -> int:
return self.tokenizer.bos_id
@property
def text_eos_id(self) -> int:
return self.tokenizer.eos_id
@property
def text_pad_id(self) -> int:
"""
Text pad ID is used as a 'blank' for frames when the model is not speaking
and for frames where the model is speaking but has already predicted the
entire text channel's content.
Example:
flow: |---user---||-------assistant--------||-user-|
text channel: 0000000000 1xxxxxxx0000000000000002 000000
Where 0 indicates PAD ID, 1 indicates BOS ID, 2 indacates EOS ID,
and x indicates tokens corresponding to actual text
"""
return get_pad_id(self.tokenizer)
def pad_audio_to_factor(self, audio, audio_len, samples_per_frame, downsampling_factor: int = 1):
"""
Zero pad the end of the audio so that we do not have a partial end frame.
The output will be zero-padded to have an integer number of frames of
length `samples_per_frame * downsampling_factor`.
Args:
audio: input time-domain signal (B, T)
audio_len: valid length for each example in the batch (B,)
samples_per_frame: number of samples per frame
downsampling_factor: how much each frame is downsampled in later processing
Returns:
padded_audio: Padded time-domain signal (B, T')
padded_len: Adjusted valid lengths (B,)
"""
with fp32_precision():
total_factor = samples_per_frame * downsampling_factor
padded_len = total_factor * torch.ceil(audio_len / total_factor).int()
max_len = padded_len.max().int().item()
num_padding = max_len - audio.shape[1]
padded_audio = F.pad(audio, (0, num_padding))
return padded_audio, padded_len
def prepare_inputs(self, batch: dict):
"""
Prepare inputs, extracting audio tokens and padding if needed.
"""
# check if audios has the same batch size
assert batch["source_audio"].size(0) == batch["target_audio"].size(0)
assert batch["audio_prompt"].size(0) == batch["target_audio"].size(0)
target_audio = batch["target_audio"]
target_audio_lens = batch["target_audio_lens"]
target_text_tokens = batch["target_text_tokens"]
non_prompt_mask = batch["non_prompt_mask"]
aligned_attention_mask = batch["aligned_attention_mask"]
aligned_position_ids = batch["aligned_position_ids"]
if self.training and (self.cfg.get("empty_turn_probability", 0.0) > 0):
# Randomly decide whether this batch gets emptied
if torch.rand(1).item() < self.cfg.empty_turn_probability:
# Zero out audio
target_audio = torch.zeros_like(target_audio)
# Create mask for tokens we want to drop
# Keep BOS and EOS, drop the rest.
keep_mask = (target_text_tokens == self.text_bos_id) | (target_text_tokens == self.text_eos_id)
full_dropout_mask = ~keep_mask # True = positions to replace with PAD
# Replace all non-BOS/EOS with PAD
target_text_tokens = torch.where(
full_dropout_mask, torch.full_like(target_text_tokens, self.text_pad_id), target_text_tokens
)
# extract target audio codes
target_audio, target_audio_lens = self.pad_audio_to_factor(
target_audio, target_audio_lens, self.target_samples_per_frame, 1
)
with ensures_target_precision(self.audio_codec_run_dtype), torch.no_grad():
target_codes, target_codes_lens = self.audio_codec.encode(
target_audio.unsqueeze(1).to(self.audio_codec_run_dtype), target_audio_lens
)
with fp32_precision():
target_len = target_codes.shape[1]
# Pad or truncate sequence variables
def pad_or_truncate(x, pad_value=0):
if x.dim() == 2: # [B, T]
L = x.shape[1]
if L < target_len:
return F.pad(x, (0, target_len - L), value=pad_value)
else:
return x[:, :target_len]
return x # leave others for now
target_text_tokens = pad_or_truncate(target_text_tokens, pad_value=self.text_pad_id)
non_prompt_mask = pad_or_truncate(non_prompt_mask, pad_value=0)
aligned_position_ids = pad_or_truncate(aligned_position_ids, pad_value=0)
# Correct attention mask padding/truncation
B, H, L1, L2 = aligned_attention_mask.shape
new_len = target_len
if L1 < new_len or L2 < new_len:
pad_rows = new_len - L1
pad_cols = new_len - L2
aligned_attention_mask = F.pad(aligned_attention_mask, (0, pad_cols, 0, pad_rows))
elif L1 > new_len or L2 > new_len:
aligned_attention_mask = aligned_attention_mask[:, :, :new_len, :new_len]
# set the pad token for the first BOS frame
target_codes_aligned = target_codes.clone()
target_codes_aligned[:, 0] = self.speech_pad_id
# set special token in the last audio prompt (it will works as a BOS token)
pos = non_prompt_mask.float().argmax(dim=1) # shape: [B]
row_idx = torch.arange(B, device=self.device)
# set the extra self.speech_pad_id at first 1 position in non_prompt_mask
target_codes_aligned[row_idx, pos] = self.speech_pad_id
# EOS dropout to make the model more robust
if self.training and self.cfg.get("text_eos_dropout_prob", 0.0) > 0:
# Mask EOS positions
eos_mask = target_text_tokens == self.text_eos_id
# Random dropout only on EOS positions
dropout_mask = (
torch.rand(eos_mask.sum(), device=target_text_tokens.device) < self.cfg.text_eos_dropout_prob
)
# Scatter dropout decisions into [B, T]
full_dropout_mask = torch.zeros_like(target_text_tokens, dtype=torch.bool)
full_dropout_mask[eos_mask] = dropout_mask
# Replace dropped EOS with PAD
target_text_tokens = torch.where(
full_dropout_mask, torch.full_like(target_text_tokens, self.text_pad_id), target_text_tokens
)
if self.training and self.cfg.get("text_eos_duplicate_prob", 0.0) > 0:
p = self.cfg.text_eos_duplicate_prob
# [B, T] mask of EOS positions
eos_mask = target_text_tokens == self.text_eos_id
# Flatten EOS positions: tensor of shape [N, 2] where each row = (batch_idx, time_idx)
eos_positions = eos_mask.nonzero(as_tuple=False) # [N, 2]
if eos_positions.numel() > 0:
N = eos_positions.shape[0]
# One random decision per EOS occurrence
duplicate_decision = torch.rand(N, device=target_text_tokens.device) < p # [N]
# Filter only EOS tokens that will be duplicated and are not at position t=0
valid = (eos_positions[:, 1] > 0) & duplicate_decision # [N]
if valid.any():
# Select only valid EOS positions
valid_positions = eos_positions[valid] # [M, 2]
# Indices for the token BEFORE the EOS (t-1)
b_idx = valid_positions[:, 0]
t_idx = valid_positions[:, 1] - 1
# Replace token before EOS with an EOS
target_text_tokens[b_idx, t_idx] = self.text_eos_id
# BOS dropout to make the model more robust
if self.training and self.cfg.get("text_bos_dropout_prob", 0.0) > 0:
# Mask BOS positions
bos_mask = target_text_tokens == self.text_bos_id
# Random dropout only on BOS positions
dropout_mask = (
torch.rand(bos_mask.sum(), device=target_text_tokens.device) < self.cfg.text_bos_dropout_prob
)
# Scatter dropout decisions into [B, T]
full_dropout_mask = torch.zeros_like(target_text_tokens, dtype=torch.bool)
full_dropout_mask[bos_mask] = dropout_mask
# Replace dropped BOS with PAD
target_text_tokens = torch.where(
full_dropout_mask,
torch.full_like(target_text_tokens, self.text_pad_id),
target_text_tokens,
)
# BOS dropout to make the model more robust
if self.training and self.cfg.get("text_bos_dropout_prob", 0.0) > 0:
prob = self.cfg.text_bos_dropout_prob # e.g., 0.5
# Identify all BOS positions [B, T]
bos_mask = target_text_tokens == self.text_bos_id
# Get indices of sequences that actually have a BOS token
# We need to know *where* the BOS tokens are to drop them.
# tensor of coordinates: [[batch_idx, seq_idx], ...]
bos_indices = torch.nonzero(bos_mask)
num_bos = bos_indices.shape[0]
if num_bos > 0:
# Create a random dropout decision for each BOS instance
drop_decisions = torch.rand(num_bos, device=target_text_tokens.device) < prob
# Ensure at least one is dropped
if drop_decisions.sum() == 0:
# Pick one random index from the available BOS locations to drop
force_idx = torch.randint(0, num_bos, (1,), device=target_text_tokens.device)
drop_decisions[force_idx] = True
# 5. Apply the dropout
# We need to map the decisions back to the full tensor
# Create a mask of the same shape as target_text_tokens
full_dropout_mask = torch.zeros_like(target_text_tokens, dtype=torch.bool)
# Set True only at the specific (batch, seq) coordinates we chose to drop
# bos_indices[:, 0] are batch indices, bos_indices[:, 1] are seq indices
full_dropout_mask[bos_indices[:, 0], bos_indices[:, 1]] = drop_decisions
# 6. Replace dropped BOS with PAD
target_text_tokens = torch.where(
full_dropout_mask,
torch.full_like(target_text_tokens, self.text_pad_id),
target_text_tokens,
)
# shift text tokens
subword_ids = F.pad(target_text_tokens[:, 1:], [0, 1])
# note that we are using a text mask where we are ignoring the desc + audio prompt but we are keeping 1 until the audio ends to support duplex
subword_mask = F.pad(non_prompt_mask[:, 1:], [0, 1])
# detach embedding as in eartts
if self.cfg.tts_config.context_hidden_size is not None:
context_hidden_state = self.embed_tokens(target_text_tokens).detach()
else:
context_hidden_state = None
if self._use_tp:
tp_world_size = self.device_mesh["tensor_parallel"].size()
if (remainder := (target_text_tokens.shape[1] - 1) % tp_world_size) != 0:
target_text_tokens = target_text_tokens[:, :-remainder]
target_codes_aligned = target_codes_aligned[:, :-remainder]
target_codes_aligned = target_codes_aligned[:, :-remainder]
subword_ids = subword_ids[:, :-remainder]
subword_mask = subword_mask[:, :-remainder]
return {
"code": target_codes_aligned,
"audio_mask": non_prompt_mask, # set audio_mask as non_prompt_mask to avoid the audio prompt in loss computation
"attention_mask": aligned_attention_mask,
"position_ids": aligned_position_ids,
"subword_ids": subword_ids,
"subword_mask": subword_mask,
"context_hidden_state": context_hidden_state,
"output_lens": target_codes_lens,
"non_prompt_mask": non_prompt_mask,
"target_text_tokens": target_text_tokens,
}
def training_step(self, batch: dict, batch_idx: int):
for m in (self.tts_model,):
if is_frozen(m):
m.eval()
inputs = self.prepare_inputs(batch)
tts_output = self.tts_model(
code=inputs["code"],
audio_mask=inputs["audio_mask"],
attention_mask=inputs["attention_mask"],
position_ids=inputs["position_ids"],
context_hidden_state=inputs["context_hidden_state"],
subword_ids=inputs["subword_ids"],
subword_mask=inputs["subword_mask"],
non_prompt_mask=inputs["non_prompt_mask"],
dataset_type=batch.get("dataset_type", None),
)
loss_dict = {"lm_loss": tts_output.lm_loss, "c_loss": tts_output.c_loss, "k_loss": tts_output.k_loss}
loss = sum(loss_dict.values())
num_frames = inputs["output_lens"].sum()
B, T = inputs["code"].shape[:2]
ans = {
"loss": loss,
"learning_rate": (
torch.as_tensor(self.trainer.optimizers[0].param_groups[0]['lr'] if self._trainer is not None else 0)
),
"batch_size": B,
"sequence_length": T,
"num_frames": num_frames.to(torch.float32), # avoid warning
"padding_ratio": num_frames / (B * T),
**loss_dict,
}
self.log_dict(ans, on_step=True)
return ans
def ensures_codec_target_dtype(self) -> None:
"""
Ensures the audio codec is instantiated with the target dtype.
This method checks whether `self.audio_codec` exists and whether its
parameters match `self.audio_codec_run_dtype`. If the codec is missing
or is running with the wrong dtype (e.g., due to PTL auto-downcasting),
the codec is reloaded by calling `setup_audio_codec()`.
Intended to be called at runtime boundaries such as:
- `on_train_epoch_start`
- `on_validation_epoch_start`
"""
if hasattr(self, "audio_codec") and next(self.audio_codec.parameters()).dtype == self.audio_codec_run_dtype:
self.audio_codec.eval()
return # already correct precision → no-op
setup_audio_codec(self)
def on_train_epoch_start(self) -> None:
self.ensures_codec_target_dtype() # potentially reloads the audio codec to make sure it's in target codec precision
def on_train_epoch_end(self) -> None:
# log model stats to debug gradient weights issues
self.log_model_stats()
def log_model_stats(self):
total_w_sq = 0.0
total_w_params = 0
max_abs_w = 0.0
sum_w = 0.0
total_g_sq = 0.0
total_g_params = 0
for p in self.parameters():
if not p.requires_grad:
continue
# ----- weights -----
w = p.detach().cpu().float() # safe offline copy
total_w_sq += (w * w).sum().item()
total_w_params += w.numel()
max_abs_w = max(max_abs_w, w.abs().max().item())
sum_w += w.sum().item()
# ----- grads (optional, disabled for speed) -----
if p.grad is not None:
g = p.grad.detach().cpu().float()
total_g_sq += (g * g).sum().item()
total_g_params += g.numel()
# L2 norms
weight_l2 = (total_w_sq**0.5) if total_w_sq > 0 else 0.0
# RMS (global)
weight_rms = ((total_w_sq / total_w_params) ** 0.5) if total_w_params > 0 else 0.0
# Mean
weight_mean = sum_w / total_w_params if total_w_params > 0 else 0.0
# direct float logging avoids device sync penalty
self.log("weights/L2", weight_l2, on_epoch=True, sync_dist=True)
self.log("weights/RMS", weight_rms, on_epoch=True, sync_dist=True)
self.log("weights/max_abs", max_abs_w, on_epoch=True, sync_dist=True)
self.log("weights/mean", weight_mean, on_epoch=True, sync_dist=True)
def on_validation_epoch_start(self) -> None:
if torch.distributed.is_initialized():
self.trainer.strategy.model.require_backward_grad_sync = False
self.ensures_codec_target_dtype() # potentially reloads the audio codec to make sure it's in target codec precision
self.results_logger = ResultsLogger(self.validation_save_path).reset()
self.asr_bleu = ASRBLEU(self.cfg.scoring_asr).reset()
self.intelligibility = Intelligibility(self.cfg.scoring_asr, reuse_asr_hyps=True).reset()
self.secs = SECS(self.cfg.get("scoring_se", "titanet_large")).reset()
def on_validation_epoch_end(self, prefix="val") -> None:
asr_bleu = self.asr_bleu.compute()
for k, m in asr_bleu.items():
self.log(f"{prefix}_{k}", m.to(self.device), on_epoch=True, sync_dist=True)
cer_wer = self.intelligibility.compute()
for k, m in cer_wer.items():
self.log(f"{prefix}_{k}", m.to(self.device), on_epoch=True, sync_dist=True)
secs = self.secs.compute()
for k, m in secs.items():
self.log(f"{prefix}_{k}", m.to(self.device), on_epoch=True, sync_dist=True)
self.results_logger.compute_and_save()
if torch.distributed.is_initialized():
self.trainer.strategy.model.require_backward_grad_sync = True
def get_teacher_force_inference_audio(self, batch, guidance_enabled=True):
inputs = self.prepare_inputs(batch)
tts_output = self.tts_model(
code=inputs["code"],
audio_mask=inputs["audio_mask"],
attention_mask=inputs["attention_mask"],
position_ids=inputs["position_ids"],
context_hidden_state=inputs["context_hidden_state"],
subword_ids=inputs["subword_ids"],
subword_mask=inputs["subword_mask"],
non_prompt_mask=inputs["non_prompt_mask"],
generation_config=self._get_generation_config(guidance_enabled=guidance_enabled),
teacher_forcing_inference=True,
guidance_enabled=guidance_enabled,
)
tf_audio_codes_pred = tts_output["codes"].squeeze(2)
# decode audio
tf_audio_codes_pred = replace_control_speech_codes(
tf_audio_codes_pred, self._control_codes, self.codec_silence_tokens
)
with ensures_target_precision(self.audio_codec_run_dtype), torch.no_grad():
audio_pred, audio_len = self.audio_codec.decode(tf_audio_codes_pred, inputs["output_lens"])
return audio_pred.squeeze(1), audio_len
def _get_generation_config(self, guidance_enabled: bool = False):
"""Get default generation config for EAR-TTS."""
return {
"num_iter": 8,
"guidance_scale": self.cfg.get("inference_guidance_scale", 0.5) if guidance_enabled else None,
"top_p_or_k": self.cfg.get("inference_top_p_or_k", 0.8),
"noise_scale": self.cfg.get("inference_noise_scale", 0.8),
"eos_threshold": -3.0,
}
@torch.inference_mode()
def run_evaluation_one_batch(self, name, dataset_batch, use_dataloader_init=False):
"""
Runs evaluation and scoring for a single data batch, logging metrics and updating result buffers.
Args:
name (str): Name/id for the batch (for logging).
dataset_batch (dict): Batch of data inputs, supports batched text/audio/etc.
use_dataloader_init (bool, optional): If True, use dataloader initialization for prompts.
Returns:
None. Outputs are logged and stored in result buffers.
"""
results = {}
inputs = self.prepare_inputs(dataset_batch)
results["audio_tf"], results["audio_tf_len"] = self.get_teacher_force_inference_audio(dataset_batch)
if use_dataloader_init:
# cut it on prompt
init_inputs = {
"code": inputs["code"],
"audio_mask": inputs["audio_mask"],
"non_prompt_mask": inputs["non_prompt_mask"],
"context_hidden_state": inputs["context_hidden_state"],
"subword_ids": inputs["subword_ids"],
"subword_mask": inputs["subword_mask"],
}
# cut init_inputs to consider only the prompt
for key in init_inputs:
if init_inputs[key] is not None:
init_inputs[key] = torch.stack(
[init_inputs[key][i, :plen] for i, plen in enumerate(dataset_batch["prompt_lens"])]
)
else:
sp = dataset_batch.get("system_prompts_raw")
system_prompt = sp[0] if sp else None
# set init inputs and get it
self.set_init_inputs(
speaker_audio=dataset_batch["audio_prompt"],
speaker_audio_lens=dataset_batch["audio_prompt_lens"],
system_prompt=system_prompt, # use the first position of the batch as system prompt
)
init_inputs = self.get_init_inputs(B=inputs["subword_ids"].size(0))
# remove the prompt from the target_text_tokens to emulate S2S connected inference
next_subword_ids = torch.stack(
[
inputs["subword_ids"][i, plen:] # slice each element
for i, plen in enumerate(dataset_batch["prompt_lens"])
]
)
results["audio"], results["audio_len"] = self.offline_inference(
next_subword_ids=next_subword_ids,
task=dataset_batch["task"][0],
init_inputs=init_inputs,
)
# remove prompt padding from the user audio as autoregressive inference does not return the prompt
dataset_batch["source_audio"] = dataset_batch["source_audio"][
:, -int(next_subword_ids.size(-1) * self.source_samples_per_frame) :
]
# clean prompt from the audio
results["audio_tf"] = results["audio_tf"][:, -int(next_subword_ids.size(-1) * self.target_samples_per_frame) :]
# remove prompt from target audio
target_audio_no_prompt = dataset_batch["target_audio"][
:, -int(next_subword_ids.size(-1) * self.target_samples_per_frame) :
]
target_audio_no_prompt_lens = dataset_batch["target_audio_lens"] - (
torch.tensor(
dataset_batch["prompt_lens"],
dtype=torch.long,
device=dataset_batch["target_audio_lens"].device,
)
* self.target_samples_per_frame
)
with fp32_precision(): # resample is fragile to bfloat16 default dtype
metric_audio_pred = results["audio"]
metric_audio_pred_lens = results["audio_len"]
# resample audio to the asr sampling rate
metric_audio_pred = resample(metric_audio_pred, self.target_sample_rate, 16000)
metric_audio_pred_lens = (metric_audio_pred_lens / self.target_sample_rate * 16000).to(torch.long)
# reshape target audio without prompt
target_audio_no_prompt_16khz = resample(target_audio_no_prompt, self.target_sample_rate, 16000)
target_audio_no_prompt_lens_16khz = (target_audio_no_prompt_lens / self.target_sample_rate * 16000).to(
torch.long
)
if self.cfg.get("use_GT_transcriptions_for_metrics", True):
# use target audio transcription for metrics
target_asr_texts = self.asr_bleu.asr.transcribe(
[
audio[:alen]
for audio, alen in zip(target_audio_no_prompt_16khz, target_audio_no_prompt_lens_16khz)
],
batch_size=target_audio_no_prompt_16khz.shape[0],
verbose=False,
)
metric_text = [asr_hyp.text for asr_hyp in target_asr_texts]
else:
metric_text = dataset_batch["target_texts"]
asr_hyps = self.asr_bleu.update(
name=name,
refs=metric_text,
pred_audio=metric_audio_pred,
pred_audio_lens=metric_audio_pred_lens,
)
self.intelligibility.update(
name=name,
refs=metric_text,
pred_audio=metric_audio_pred,
pred_audio_lens=metric_audio_pred_lens,
asr_hyps=asr_hyps,
)
# add ground truth intelligibility metrics
self.intelligibility.update(
name=name + "_gt",
refs=dataset_batch["target_texts"],
pred_audio=target_audio_no_prompt_16khz,
pred_audio_lens=target_audio_no_prompt_lens_16khz,
asr_hyps=(
metric_text if self.cfg.get("use_GT_transcriptions_for_metrics", True) else None
), # reuse GT transcription
)
self.secs.update(
name=name,
target_audio=resample(dataset_batch["target_audio"], self.target_sample_rate, 16000),
target_audio_lens=(dataset_batch["target_audio_lens"] / self.target_sample_rate * 16000).to(
torch.long
),
pred_audio=resample(results["audio"], self.target_sample_rate, 16000),
pred_audio_lens=(results["audio_len"] / self.target_sample_rate * 16000).to(torch.long),
)
eou_labels = generate_multiturn_speaking_mask(
next_subword_ids, bos_token_id=self.text_bos_id, eos_token_id=self.text_eos_id
)
self.results_logger.update(
name=name,
refs=dataset_batch["target_texts"],
hyps=metric_text,
asr_hyps=asr_hyps,
samples_id=dataset_batch['sample_id'],
pred_audio=results["audio"].float(),
pred_audio_tf=results["audio_tf"].float(),
pre_audio_trimmed=None,
reference_audio=dataset_batch["audio_prompt"].float(),
target_audio=target_audio_no_prompt.float(),
pred_audio_sr=self.target_sample_rate,
user_audio=dataset_batch["source_audio"].float(),
user_audio_sr=self.source_sample_rate,
eou_pred=eou_labels,
fps=self.target_fps,
results=results if self.cfg.get("dump_tokens_text", False) else None,
tokenizer=self.tokenizer,
)
@torch.inference_mode()
def validation_step(self, batch: dict, batch_idx: int):
for name, dataset_batch in batch.items():
if dataset_batch is None:
continue # some dataset is exhausted
B = len(dataset_batch['sample_id'])
# run inference for a custom speaker reference
if self.cfg.get("inference_speaker_reference", None):
new_dataset_batch = copy.deepcopy(dataset_batch)
speaker_audio, sr = load_audio_librosa(self.cfg.inference_speaker_reference)
speaker_audio = resample(speaker_audio, sr, self.target_sample_rate)
speaker_audio = speaker_audio.repeat(B, 1).to(self.device)
# lengths -> [B]
speaker_audio_lens = torch.tensor([speaker_audio.size(1)], device=self.device).long().repeat(B)
new_dataset_batch["audio_prompt"] = speaker_audio
new_dataset_batch["audio_prompt_lens"] = speaker_audio_lens
self.run_evaluation_one_batch(name, new_dataset_batch, use_dataloader_init=False)
# run inference using dataloader speaker references
else:
self.run_evaluation_one_batch(name, dataset_batch, use_dataloader_init=False)
def on_test_epoch_start(self) -> None:
return self.on_validation_epoch_start()
def on_test_epoch_end(self) -> None:
return self.on_validation_epoch_end(prefix="test")
def test_step(self, *args, **kwargs):
return self.validation_step(*args, **kwargs)
def set_audio_prompt_lantent(
self,
speaker_audio,
speaker_audio_lens,
system_prompt=None,
batch_size=1,
name="default_speaker",
):
"""
Compute and cache an audio prompt latent representation for a given speaker.
The latent is stored as a registered buffer so it is saved inside checkpoints.
"""
# Prepare inputs
self.set_init_inputs(
speaker_audio=speaker_audio,
speaker_audio_lens=speaker_audio_lens,
system_prompt=system_prompt,
)
init_inputs = self.get_init_inputs(B=batch_size)
init_inputs.update(
{
"use_cache": True,
"past_key_values": None,
"guidance_enabled": False,
}
)
# Forward pass
audio_prompt_lantent = self.tts_model(**init_inputs).audio_prompt_lantent
# Detach
cached = audio_prompt_lantent.detach().clone()
# Store as non-trainable parameter
module = nn.Parameter(cached, requires_grad=False)
self.audio_prompt_latents[name] = module
return cached
def get_audio_prompt_lantent(self, name):
"""
Retrieve a cached audio prompt latent stored as a buffer.
"""
if name not in self.audio_prompt_latents:
raise KeyError(f"Unknown audio prompt latent '{name}'. " "Call set_audio_prompt_lantent(...) first.")
return self.audio_prompt_latents[name].to(self.device)
def set_init_inputs(self, speaker_audio=None, speaker_audio_lens=None, system_prompt=None, speaker_name=None):
"""
Registers and prepares initial input buffers for text/audio prompt and context, to warm up AR inference.
Args:
speaker_audio (torch.Tensor): Batch of prompt audio, (B, T).
speaker_audio_lens (torch.Tensor): Lengths for each sample in speaker_audio, (B,).
system_prompt (str, optional): System prompt for context.
speaker_name (str, optional): Speaker name.
Returns:
dict: Dictionary of input tensors to be passed to inference, with registered buffers.
"""
# compute prompt audio size and slice it
with fp32_precision():
# compute the exact number of samples for the prompt duration
prompt_audio_size = int(
((self.data_cfg.audio_prompt_duration * self.target_sample_rate) // self.target_samples_per_frame)
* self.target_samples_per_frame
)
# if a speaker name exists, use the cached latent
if speaker_name is not None:
speaker_audio = torch.zeros(
(1, prompt_audio_size),
device=self.device,
dtype=torch.float32,
)
speaker_audio_lens = torch.LongTensor([speaker_audio.shape[1]]).to(self.device)
B, T = speaker_audio.shape
device = speaker_audio.device
dtype = speaker_audio.dtype
# allocate result
prompt_audio = torch.zeros(B, prompt_audio_size, device=device, dtype=dtype)
# process each example independently
for b in range(B):
valid_len = min(speaker_audio_lens[b].item(), T)
# handle empty
if valid_len <= 0:
continue
# valid (non-padded) segment
valid_segment = speaker_audio[b, :valid_len]
if valid_len >= prompt_audio_size:
# enough valid audio → crop from start (no silence)
prompt_audio[b] = valid_segment[:prompt_audio_size]
else:
# too short → repeat and crop
repeat_factor = (prompt_audio_size + valid_len - 1) // valid_len # ceil division
expanded = valid_segment.repeat(repeat_factor)
prompt_audio[b] = expanded[:prompt_audio_size]
# add a silence in the end to smooth the transition between prompt and audio tokens
prompt_audio[:, -int(self.target_samples_per_frame * 2) :] = 0
# get prompt audio size
with fp32_precision():