Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:28:58 +08:00

693 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
"""
Evaluation script for Duplex EARTTS models following MagpieTTS evaluation recipe.
Args:
config-path (str):
Path to the directory containing the YAML configuration file.
config-name (str):
Name of the YAML configuration file.
checkpoint_path (str):
Path to the Duplex EARTTS checkpoint file.
datasets_json_path (str):
Path to a JSONL (JSON Lines) file describing the evaluation dataset.
Each line must be a valid JSON object representing one sample.
Supported formats:
----------------------------------------------------------------------
1) SINGLE-TURN FORMAT
----------------------------------------------------------------------
"text" is a string.
Example:
{"text": "Like really quickly and they go haha and then they run off.",
"context_audio_filepath": "speaker_1.wav",
"audio_filepath": "audio_1.wav"}
{"text": "Sure. Okay.",
"context_audio_filepath": "speaker_2.wav",
"audio_filepath": "audio_2.wav"}
----------------------------------------------------------------------
2) MULTI-TURN FORMAT
----------------------------------------------------------------------
"text" is a list of utterances (List[str]).
Each element represents one conversational turn. The model will
tokenize and pad each segment sequentially.
Example:
{"text": ["Okay yeah.", "Yeah.", "Right.", "I get what youre saying.", "That makes sense."],
"context_audio_filepath": "speaker_1.wav",
"audio_filepath": "dummy_blank_audio_mt_0001.wav"}
{"text": ["Okay.", "Really?", "Yeah, okay.", "I didnt know that.", "Thats interesting."],
"context_audio_filepath": "speaker_2.wav",
"audio_filepath": "audio_2.wav"}
----------------------------------------------------------------------
FIELD DESCRIPTIONS
----------------------------------------------------------------------
text:
Either:
- str (single-turn)
- List[str] (multi-turn)
context_audio_filepath:
Path to the reference speaker audio used for conditioning.
This can be overridden by setting:
++user_custom_speaker_reference=<path>
audio_filepath:
Output audio file name.
This is used only as the base filename for saving generated audio
inside `out_dir`. The file does NOT need to exist beforehand.
out_dir (str):
Directory where generated audio samples will be saved.
inference_dtype (str, optional):
Target dtype used during inference. This controls the precision
of model weights and operations.
Supported values:
- "float32" (default)
- "float16"
- "bfloat16"
Notes:
- If set to a lower precision (e.g., float16), the model weights
and/or execution dtype will be adjusted accordingly.
- Internally mapped via `getattr(torch, inference_dtype)`.
keep_codec_original_dtype (bool, optional):
Controls whether the audio codec module keeps its original dtype
when `inference_dtype` is not float32.
If True (default):
- Only the TTS backbone (`model.tts_model`) is cast to the target dtype.
- The codec remains in its original precision (typically float32).
- Useful to isolate precision effects and avoid degradation from
codec quantization.
If False:
- The entire model (including codec) is cast to `inference_dtype`.
- `model.audio_codec_run_dtype` is also set accordingly.
debug_dtype (bool, optional):
Enables runtime inspection of tensor dtypes flowing through the model.
If True:
- Forward hooks are attached to all leaf modules.
- During the first batch, dtype usage statistics are collected
and logged.
- Outputs include:
- Per-module-group dtype distribution
- Example module names per dtype
Usage:
# Example with fp32 inference
python duplex_eartts_eval.py \
--config-path=conf/ \
--config-name=duplex_eartts.yaml \
++checkpoint_path=duplex_eartts_results/duplex_eartts/model.ckpt \
++datasets_json_path=/path/to/evalset_config.jsonl \
++out_dir=duplex_eartts_results/duplex_eartts/audio_samples/dummy_dataset
# Example with fp16 inference and dtype debugging
python duplex_eartts_eval.py \
--config-path=conf/ \
--config-name=duplex_eartts.yaml \
++checkpoint_path=duplex_eartts_results/duplex_eartts/model.ckpt \
++datasets_json_path=/path/to/evalset_config.jsonl \
++out_dir=uplex_eartts_results/duplex_eartts/audio_samples/dummy_dataset \
++inference_dtype=float16 \
++keep_codec_original_dtype=True \
++debug_dtype=True
"""
import json
import os
from functools import partial
import librosa
import soundfile as sf
import torch
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import DataLoader, Dataset
from nemo.collections.audio.parts.utils.transforms import resample
torch.set_float32_matmul_precision("medium")
torch.backends.cudnn.allow_tf32 = True
torch.backends.cuda.matmul.allow_tf32 = True
from contextlib import nullcontext
from omegaconf import OmegaConf
from nemo.collections.speechlm2.models.duplex_ear_tts import DuplexEARTTS
from nemo.collections.speechlm2.parts.metrics.asr_cer_wer import Intelligibility
from nemo.collections.speechlm2.parts.metrics.secs import SECS
from nemo.collections.speechlm2.parts.precision import fp32_precision
from nemo.core.config import hydra_runner
from nemo.utils import logging
# Use .get() to avoid crashing when running a single GPU without torchrun
if torch.cuda.is_available():
torch.cuda.set_device(int(os.environ.get("LOCAL_RANK", 0)))
def attach_dtype_counter(model):
"""
Attaches forward hooks to all leaf modules of a model to track the dtype
of their outputs during inference.
This utility is designed for debugging precision behavior, especially when
using mixed precision or reduced precision (fp16 / bf16).
Behavior:
- Registers a forward hook on each leaf module (modules with no children).
- For each forward pass, records the dtype of the module output.
- Aggregates statistics grouped by top-level module name.
- Stores a few example module class names per dtype.
Returns:
handles (List[RemovableHandle]):
List of hook handles. These must be removed manually to avoid
memory leaks or performance degradation.
stats (Dict[str, Dict[str, int]]):
Nested dictionary containing dtype counts per module group.
Structure:
stats[module_group][dtype] = count
Example:
{
"tts_model": {
"torch.float16": 120,
"torch.float32": 0,
"torch.bfloat16": 0,
"other": 2
}
}
examples (Dict[str, Dict[str, List[str]]]):
Stores up to 3 example module class names per dtype per group.
Useful for quickly identifying which layers are running in
unexpected precision.
Notes:
- Only inspects outputs (not inputs or parameters).
- Dtype is inferred from the first tensor found in the output.
- Non-floating dtypes are categorized as "other".
- Grouping is based on the top-level module name (prefix before first dot).
Typical usage:
handles, stats, examples = attach_dtype_counter(model)
# Run inference ...
for h in handles:
h.remove()
"""
handles = []
# structure: stats[module_group][dtype] = count
stats = {}
examples = {}
def is_leaf(module):
return len(list(module.children())) == 0
def get_dtype(x):
if torch.is_tensor(x):
return str(x.dtype)
elif isinstance(x, (list, tuple)):
for t in x:
if torch.is_tensor(t):
return str(t.dtype)
return "other"
def get_module_group(name):
# top-level module (before first dot)
return name.split(".")[0] if "." in name else name
def hook_fn(name):
def fn(module, inputs, outputs):
dtype = get_dtype(outputs)
if dtype not in ["torch.float16", "torch.bfloat16", "torch.float32"]:
dtype = "other"
group = get_module_group(name)
if group not in stats:
stats[group] = {
"torch.float16": 0,
"torch.bfloat16": 0,
"torch.float32": 0,
"other": 0,
}
examples[group] = {
"torch.float16": [],
"torch.bfloat16": [],
"torch.float32": [],
"other": [],
}
stats[group][dtype] += 1
# store a few examples per dtype per group
if len(examples[group][dtype]) < 3:
examples[group][dtype].append(module.__class__.__name__)
return fn
for name, module in model.named_modules():
if is_leaf(module):
handles.append(module.register_forward_hook(hook_fn(name)))
return handles, stats, examples
def report_dtype_stats(handles, stats, examples):
"""
Cleans up monitoring hooks and logs a detailed report of the tensor precisions
(dtypes) observed during the model forward pass.
This function should be called after at least one inference iteration has
completed while hooks are attached. It removes the hooks to prevent
performance overhead and prints a structured summary of which module groups
executed in which dtypes.
Args:
handles (List[torch.utils.hooks.RemovableHandle]): The list of hooks
returned by `attach_dtype_counter`.
stats (Dict): Nested dictionary containing dtype counts per module group.
examples (Dict): Dictionary containing example module names for each
observed dtype.
"""
for h in handles:
h.remove()
logging.info("\n=== DTYPE USAGE PER MODULE ===")
for group, group_stats in stats.items():
total = sum(group_stats.values())
if total == 0:
continue
logging.info(f"\n--- {group} ---")
for dtype, count in group_stats.items():
if count > 0:
logging.info(f"{dtype}: {count} ({100*count/total:.2f}%)")
logging.info("\n=== EXAMPLES ===")
for group, group_examples in examples.items():
logging.info(f"\n--- {group} ---")
for dtype, mods in group_examples.items():
if mods:
logging.info(f"{dtype}: {mods}")
class EvalJSONLDataset(Dataset):
"""
Standard PyTorch Dataset for reading JSONL evaluation files.
"""
def __init__(self, file_path):
self.samples = []
with open(file_path, "r", encoding="utf-8") as f:
for line_idx, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
self.samples.append(json.loads(line))
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON on line {line_idx}: {e}")
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
return self.samples[idx]
def collate_and_tokenize_custom(
batch,
model,
extra_duration_thrshould=1.3,
sample_rate=22050,
root_path=None,
add_beginning_pad_tokens=False,
add_eos=False,
pad_factor_text_speech=10,
force_interruption=False,
):
tokenized_list = []
# --- TEXT TOKENIZATION ---
for s in batch:
text_data = s["text"]
# Check if text is a list (New Logic)
if isinstance(text_data, list):
# Start with BOS
full_ids = []
for segment in text_data:
# Tokenize segment
seg_ids = [model.tokenizer.bos]
seg_ids = seg_ids + model.tokenizer.text_to_ids(segment)
seg_len = len(seg_ids)
# Calculate pad length (pad_factor_text_speechx the size of the text)
pad_len = seg_len * pad_factor_text_speech
# Construct: text + 4x pads
# We extend the list with the tokens and then the pad tokens
pad_ids = [model.text_pad_id] * pad_len
if force_interruption:
fname = s["audio_filepath"]
no_ext = fname.split(".")[0]
sample_id = int(no_ext.split("_")[-1])
case = sample_id % 3 # 0,1,2 -> ~33% each
if case == 0:
# 33%: emulate interruption where text was not fully processed
# (no pad eos placement at all)
if len(seg_ids) >= 2:
seg_ids[-2] = model.text_eos_id
seg_ids[-1] = model.text_pad_id
else:
# fallback: if seg_ids is too short, emulate with pad EOS at 0
pad_ids[0] = model.text_eos_id
elif case == 1:
# 33%: put EOS at pad index 6 - so 0.5 seconds after the whole text was processed
eos_idx = min(6, len(pad_ids) - 1)
pad_ids[eos_idx] = model.text_eos_id
else:
# 33%: put EOS at pad index 0
eos_idx = 0
pad_ids[eos_idx] = model.text_eos_id
else:
if (
add_eos
): # add eos in the end of the paddding sequence keep 70% for the speech and the rest for after EOS
eos_idx = int(len(pad_ids) * 0.7)
pad_ids[eos_idx] = model.text_eos_id
full_ids.extend(seg_ids)
full_ids.extend(pad_ids)
tokenized_list.append(torch.as_tensor(full_ids, dtype=torch.long))
else:
# Standard String Handling
tokenized_list.append(
torch.as_tensor([model.tokenizer.bos] + model.tokenizer.text_to_ids(text_data), dtype=torch.long)
)
if add_beginning_pad_tokens:
pad_len = 25
prefix = torch.full((pad_len,), model.text_pad_id, dtype=torch.long)
for i in range(len(tokenized_list)):
tokenized_list[i] = torch.cat([prefix, tokenized_list[i]])
# Pad the text sequences (batch-wise)
input_ids = pad_sequence(tokenized_list, batch_first=True, padding_value=model.text_pad_id)
# load the target audio if available
audio_list = []
audio_lengths = []
target_num_frames = []
for i, s in enumerate(batch):
# Load Context Audio
audio_path = s["context_audio_filepath"]
if root_path is not None:
audio_path = os.path.join(root_path, audio_path)
# Safety check for context audio presence, though usually required
if os.path.exists(audio_path):
wav, sr = librosa.load(audio_path, sr=sample_rate, mono=True)
wav = torch.as_tensor(wav, dtype=torch.float32)
else:
# Fallback if context missing (optional safety)
wav = torch.zeros(1, dtype=torch.float32)
audio_list.append(wav)
audio_lengths.append(len(wav))
# Handle Target Audio / Duration
tdur_audio_path = s["audio_filepath"]
if root_path is not None:
tdur_audio_path = os.path.join(root_path, tdur_audio_path)
# Check availability
if tdur_audio_path and os.path.exists(tdur_audio_path):
wav_dur, sr_ = librosa.load(tdur_audio_path, sr=sample_rate, mono=True)
tdur = wav_dur.shape[0] // model.target_samples_per_frame
target_num_frames.append(tdur * extra_duration_thrshould)
else:
# Audio not available: Derive size from text channel
# We follow the 4x approach logic here to determine frames.
# If text was a list, it already has physical pads (1 + 4 ratio).
# We map 1 token roughly to 1 frame (or whatever the model scale is).
# Assuming 1 token ~ 1 frame in the model's alignment, we just take the input length.
current_text_len = len(tokenized_list[i])
if isinstance(s["text"], list):
# The text tokens are already physically padded 10x.
# Target frames should match this structure exactly.
target_num_frames.append(current_text_len)
else:
# If text was a string (no physical pads added), but audio is missing,
# we simulate the 4x duration expansion (1 part text, 4 parts silence = 5x total).
target_num_frames.append(current_text_len * 5)
# audio padding
max_audio_len = max(audio_lengths)
B = len(audio_lengths)
padded_audio = torch.zeros((B, max_audio_len), dtype=torch.float32)
for i, wav in enumerate(audio_list):
padded_audio[i, : len(wav)] = wav
# Keep on CPU
audio_lengths = torch.tensor(audio_lengths, dtype=torch.long)
# Expand text length to match expected output speech duration
B, L = input_ids.shape
target_len = int(max(target_num_frames))
# Ensure target_len is at least as long as the input text
# (prevents truncation if calc was slightly off)
target_len = max(target_len, L)
padded_input_ids = torch.full((B, target_len), fill_value=model.text_pad_id, dtype=input_ids.dtype)
# Copy the actual tokens (which might already contain list-based padding)
padded_input_ids[:, :L] = input_ids
# If text is a list ["Hi", "There"], join it into "Hi There"
collapsed_raw_text = [" ".join(s["text"]) if isinstance(s["text"], list) else s["text"] for s in batch]
return {
"input_ids": padded_input_ids,
"raw_text": collapsed_raw_text,
"context_audio": padded_audio,
"context_audio_lengths": audio_lengths,
"target_audio_paths": [s["audio_filepath"] for s in batch],
"target_num_frames": target_num_frames,
}
@hydra_runner(config_path="conf", config_name="duplex_eartts")
def inference(cfg):
OmegaConf.resolve(cfg)
distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1
if distributed and not torch.distributed.is_initialized():
torch.distributed.init_process_group(backend="nccl")
# Dynamically determine the correct GPU for this process
if torch.cuda.is_available():
local_rank = int(os.environ.get("LOCAL_RANK", 0))
target_device = torch.device(f"cuda:{local_rank}")
else:
target_device = torch.device("cpu")
torch.set_float32_matmul_precision("medium")
torch.backends.cudnn.allow_tf32 = True
torch.backends.cuda.matmul.allow_tf32 = True
target_dtype = getattr(torch, cfg.get("inference_dtype", "float32"))
if target_dtype != torch.float32:
torch.set_default_dtype(target_dtype)
if cfg.get("checkpoint_path", None):
model = DuplexEARTTS.load_from_checkpoint(
cfg.checkpoint_path, cfg=OmegaConf.to_container(cfg, resolve=True), map_location=target_device
).eval()
else:
raise ValueError("For evaluation, you must provide `cfg.checkpoint_path`.")
if target_dtype != torch.float32:
if cfg.get("keep_codec_original_dtype", True):
model.tts_model.to(dtype=target_dtype)
model.ensures_codec_target_dtype() # ensures that codec is in the right precision
else:
model.audio_codec_run_dtype = target_dtype
model.to(dtype=target_dtype)
if cfg.get("debug_dtype", False):
handles, stats, examples = attach_dtype_counter(model)
with fp32_precision():
intelligibility = Intelligibility("stt_en_fastconformer_transducer_large", reuse_asr_hyps=False).reset()
secs_metric = SECS("titanet_large").reset()
# Initialize the Dataset
eval_dataset = EvalJSONLDataset(cfg.datasets_json_path)
# Use partial to bind the model and config parameters to the collate function
collate_fn = partial(
collate_and_tokenize_custom,
model=model,
extra_duration_thrshould=1.5,
sample_rate=model.target_sample_rate,
root_path=cfg.audio_dir,
add_beginning_pad_tokens=cfg.get("add_beginning_pad_tokens", True),
add_eos=cfg.get("add_eos", True),
pad_factor_text_speech=cfg.get("pad_factor_text_speech", 10),
force_interruption=cfg.get("force_interruption", False),
)
# Initialize the DataLoader
dataloader = DataLoader(
dataset=eval_dataset,
batch_size=cfg.batch_size,
collate_fn=collate_fn,
num_workers=cfg.get("num_workers", 4),
pin_memory=True,
shuffle=False,
drop_last=False,
)
if cfg.get("user_custom_speaker_reference", None):
wav, sr = librosa.load(cfg.model.inference_speaker_reference, sr=model.target_sample_rate, mono=True)
speaker_wav = torch.as_tensor(wav, dtype=target_dtype).unsqueeze(0).to(model.device)
# Iterate over the DataLoader
for batch_id, inputs in enumerate(dataloader):
# Move required tensors to the GPU immediately
inputs["input_ids"] = inputs["input_ids"].to(model.device)
inputs["context_audio"] = inputs["context_audio"].to(model.device)
inputs["context_audio_lengths"] = inputs["context_audio_lengths"].to(model.device)
if cfg.get("user_custom_speaker_reference", None):
inputs["context_audio"] = speaker_wav.expand(inputs["input_ids"].size(0), *speaker_wav.shape[1:])
inputs["context_audio_lengths"][:] = speaker_wav.size(-1)
with torch.no_grad():
model.set_init_inputs(
speaker_audio=inputs["context_audio"],
speaker_audio_lens=inputs["context_audio_lengths"],
system_prompt=cfg.get("inference_system_prompt", None),
)
init_inputs = model.get_init_inputs(B=inputs["input_ids"].size(0))
audio, audio_len = model.offline_inference(
next_subword_ids=inputs["input_ids"],
task="custom",
init_inputs=init_inputs,
)
if cfg.get("debug_dtype", False) and batch_id == 0:
report_dtype_stats(handles, stats, examples)
with fp32_precision():
audio = audio.float()
# reset audio len to the actual size removing extra long audio padding
audio_len = (
torch.tensor(inputs["target_num_frames"], device=audio.device) * model.target_samples_per_frame
).int()
# resample audio to the asr sampling rate
metric_audio_pred = resample(audio, model.target_sample_rate, 16000)
metric_audio_pred_lens = (audio_len / model.target_sample_rate * 16000).to(torch.long)
intelligibility.update(
name="dataset",
refs=inputs["raw_text"],
pred_audio=metric_audio_pred,
pred_audio_lens=metric_audio_pred_lens,
asr_hyps=None,
)
secs_metric.update(
name="dataset",
target_audio=resample(inputs["context_audio"], model.target_sample_rate, 16000),
target_audio_lens=(inputs["context_audio_lengths"] / model.target_sample_rate * 16000).to(torch.long),
pred_audio=metric_audio_pred,
pred_audio_lens=metric_audio_pred_lens,
)
# save audio to cfg.out_dir
os.makedirs(cfg.out_dir, exist_ok=True)
audio = audio.detach().cpu().float()
audio_len = audio_len.cpu()
for i in range(audio.size(0)):
wav = audio[i, : audio_len[i]].numpy()
# Use original target audio filename
target_path = inputs["target_audio_paths"][i]
base_name = os.path.basename(target_path)
out_path = os.path.join(cfg.out_dir, base_name)
sf.write(
out_path,
wav,
samplerate=model.target_sample_rate,
)
logging.info(f"Saved: {out_path}")
with fp32_precision():
logging.info("\n--- Evaluation Metrics ---")
cer_wer = intelligibility.compute()
for k, m in cer_wer.items():
logging.info(f"Intelligibility - {k}: {m}")
secs_scores = secs_metric.compute()
for k, m in secs_scores.items():
logging.info(f"SECS - {k}: {m}")
if __name__ == "__main__":
inference()