ba4be087d5
Create PR to main with cherry-pick from release / cherry-pick (push) Failing after 0s
CICD NeMo / pre-flight (push) Failing after 0s
CICD NeMo / configure (push) Has been skipped
Build, validate, and release Neural Modules / pre-flight (push) Failing after 1s
CICD NeMo / code-linting (push) Has been skipped
Build, validate, and release Neural Modules / release (push) Has been skipped
Build, validate, and release Neural Modules / release-summary (push) Has been cancelled
CICD NeMo / cicd-test-container-build (push) Has been cancelled
CICD NeMo / cicd-import-tests (push) Has been cancelled
CICD NeMo / L0_Setup_Test_Data_And_Models (push) Has been cancelled
CICD NeMo / cicd-main-unit-tests (push) Has been cancelled
CICD NeMo / cicd-main-speech (push) Has been cancelled
CICD NeMo / Nemo_CICD_Test (push) Has been cancelled
CICD NeMo / Coverage (e2e) (push) Has been cancelled
CICD NeMo / Coverage (unit-test) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CICD NeMo / cicd-wait-in-queue (push) Has been cancelled
584 lines
17 KiB
Python
584 lines
17 KiB
Python
# 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 os
|
|
import pytest
|
|
import torch
|
|
from lhotse import CutSet, SupervisionSegment
|
|
from lhotse.testing.dummies import dummy_cut, dummy_recording
|
|
|
|
from nemo.collections.common.data.utils import move_data_to_device
|
|
from nemo.collections.speechlm2.data.duplex_ear_tts_dataset import (
|
|
DuplexEARTTSDataset,
|
|
add_speech_delay,
|
|
sample_audio_segments_repeat,
|
|
)
|
|
from nemo.collections.speechlm2.models import DuplexEARTTS
|
|
|
|
|
|
if torch.cuda.is_available():
|
|
torch.set_default_device('cuda')
|
|
|
|
|
|
test_eartts_config = {
|
|
"model": {
|
|
"pretrained_lm_name": "nvidia/NVIDIA-Nemotron-Nano-9B-v2",
|
|
"pretrained_ae_dir": None,
|
|
"pretrained_tts_model": None,
|
|
"trust_remote_code": True,
|
|
"scoring_asr": "stt_en_fastconformer_transducer_large",
|
|
"freeze_params": [
|
|
r"^audio_codec\..+$", # Keep audio codec frozen as it only provides supervision for training.
|
|
r"^embed_tokens\..+$", # Keep embed_tokens frozen as done in eartts
|
|
],
|
|
"bos_token": "<s>",
|
|
"eos_token": "</s>",
|
|
"pad_token": "<SPECIAL_12>",
|
|
"audio_codec_run_dtype": "float32",
|
|
"prevent_freeze_params": [],
|
|
"audio_save_path": "",
|
|
"inference_guidance_scale": 0.5,
|
|
"inference_noise_scale": 0.8,
|
|
"inference_top_p_or_k": 0.8,
|
|
"inference_guidance_enabled": False,
|
|
"subword_mask_exactly_as_eartts": False,
|
|
"context_hidden_mask_exactly_as_eartts": False,
|
|
"exclude_norm_from_wd": True,
|
|
"optimizer": {
|
|
"_target_": "torch.optim.AdamW",
|
|
"lr": 4e-5,
|
|
"betas": [0.9, 0.98],
|
|
"weight_decay": 0,
|
|
"foreach": True,
|
|
},
|
|
"lr_scheduler": {
|
|
"_target_": "nemo.core.optim.lr_scheduler.InverseSquareRootAnnealing",
|
|
"warmup_steps": 2500,
|
|
"min_lr": 1e-6,
|
|
"max_steps": 100_000_000,
|
|
},
|
|
"codec_config": {
|
|
"latent_size": 512,
|
|
"n_fft": 16,
|
|
"hop_length": 4,
|
|
"base_hidden_size": 384,
|
|
"channel_mult": [1, 2, 4],
|
|
"rates": [7, 7, 9],
|
|
"num_blocks": 3,
|
|
"kernel_size": 7,
|
|
"groups": 1,
|
|
"codebook_size": 1024,
|
|
"num_quantizers": 31,
|
|
"wav_to_token_ratio": 1764,
|
|
},
|
|
"tts_config": {
|
|
"use_gated_fusion_for_text_audio": True,
|
|
"disable_eos_prediction": True,
|
|
"use_bos_eos_emb": True,
|
|
"use_subword_flag_emb": True,
|
|
"num_delay_speech_tokens": 2,
|
|
"backbone_type": "gemma3_text",
|
|
"backbone_model_class": None,
|
|
"backbone_config_class": None,
|
|
"backbone_config": {
|
|
"hidden_size": 1152,
|
|
"intermediate_size": 4608,
|
|
"num_hidden_layers": 1,
|
|
"num_attention_heads": 16,
|
|
"num_key_value_heads": 16,
|
|
"head_dim": 72,
|
|
"attention_dropout": 0.1,
|
|
"use_cache": False,
|
|
},
|
|
"latent_size": 512,
|
|
"codebook_size": 1024,
|
|
"num_quantizers": 31,
|
|
"context_hidden_size": None,
|
|
"cas_config": {
|
|
"backbone_type": "t5gemma",
|
|
"backbone_model_class": None,
|
|
"backbone_config_class": None,
|
|
"backbone_config": {
|
|
"is_encoder_decoder": False,
|
|
"encoder": {
|
|
"hidden_size": 1152,
|
|
"intermediate_size": 4608,
|
|
"num_hidden_layers": 1,
|
|
"num_attention_heads": 16,
|
|
"num_key_value_heads": 16,
|
|
"head_dim": 72,
|
|
"use_cache": False,
|
|
"attention_dropout": 0.1,
|
|
},
|
|
},
|
|
},
|
|
"mog_head_config": {
|
|
"intermediate_size": 4608,
|
|
"num_layers": 3,
|
|
"low_rank": 64,
|
|
"num_predictions": 1024,
|
|
"min_log_std": -4.0,
|
|
"eps": 1e-6,
|
|
},
|
|
"p_uncond": 0.1,
|
|
"label_smoothing": 0.01,
|
|
"max_training_rate": 0.8,
|
|
"quantizer_dropout": 0.5,
|
|
"random_target_masking": False,
|
|
"exponent": 3.0,
|
|
},
|
|
},
|
|
"trainer": {
|
|
"devices": -1,
|
|
"accelerator": "gpu",
|
|
"num_nodes": 1,
|
|
"precision": 32,
|
|
"logger": False,
|
|
"enable_checkpointing": False,
|
|
"use_distributed_sampler": False,
|
|
"max_steps": 100_000_000,
|
|
"val_check_interval": 1000,
|
|
"limit_train_batches": "${trainer.val_check_interval}",
|
|
"limit_val_batches": 2,
|
|
"log_every_n_steps": 20,
|
|
"num_sanity_val_steps": 0,
|
|
"gradient_clip_val": 1.0,
|
|
"accumulate_grad_batches": 1,
|
|
"strategy": {
|
|
"_target_": "lightning.pytorch.strategies.DDPStrategy",
|
|
"gradient_as_bucket_view": True,
|
|
"find_unused_parameters": True,
|
|
},
|
|
},
|
|
"data": {
|
|
"add_text_bos_and_eos_in_each_turn": True,
|
|
"add_audio_prompt": True,
|
|
"audio_prompt_duration": 3.0,
|
|
"frame_length": 0.08,
|
|
"source_sample_rate": 22050,
|
|
"target_sample_rate": 22050,
|
|
"input_roles": ["user", "User"],
|
|
"output_roles": ["agent", "Assistant", "assistant", "Agent"],
|
|
},
|
|
"exp_manager": {
|
|
"exp_dir": None,
|
|
"explicit_log_dir": "",
|
|
"name": "eartts",
|
|
"create_tensorboard_logger": False,
|
|
"create_checkpoint_callback": True,
|
|
"use_datetime_version": True,
|
|
"max_time_per_run": "00:03:50:00",
|
|
"resume_from_checkpoint": None,
|
|
"resume_if_exists": True,
|
|
"resume_ignore_no_checkpoint": True,
|
|
"create_wandb_logger": True,
|
|
"wandb_logger_kwargs": {
|
|
"name": "duplex_eartts_test",
|
|
"project": "duplex_eartts",
|
|
"resume": True,
|
|
},
|
|
},
|
|
}
|
|
|
|
# set CI cached path
|
|
if os.path.exists("/home/TestData/nvidia--NVIDIA-Nemotron-Nano-9B-v2/"):
|
|
test_eartts_config["model"]["pretrained_lm_name"] = "/home/TestData/nvidia--NVIDIA-Nemotron-Nano-9B-v2/"
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def model():
|
|
model = DuplexEARTTS(test_eartts_config)
|
|
if torch.cuda.is_available():
|
|
model.to("cuda")
|
|
return model
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def dataset(model):
|
|
return DuplexEARTTSDataset(
|
|
model.tokenizer,
|
|
add_text_bos_and_eos_in_each_turn=True,
|
|
add_audio_prompt=True,
|
|
audio_prompt_duration=3.0,
|
|
frame_length=0.08,
|
|
source_sample_rate=22050,
|
|
target_sample_rate=22050,
|
|
input_roles=["user", "User"],
|
|
output_roles=["agent", "Assistant", "assistant", "Agent"],
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def training_cutset_batch():
|
|
cut = dummy_cut(0, recording=dummy_recording(0, with_data=True, duration=1.0, sampling_rate=22050))
|
|
cut.target_audio = dummy_recording(1, with_data=True, duration=1.0, sampling_rate=22050)
|
|
cut.supervisions = [
|
|
SupervisionSegment(
|
|
id=cut.id,
|
|
recording_id=cut.recording_id,
|
|
start=0,
|
|
duration=0.1,
|
|
text='hi',
|
|
speaker="user",
|
|
),
|
|
SupervisionSegment(
|
|
id=cut.id,
|
|
recording_id=cut.recording_id,
|
|
start=0.3,
|
|
duration=0.1,
|
|
text='hello',
|
|
speaker="assistant",
|
|
),
|
|
SupervisionSegment(
|
|
id=cut.id,
|
|
recording_id=cut.recording_id,
|
|
start=0.5,
|
|
duration=0.1,
|
|
text='ok',
|
|
speaker="user",
|
|
),
|
|
SupervisionSegment(
|
|
id=cut.id,
|
|
recording_id=cut.recording_id,
|
|
start=0.6,
|
|
duration=0.1,
|
|
text='okay',
|
|
speaker="assistant",
|
|
),
|
|
]
|
|
return CutSet([cut])
|
|
|
|
|
|
def test_eartts_dataset(dataset, training_cutset_batch):
|
|
batch = dataset[training_cutset_batch]
|
|
expected_keys = {
|
|
"sample_id",
|
|
"non_prompt_mask",
|
|
"prompt_lens",
|
|
"aligned_attention_mask",
|
|
"aligned_position_ids",
|
|
"source_audio",
|
|
"source_audio_lens",
|
|
"target_audio",
|
|
"target_audio_lens",
|
|
"target_text_tokens",
|
|
"target_token_lens",
|
|
"source_tokens",
|
|
"source_token_lens",
|
|
"target_texts",
|
|
"audio_prompt",
|
|
"audio_prompt_lens",
|
|
"task",
|
|
}
|
|
|
|
for key in expected_keys:
|
|
assert key in batch, f"Missing key: {key}"
|
|
|
|
tensor_keys = [
|
|
"non_prompt_mask",
|
|
"aligned_attention_mask",
|
|
"aligned_position_ids",
|
|
"source_audio",
|
|
"source_audio_lens",
|
|
"target_audio",
|
|
"target_audio_lens",
|
|
"target_text_tokens",
|
|
"target_token_lens",
|
|
"source_tokens",
|
|
"source_token_lens",
|
|
"audio_prompt",
|
|
"audio_prompt_lens",
|
|
]
|
|
|
|
for key in tensor_keys:
|
|
assert torch.is_tensor(batch[key]), f"{key} must be a tensor"
|
|
|
|
# Check target text consistency
|
|
assert batch["target_texts"] == ["hello okay"]
|
|
assert batch["source_tokens"].tolist() == [
|
|
[
|
|
2,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
2,
|
|
1,
|
|
2,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
1,
|
|
1662,
|
|
2,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
]
|
|
]
|
|
|
|
assert batch["target_text_tokens"].tolist() == [
|
|
[
|
|
2,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
2,
|
|
12,
|
|
12,
|
|
12,
|
|
12,
|
|
1,
|
|
2,
|
|
12,
|
|
12,
|
|
1,
|
|
2,
|
|
1417,
|
|
12,
|
|
12,
|
|
]
|
|
]
|
|
|
|
# Check task
|
|
assert batch["task"] == ["s2s_duplex"]
|
|
|
|
|
|
# test extra functions inside of eartts dataset
|
|
def test_add_speech_delay():
|
|
source_audio = torch.ones(1, 16000)
|
|
target_audio = torch.ones(1, 22050)
|
|
|
|
source_lens = torch.tensor([16000])
|
|
target_lens = torch.tensor([22050])
|
|
|
|
num_delays = 2
|
|
|
|
# samples per frame (float → int handled explicitly)
|
|
target_samples_per_frame = source_audio.size(1) / 12.5
|
|
source_samples_per_frame = target_audio.size(1) / 12.5
|
|
|
|
expected_extra_src_size = int(source_samples_per_frame * num_delays)
|
|
expected_extra_tgt_size = int(target_samples_per_frame * num_delays)
|
|
|
|
out_src, out_src_lens, out_tgt, out_tgt_lens = add_speech_delay(
|
|
source_audio=source_audio,
|
|
source_audio_lens=source_lens,
|
|
target_audio=target_audio,
|
|
target_audio_lens=target_lens,
|
|
num_delay_speech_tokens=num_delays,
|
|
target_samples_per_frame=target_samples_per_frame,
|
|
source_samples_per_frame=source_samples_per_frame,
|
|
)
|
|
|
|
# --------------------------------------------------
|
|
# Shape & length bookkeeping
|
|
# --------------------------------------------------
|
|
assert out_src.shape == (1, source_audio.size(1) + expected_extra_src_size)
|
|
assert out_tgt.shape == (1, target_audio.size(1) + expected_extra_tgt_size)
|
|
assert out_src_lens.item() == source_lens.item() + expected_extra_src_size
|
|
assert out_tgt_lens.item() == target_lens.item() + expected_extra_tgt_size
|
|
|
|
# --------------------------------------------------
|
|
# Padding direction & content
|
|
# --------------------------------------------------
|
|
# Target audio is left-padded
|
|
assert torch.all(out_tgt[:, :expected_extra_tgt_size] == 0)
|
|
assert torch.all(out_tgt[:, expected_extra_tgt_size:] == 1)
|
|
|
|
# Source audio is right-padded
|
|
assert torch.all(out_src[:, : source_audio.size(1)] == 1)
|
|
assert torch.all(out_src[:, source_audio.size(1) :] == 0)
|
|
|
|
|
|
def test_sample_audio_segments_repeat():
|
|
cases = [
|
|
# (audio, lens, n_sample, expected_when_sample_false)
|
|
(
|
|
torch.tensor([[1.0, 2.0, 3.0, 4.0, 5.0]]),
|
|
torch.tensor([5]),
|
|
3,
|
|
torch.tensor([[1.0, 2.0, 3.0]]),
|
|
),
|
|
(
|
|
torch.tensor([[1.0, 2.0]]),
|
|
torch.tensor([2]),
|
|
5,
|
|
torch.tensor([[1.0, 2.0, 1.0, 2.0, 1.0]]),
|
|
),
|
|
(
|
|
torch.zeros(1, 10),
|
|
torch.tensor([0]),
|
|
4,
|
|
torch.zeros(1, 4),
|
|
),
|
|
]
|
|
|
|
for prompt_audio, prompt_audio_lens, n_sample, expected in cases:
|
|
# --------------------------------------------------
|
|
# sample=False → deterministic + sequence check
|
|
# --------------------------------------------------
|
|
out = sample_audio_segments_repeat(
|
|
prompt_audio,
|
|
prompt_audio_lens,
|
|
n_sample=n_sample,
|
|
sample=False,
|
|
)
|
|
|
|
assert out.shape == expected.shape
|
|
assert torch.equal(out, expected)
|
|
|
|
# --------------------------------------------------
|
|
# sample=True → stochastic, shape only
|
|
# --------------------------------------------------
|
|
out = sample_audio_segments_repeat(
|
|
prompt_audio,
|
|
prompt_audio_lens,
|
|
n_sample=n_sample,
|
|
sample=True,
|
|
)
|
|
|
|
assert out.shape == expected.shape
|
|
|
|
|
|
def test_eartts_training_step(model, dataset, training_cutset_batch):
|
|
model.train()
|
|
model.on_train_epoch_start()
|
|
batch = dataset[training_cutset_batch]
|
|
batch = move_data_to_device(batch, device=model.device)
|
|
results = model.training_step(batch, batch_idx=0)
|
|
assert torch.is_tensor(results["loss"])
|
|
assert not torch.isnan(results["loss"])
|
|
assert results["loss"] > 0
|
|
|
|
|
|
def test_eartts_validation_step(model, dataset, training_cutset_batch):
|
|
model.eval()
|
|
model.on_validation_epoch_start()
|
|
batch = dataset[training_cutset_batch]
|
|
batch = move_data_to_device(batch, device=model.device)
|
|
results = model.validation_step({"dummy_val_set": batch}, batch_idx=0)
|
|
assert results is None # no return value
|
|
|
|
|
|
def test_eartts_offline_generation(model):
|
|
model.eval()
|
|
# generate random subword_ids
|
|
subword_ids = torch.ones(2, 10).long()
|
|
|
|
# set init inputs and get it
|
|
model.set_init_inputs(
|
|
speaker_audio=torch.randn(1, 22050),
|
|
speaker_audio_lens=torch.tensor([22050]),
|
|
)
|
|
init_inputs = model.get_init_inputs(B=subword_ids.size(0))
|
|
torch.manual_seed(42)
|
|
if torch.cuda.is_available():
|
|
torch.cuda.manual_seed_all(42)
|
|
|
|
gen_audio, gen_audio_len = model.offline_inference(
|
|
next_subword_ids=subword_ids,
|
|
init_inputs=init_inputs,
|
|
)
|
|
torch.manual_seed(42)
|
|
if torch.cuda.is_available():
|
|
torch.cuda.manual_seed_all(42)
|
|
|
|
gen_audio_inc, gen_audio_len_inc = model.offline_inference(
|
|
next_subword_ids=subword_ids, init_inputs=init_inputs, incremental_audio_decoding=True
|
|
)
|
|
|
|
assert torch.equal(
|
|
gen_audio_len, gen_audio_len_inc
|
|
), "Audio lengths differ between incremental and non-incremental decoding."
|
|
|
|
# compare waveform
|
|
torch.testing.assert_close(
|
|
gen_audio,
|
|
gen_audio_inc,
|
|
atol=1e-1,
|
|
rtol=0,
|
|
)
|
|
|
|
assert gen_audio.shape == (2, 17640)
|
|
assert gen_audio_len[0] == gen_audio.size(-1)
|
|
assert gen_audio.dtype == torch.float32
|