NVIDIA-NeMo/Speech · #16291
[TTS] Validate MagpieTTS evalset configs and centralize per-dataset evaluation overrides
docs/source/tts/magpietts.rst27 + / 0 −
@@ -133,6 +133,7 @@ Several parameters control the generation behavior. The temperature setting affe python examples/tts/magpietts_inference.py \ --nemo_files /path/to/magpietts_model.nemo \ --codecmodel_path /path/to/audio_codec.nemo \+ --datasets_json_path /path/to/evalset_config.json \ --datasets your_evaluation_set \ --out_dir /path/to/output \ --temperature 0.6 \@@ -146,6 +147,32 @@ When using models trained with frame stacking, you can enable the Local Transfor To enable Long-form speech generation (beta) set ``--longform_mode`` to ``auto or always``; this will either automatically detect the long form text or always use the long form inference pipeline. For comprehensive documentation on longform inference, see the :doc:`Longform Inference Guide <magpietts-longform>`. +Evaluation set configuration+----------------------------++``--datasets_json_path`` points to a JSON file that maps each dataset name to its manifest, its audio directory, and optional per-dataset evaluation overrides. Relative paths are resolved against ``--datasets_base_path``. ``--datasets`` selects a subset of the entries.++.. code-block:: json++ {+ "libritts_test_clean": {+ "manifest_path": "LibriTTS/test_clean_manifest.json",+ "audio_dir": "LibriTTS/test-clean",+ "language": "en",+ "asr_model": {"name": "nvidia/parakeet-tdt-1.1b", "type": "nemo"}+ }+ }++The optional keys are:++- ``language``: overrides ``--language`` for this dataset.+- ``asr_model``: ``{"name": ..., "type": ...}`` overrides ``--asr_model_name`` and ``--asr_model_type``.+- ``tokenizer_names``: list of text tokenizer names to use for this dataset (``magpietts_inference.py`` only).++Malformed values of ``language`` and ``asr_model`` (for example a JSON ``null`` or an ``asr_model`` without a supported ``type``) are rejected when the config is loaded; remove a key to inherit the command-line value. Keys that are not recognized, including training-only fields such as ``feature_dir``, are rejected, so that a misspelled key cannot silently fall back to the command-line value.++The CER/WER reference (``gt_text``) is taken from ``normalized_text`` when present, otherwise from ``original_text``, otherwise from ``text``. ``normalized_text`` and ``text`` are the same strings the dataloaders feed the model; ``original_text`` is the orthography kept in legacy phonemized manifests and is used only as the metric reference there, so ``gt_text`` and ``tts_text_input`` differ for such manifests.+ Resources ######### examples/tts/evalset_config.json3 + / 7 −
@@ -1,23 +1,19 @@ { "an4_val_ci": { "manifest_path": "/home/TestData/an4_dataset/an4_val_context_v1.json",- "audio_dir": "/",- "feature_dir": null+ "audio_dir": "/" }, "an4_val_tiny_ci": { "manifest_path": "/home/TestData/an4_dataset/an4_val_context_v1_tiny.json",- "audio_dir": "/",- "feature_dir": null+ "audio_dir": "/" }, "an4_val_ci_longform_tiny": { "manifest_path": "/home/TestData/an4_dataset/an4_val_context_v1_longform_tiny_normalized.json",- "audio_dir": "/",- "feature_dir": null+ "audio_dir": "/" }, "an4_val_ci_nemotronTokenizer": { "manifest_path": "/home/TestData/an4_dataset/an4_val_context_v1.json", "audio_dir": "/",- "feature_dir": null, "tokenizer_names": ["nemotron_nano_30b"] } }examples/tts/magpietts_inference.py13 + / 42 −
@@ -71,9 +71,11 @@ EvaluationConfig, compute_mean_with_confidence_interval, evaluate_generated_audio_dir,+ resolve_evaluation_config_for_dataset, ) from nemo.collections.tts.modules.magpietts_inference.inference import BaseInferenceConfig, BaseInferenceRunner from nemo.collections.tts.modules.magpietts_inference.utils import (+ EXPERIMENT_METRICS_CSV_HEADER, ModelLoadConfig, _add_common_args, _add_easy_magpie_args,@@ -165,38 +167,24 @@ def run_inference_and_evaluation( cer_per_dataset = [] all_datasets_filewise_metrics = {} - # CSV headers- csv_header = (- "checkpoint_name,dataset,cer_filewise_avg,wer_filewise_avg,cer_cumulative,"- "wer_cumulative,cer_pred_gt_audio_filewise_avg,cer_pred_gt_audio_cumulative,"- "wer_pred_gt_audio_filewise_avg,wer_pred_gt_audio_cumulative,"- "ssim_pred_gt_avg,ssim_pred_context_avg,ssim_gt_context_avg,"- "ssim_pred_gt_avg_alternate,ssim_pred_context_avg_alternate,"- "ssim_gt_context_avg_alternate,esim_pred_gt_avg,ems_pred_gt_avg,"- "pitch_distance_avg,intensity_distance_avg,speech_rate_distance_avg,"- "cer_gt_audio_cumulative,wer_gt_audio_cumulative,"- "utmosv2_avg,total_gen_audio_seconds,frechet_codec_distance,"- "eou_cutoff_rate,eou_silence_rate,eou_noise_rate,eou_error_rate,"- "katakana_cer_filewise_avg,katakana_cer_cumulative"- )+ # CSV header; the columns are shared with append_metrics_to_csv so header and rows cannot drift apart.+ csv_header = EXPERIMENT_METRICS_CSV_HEADER for dataset in datasets: logging.info(f"Processing dataset: {dataset}") meta = dataset_meta_info[dataset] manifest_records = read_manifest(meta['manifest_path']) - if 'asr_model' in meta:- asr_model_name = meta['asr_model']['name']- asr_model_type = meta['asr_model']['type']- else:- asr_model_name = eval_config.asr_model_name- asr_model_type = eval_config.asr_model_type-- if 'language' in meta:- language = meta.get('language')- else:- language = eval_config.language+ # Per-dataset overrides (asr_model, language) come from the evalset entry; everything else is inherited from+ # the CLI-level eval_config.+ eval_config_for_dataset = resolve_evaluation_config_for_dataset(eval_config, meta)+ language = eval_config_for_dataset.language+ if not skip_evaluation:+ logging.info(+ f"Dataset {dataset}: language={language}, "+ f"asr_model={eval_config_for_dataset.asr_model_name} ({eval_config_for_dataset.asr_model_type})"+ ) tokenizer_names = meta.get('tokenizer_names', None) @@ -299,23 +287,6 @@ def run_inference_and_evaluation( default_audio_dir=meta['audio_dir'], ) - eval_config_for_dataset = EvaluationConfig(- sv_model=eval_config.sv_model,- asr_model_name=asr_model_name,- asr_model_type=asr_model_type,- eou_model_name=eval_config.eou_model_name,- language=language,- with_utmosv2=eval_config.with_utmosv2,- with_fcd=eval_config.with_fcd,- codec_model_path=eval_config.codec_model_path,- with_prosody_metrics=eval_config.with_prosody_metrics,- prosody_model_size=eval_config.prosody_model_size,- strip_text_annotations_for_metrics=eval_config.strip_text_annotations_for_metrics,- device=eval_config.device,- asr_batch_size=eval_config.asr_batch_size,- eou_batch_size=eval_config.eou_batch_size,- )- metrics, filewise_metrics = evaluate_generated_audio_dir( manifest_path=eval_manifest_path, audio_dir=eval_audio_dir,nemo/collections/tts/modules/magpietts_inference/__init__.py2 + / 0 −
@@ -51,6 +51,7 @@ EvaluationConfig, compute_mean_with_confidence_interval, evaluate_generated_audio_dir,+ resolve_evaluation_config_for_dataset, ) from nemo.collections.tts.modules.magpietts_inference.inference import ( BaseInferenceConfig,@@ -91,6 +92,7 @@ # Evaluation "EvaluationConfig", "evaluate_generated_audio_dir",+ "resolve_evaluation_config_for_dataset", "compute_mean_with_confidence_interval", "DEFAULT_VIOLIN_METRICS", # Visualizationnemo/collections/tts/modules/magpietts_inference/evaluate_generated_audio.py57 + / 49 −
@@ -15,7 +15,6 @@ """ Used in inference and evaluation scripts to obtain metrics such as ASR_WER and UTMOSV2 scores. """-import argparse import json import os import pprint@@ -38,6 +37,12 @@ from nemo.collections.tts.metrics.eou_classifier import EoUClassification, EoUClassifier, EoUType from nemo.collections.tts.metrics.frechet_codec_distance import FrechetCodecDistance from nemo.collections.tts.metrics.prosody import compute_prosody_distances+from nemo.collections.tts.modules.magpietts_inference.evaluation_config import (+ ASR_MODEL_TYPES,+ EVALSET_ENTRY_KEYS,+ EvaluationConfig,+ validate_evalset_entry,+) from nemo.collections.tts.parts.utils.tts_dataset_utils import ( JapaneseTextProcessor, NemoTranscriber,@@ -117,7 +122,8 @@ def _get_record_texts(record: dict) -> tuple[str, Optional[str], str]: if dataloader_normalized_text is not None: metric_reference_text = dataloader_normalized_text else:- metric_reference_text = record.get("original_text", tts_text_input)+ original_text = record.get("original_text") # JSON null counts as absent+ metric_reference_text = original_text if original_text is not None else tts_text_input return tts_text_input, dataloader_normalized_text, metric_reference_text @@ -150,16 +156,29 @@ def _get_record_texts(record: dict) -> tuple[str, Optional[str], str]: def load_evalset_config(config_path: Optional[str] = None, dataset_base_path: Optional[Path] = None) -> dict:- """Load dataset meta info from JSON config file."""+ """Load dataset meta info from JSON config file.++ Relative ``manifest_path``, ``audio_dir`` and ``asr_model.name`` (``.nemo``) paths are resolved against+ ``dataset_base_path``. Every entry is checked with ``validate_evalset_entry``; keys outside ``EVALSET_ENTRY_KEYS``+ are rejected.+ """ if config_path is None or not os.path.exists(config_path): raise ValueError("No dataset_json_path provided, please provide a valid path to the evalset config file.") logging.info(f"Loading evalset config from {config_path}") with open(config_path, 'r') as f: dataset_meta_info = json.load(f) - # Validate that all evaluation datasets exist+ # Validate that all evaluation datasets exist and that the optional per-dataset overrides are well-formed+ # (see validate_evalset_entry and resolve_evaluation_config_for_dataset in evaluation_config.py). for dataset_name, info in dataset_meta_info.items():+ validate_evalset_entry(info, dataset_name=dataset_name)+ unrecognized_keys = sorted(set(info) - EVALSET_ENTRY_KEYS)+ if unrecognized_keys:+ raise ValueError(+ f"Dataset {dataset_name}: unrecognized evalset keys {unrecognized_keys}; "+ f"recognized keys are {sorted(EVALSET_ENTRY_KEYS)}."+ ) manifest_path = Path(info["manifest_path"]) audio_dir = Path(info["audio_dir"]) @@ -350,7 +369,10 @@ def load_evaluation_models( elif asr_model_type == "whisper": models['asr_model'] = WhisperTranscriber(model_name=asr_model_name, device=device) else:- raise ValueError(f"Unknown ASR model type {asr_model_name}")+ raise ValueError(+ f"Unknown ASR model type {asr_model_type!r} for ASR model {asr_model_name!r}; "+ f"expected one of {', '.join(ASR_MODEL_TYPES)}."+ ) if sv_model_type == "wavlm": models['feature_extractor'] = Wav2Vec2FeatureExtractor.from_pretrained('microsoft/wavlm-base-plus-sv')@@ -829,6 +851,36 @@ def evaluate( return avg_metrics, filtered_filewise +def evaluate_with_config(manifest_path, audio_dir, generated_audio_dir, config: EvaluationConfig):+ """Run ``evaluate`` with every field of ``config`` forwarded.++ Single place that maps ``EvaluationConfig`` fields to ``evaluate`` keyword arguments; used by+ ``evaluation.evaluate_generated_audio_dir``.++ Returns:+ Tuple of (avg_metrics dict, filewise_metrics list), see ``evaluate``.+ """+ return evaluate(+ manifest_path=manifest_path,+ audio_dir=audio_dir,+ generated_audio_dir=generated_audio_dir,+ language=config.language,+ sv_model_type=config.sv_model,+ asr_model_name=config.asr_model_name,+ asr_model_type=config.asr_model_type,+ with_utmosv2=config.with_utmosv2,+ with_fcd=config.with_fcd,+ codec_model_path=config.codec_model_path,+ with_prosody_metrics=config.with_prosody_metrics,+ prosody_model_size=config.prosody_model_size,+ strip_text_annotations_for_metrics=config.strip_text_annotations_for_metrics,+ device=config.device,+ eou_model_name=config.eou_model_name,+ asr_batch_size=config.asr_batch_size,+ eou_batch_size=config.eou_batch_size,+ )++ def compute_fcd(gt_audio_paths, predicted_codes_paths, codec_model_path, device="cuda"): """Compute Frechet Codec Distance from ground-truth audio paths and predicted codec codes paths. @@ -972,47 +1024,3 @@ def compute_global_metrics( pprint.pprint(avg_metrics) return avg_metrics---def main():- # audio_dir="/datap/misc/Datasets/riva" \- parser = argparse.ArgumentParser(description='Evaluate Generated Audio')- parser.add_argument('--manifest_path', type=str, default=None)- parser.add_argument('--audio_dir', type=str, default=None)- parser.add_argument('--generated_audio_dir', type=str, default=None)- parser.add_argument('--language', type=str, default="en")- parser.add_argument('--evalset', type=str, default=None)- parser.add_argument(- '--with_prosody_metrics',- action='store_true',- help='Compute ESIM/EMS and pitch, intensity, and speech-rate distance metrics.',- )- parser.add_argument('--prosody_model_size', type=str, default="small", choices=["small", "large"])- parser.add_argument(- '--strip_text_annotations_for_metrics',- action='store_true',- help='Strip bracket/tag/control annotations from reference and ASR hypothesis text while computing text metrics.',- )- args = parser.parse_args()-- if args.evalset is not None:- dataset_meta_info = load_evalset_config()- assert args.evalset in dataset_meta_info, f"Dataset '{args.evalset}' not found in evalset_config.json"- args.manifest_path = dataset_meta_info[args.evalset]['manifest_path']- args.audio_dir = dataset_meta_info[args.evalset]['audio_dir']-- evaluate(- args.manifest_path,- args.audio_dir,- args.generated_audio_dir,- args.language,- sv_model_type="wavlm",- asr_model_name="nvidia/parakeet-ctc-0.6b",- with_prosody_metrics=args.with_prosody_metrics,- strip_text_annotations_for_metrics=args.strip_text_annotations_for_metrics,- prosody_model_size=args.prosody_model_size,- )---if __name__ == "__main__":- main()nemo/collections/tts/modules/magpietts_inference/evaluation.py13 + / 53 −
@@ -20,51 +20,26 @@ """ from __future__ import annotations -from dataclasses import dataclass from typing import Dict, List, Tuple import numpy as np import scipy.stats as stats # Import the existing evaluation module import nemo.collections.tts.modules.magpietts_inference.evaluate_generated_audio as evaluate_generated_audio+from nemo.collections.tts.modules.magpietts_inference.evaluation_config import (+ EvaluationConfig,+ resolve_evaluation_config_for_dataset,+) from nemo.utils import logging --@dataclass-class EvaluationConfig:- """Configuration for audio quality evaluation.-- Attributes:- sv_model: Speaker verification model type ("titanet" or "wavlm").- asr_model_name: ASR model for transcription (e.g., "nvidia/parakeet-tdt-1.1b").- asr_model_name: ASR model for transcription (e.g., "nvidia/parakeet-tdt-1.1b").- eou_model_name: Hugging Face model id or local path to the EoU model.- language: Language code for transcription (e.g., "en").- with_utmosv2: Whether to compute UTMOSv2 (Mean Opinion Score) metrics.- with_fcd: Whether to compute Frechet Codec Distance metric.- codec_model_path: Path to the audio codec model. If None, will skip computing Frechet Codec Distance metric.- with_prosody_metrics: Whether to compute ESIM/EMS plus pitch,- intensity, and speech-rate distance metrics.- prosody_model_size: Emotion encoder size ("small" or "large").- strip_text_annotations_for_metrics: Whether to strip annotation/control markers from reference and ASR hypothesis text before text metrics.- device: Device to use for running models used during evaluation.- """-- sv_model: str = "titanet"- asr_model_name: str = "nvidia/parakeet-tdt-1.1b"- asr_model_type: str = "nemo"- eou_model_name: str = "facebook/wav2vec2-base-960h"- language: str = "en"- with_utmosv2: bool = True- with_fcd: bool = True- codec_model_path: str = None- with_prosody_metrics: bool = False- prosody_model_size: str = "small"- strip_text_annotations_for_metrics: bool = False- device: str = "cuda"- asr_batch_size: int = 32- eou_batch_size: int = 32+__all__ = [+ "DEFAULT_VIOLIN_METRICS",+ "EvaluationConfig",+ "compute_mean_with_confidence_interval",+ "evaluate_generated_audio_dir",+ "resolve_evaluation_config_for_dataset",+] def evaluate_generated_audio_dir(@@ -94,28 +69,13 @@ def evaluate_generated_audio_dir( """ logging.info(f"Evaluating generated audio from {generated_audio_dir} " f"against manifest {manifest_path}") - avg_metrics, filewise_metrics = evaluate_generated_audio.evaluate(+ return evaluate_generated_audio.evaluate_with_config( manifest_path=manifest_path, audio_dir=audio_dir, generated_audio_dir=generated_audio_dir,- language=config.language,- sv_model_type=config.sv_model,- asr_model_name=config.asr_model_name,- asr_model_type=config.asr_model_type,- with_utmosv2=config.with_utmosv2,- with_fcd=config.with_fcd,- codec_model_path=config.codec_model_path,- with_prosody_metrics=config.with_prosody_metrics,- prosody_model_size=config.prosody_model_size,- strip_text_annotations_for_metrics=config.strip_text_annotations_for_metrics,- device=config.device,- eou_model_name=config.eou_model_name,- asr_batch_size=config.asr_batch_size,- eou_batch_size=config.eou_batch_size,+ config=config, ) - return avg_metrics, filewise_metrics- def compute_mean_with_confidence_interval( metrics_list: List[dict],nemo/collections/tts/modules/magpietts_inference/evaluation_config.pyadded157 + / 0 −
@@ -0,0 +1,157 @@+# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.+# SPDX-License-Identifier: Apache-2.0+#+# 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 configuration for MagpieTTS inference: the ``EvaluationConfig`` dataclass and the helpers that validate and+apply the per-dataset overrides of an evalset config entry.++This module has no heavy dependencies so that both ``evaluation`` and ``evaluate_generated_audio`` can import it+without an import cycle.+"""+from __future__ import annotations++from dataclasses import dataclass, replace+from typing import Optional++# Supported ASR backends: the values of --asr_model_type and of the "type" field of an evalset "asr_model" entry.+ASR_MODEL_TYPES = ("nemo", "nemo_with_prompt", "whisper")+# Keys of an evalset config entry that the inference/evaluation scripts read. Any other key is rejected, so that a+# misspelled override (e.g. "langauge") cannot silently leave the CLI-level value in force; training-only DatasetMeta+# fields such as "feature_dir" or "sample_weight" do not belong in an evaluation config.+EVALSET_ENTRY_KEYS = frozenset(+ {+ "manifest_path",+ "audio_dir",+ "tokenizer_names",+ "language",+ "asr_model",+ }+)+++@dataclass+class EvaluationConfig:+ """Configuration for audio quality evaluation.++ Attributes:+ sv_model: Speaker verification model type ("titanet" or "wavlm").+ asr_model_name: ASR model for transcription (e.g., "nvidia/parakeet-tdt-1.1b").+ asr_model_type: ASR backend for ``asr_model_name``; one of ``ASR_MODEL_TYPES`` ("nemo", "nemo_with_prompt"+ or "whisper").+ eou_model_name: Hugging Face model id or local path to the EoU model.+ language: Language code for transcription (e.g., "en").+ with_utmosv2: Whether to compute UTMOSv2 (Mean Opinion Score) metrics.+ with_fcd: Whether to compute Frechet Codec Distance metric.+ codec_model_path: Path to the audio codec model. Required when ``with_fcd`` is True+ (``evaluate_generated_audio_dir`` raises otherwise); set ``with_fcd=False`` to skip the Frechet Codec+ Distance metric.+ with_prosody_metrics: Whether to compute ESIM/EMS plus pitch,+ intensity, and speech-rate distance metrics.+ prosody_model_size: Emotion encoder size ("small" or "large").+ strip_text_annotations_for_metrics: Whether to strip annotation/control markers from reference and ASR+ hypothesis text before text metrics.+ device: Device to use for running models used during evaluation.+ asr_batch_size: Batch size for ASR transcription.+ eou_batch_size: Batch size for EoU classification.+ """++ sv_model: str = "titanet"+ asr_model_name: str = "nvidia/parakeet-tdt-1.1b"+ asr_model_type: str = "nemo"+ eou_model_name: str = "facebook/wav2vec2-base-960h"+ language: str = "en"+ with_utmosv2: bool = True+ with_fcd: bool = True+ codec_model_path: str = None+ with_prosody_metrics: bool = False+ prosody_model_size: str = "small"+ strip_text_annotations_for_metrics: bool = False+ device: str = "cuda"+ asr_batch_size: int = 32+ eou_batch_size: int = 32+++def validate_evalset_entry(info: dict, dataset_name: Optional[str] = None) -> None:+ """Validate the optional per-dataset evaluation overrides of one evalset config entry.++ Recognized optional keys and their required types:++ - ``language``: non-empty string without surrounding whitespace (remove the key to use the CLI-level language).+ - ``asr_model``: ``{"name": <model name or .nemo path>, "type": <one of ASR_MODEL_TYPES>}``.++ Absent keys are fine. A JSON ``null`` is rejected like any other wrong type, so that a broken override cannot+ silently fall back to the CLI-level value. Used by ``load_evalset_config`` (``evaluate_generated_audio.py``) and+ by ``resolve_evaluation_config_for_dataset``.++ Args:+ info: One entry of the evalset config.+ dataset_name: Dataset name used to prefix error messages, if known.++ Raises:+ ValueError: If a recognized key has a malformed value.+ """+ prefix = f"Dataset {dataset_name}: " if dataset_name is not None else "Evalset entry: "+ if "language" in info:+ value = info["language"]+ if not isinstance(value, str) or not value or value != value.strip():+ raise ValueError(+ f"{prefix}'language' must be a non-empty string without surrounding whitespace, such as \"en\" "+ f"(remove the key to use the CLI-level language), got {value!r}."+ )+ if "asr_model" in info:+ value = info["asr_model"]+ if (+ not isinstance(value, dict)+ or not isinstance(value.get("name"), str)+ or not value["name"].strip()+ or value.get("type") not in ASR_MODEL_TYPES+ ):+ types = ", ".join(ASR_MODEL_TYPES)+ raise ValueError(+ f"{prefix}'asr_model' must be an object "+ f"{{\"name\": <model name or .nemo path>, \"type\": <{types}>}}, got {value!r}."+ )+++def resolve_evaluation_config_for_dataset(eval_config: EvaluationConfig, dataset_meta: dict) -> EvaluationConfig:+ """Return a copy of ``eval_config`` with the per-dataset overrides of one evalset config entry applied.++ Recognized optional keys of an evalset config entry:++ - ``asr_model``: ``{"name": ..., "type": ...}`` overriding ``asr_model_name`` and ``asr_model_type``.+ - ``language``: overrides ``language``.++ Keys that are absent keep the value from ``eval_config``. ``eval_config`` itself is not mutated.++ Args:+ eval_config: CLI-level evaluation configuration.+ dataset_meta: One entry of the evalset config (see ``load_evalset_config``).++ Returns:+ A new ``EvaluationConfig`` with the overrides applied.++ Raises:+ ValueError: If a recognized key is malformed (see ``validate_evalset_entry``):+ ``language`` is not a non-empty string or ``asr_model`` lacks a valid+ ``name``/``type``. JSON ``null`` counts as malformed, so a broken override never silently falls back to+ the CLI-level value.+ """+ validate_evalset_entry(dataset_meta)+ overrides = {}+ if "asr_model" in dataset_meta:+ overrides["asr_model_name"] = dataset_meta["asr_model"]["name"]+ overrides["asr_model_type"] = dataset_meta["asr_model"]["type"]+ if "language" in dataset_meta:+ overrides["language"] = dataset_meta["language"]+ return replace(eval_config, **overrides)nemo/collections/tts/modules/magpietts_inference/utils.py52 + / 37 −
@@ -40,6 +40,7 @@ from nemo.collections.tts.models import EasyMagpieTTSInferenceModel, MagpieTTSModel from nemo.collections.tts.models.easy_magpietts_inference import EasyModelInferenceParameters from nemo.collections.tts.models.magpietts import ModelInferenceParameters+from nemo.collections.tts.modules.magpietts_inference.evaluation_config import ASR_MODEL_TYPES from nemo.collections.tts.modules.magpietts_inference.inference import ( BaseInferenceRunner, EasyMagpieInferenceConfig,@@ -619,49 +620,63 @@ def parse_layer_list(layer_str: Optional[str]) -> Optional[List[int]]: return [int(layer.strip()) for layer in layer_str.split(",")] +# Metric columns of all_experiment_metrics.csv / all_experiment_metrics_with_ci.csv after "checkpoint_name,dataset".+# The header and append_metrics_to_csv are both derived from this tuple so they cannot drift apart. Append new+# metrics at the END so that rows appended to a pre-existing CSV keep their earlier columns aligned+# (write_csv_header_if_needed warns when the existing header differs).+EXPERIMENT_METRICS_CSV_COLUMNS = (+ "cer_filewise_avg",+ "wer_filewise_avg",+ "cer_cumulative",+ "wer_cumulative",+ "cer_pred_gt_audio_filewise_avg",+ "cer_pred_gt_audio_cumulative",+ "wer_pred_gt_audio_filewise_avg",+ "wer_pred_gt_audio_cumulative",+ "ssim_pred_gt_avg",+ "ssim_pred_context_avg",+ "ssim_gt_context_avg",+ "ssim_pred_gt_avg_alternate",+ "ssim_pred_context_avg_alternate",+ "ssim_gt_context_avg_alternate",+ "esim_pred_gt_avg",+ "ems_pred_gt_avg",+ "pitch_distance_avg",+ "intensity_distance_avg",+ "speech_rate_distance_avg",+ "cer_gt_audio_cumulative",+ "wer_gt_audio_cumulative",+ "utmosv2_avg",+ "total_gen_audio_seconds",+ "frechet_codec_distance",+ "eou_cutoff_rate",+ "eou_silence_rate",+ "eou_noise_rate",+ "eou_error_rate",+ "katakana_cer_filewise_avg",+ "katakana_cer_cumulative",+)+EXPERIMENT_METRICS_CSV_HEADER = ",".join(("checkpoint_name", "dataset") + EXPERIMENT_METRICS_CSV_COLUMNS)++ def write_csv_header_if_needed(csv_path: str, header: str) -> None:- """Write CSV header if file doesn't exist."""+ """Write the CSV header if the file does not exist; warn if an existing file was written with another header.""" if not os.path.exists(csv_path): with open(csv_path, "w") as f: f.write(header + "\n")+ return+ with open(csv_path) as f:+ existing_header = f.readline().rstrip("\n")+ if existing_header != header:+ logging.warning(+ f"{csv_path} was written with a different column layout; rows appended from now on follow the current "+ f"header. Existing header: {existing_header}. Current header: {header}."+ ) def append_metrics_to_csv(csv_path: str, checkpoint_name: str, dataset: str, metrics: dict) -> None:- """Append metrics to a CSV file."""- values = [- checkpoint_name,- dataset,- metrics.get('cer_filewise_avg', ''),- metrics.get('wer_filewise_avg', ''),- metrics.get('cer_cumulative', ''),- metrics.get('wer_cumulative', ''),- metrics.get('cer_pred_gt_audio_filewise_avg', ''),- metrics.get('cer_pred_gt_audio_cumulative', ''),- metrics.get('wer_pred_gt_audio_filewise_avg', ''),- metrics.get('wer_pred_gt_audio_cumulative', ''),- metrics.get('ssim_pred_gt_avg', ''),- metrics.get('ssim_pred_context_avg', ''),- metrics.get('ssim_gt_context_avg', ''),- metrics.get('ssim_pred_gt_avg_alternate', ''),- metrics.get('ssim_pred_context_avg_alternate', ''),- metrics.get('ssim_gt_context_avg_alternate', ''),- metrics.get('esim_pred_gt_avg', ''),- metrics.get('ems_pred_gt_avg', ''),- metrics.get('pitch_distance_avg', ''),- metrics.get('intensity_distance_avg', ''),- metrics.get('speech_rate_distance_avg', ''),- metrics.get('cer_gt_audio_cumulative', ''),- metrics.get('wer_gt_audio_cumulative', ''),- metrics.get('utmosv2_avg', ''),- metrics.get('total_gen_audio_seconds', ''),- metrics.get('frechet_codec_distance', ''),- metrics.get('eou_cutoff_rate', ''),- metrics.get('eou_silence_rate', ''),- metrics.get('eou_noise_rate', ''),- metrics.get('eou_error_rate', ''),- metrics.get('katakana_cer_filewise_avg', ''),- metrics.get('katakana_cer_cumulative', ''),- ]+ """Append one row to a CSV file whose header is ``EXPERIMENT_METRICS_CSV_HEADER``."""+ values = [checkpoint_name, dataset] + [metrics.get(column, '') for column in EXPERIMENT_METRICS_CSV_COLUMNS] with open(csv_path, "a") as f: f.write(",".join(str(v) for v in values) + "\n") logging.info(f"Metrics appended to: {csv_path}")@@ -1254,7 +1269,7 @@ def _add_common_args(parser: argparse.ArgumentParser) -> None: '--asr_model_type', type=str, default='nemo',- choices=['nemo', 'nemo_with_prompt', 'whisper'],+ choices=list(ASR_MODEL_TYPES), help="Type of ASR model provided in 'asr_model_name'", ) eval_group.add_argument(tests/collections/tts/modules/magpietts_inference/test_magpietts_inference.py240 + / 1 −
@@ -22,16 +22,30 @@ import os import pytest- from examples.tts.magpietts_inference import main as magpietts_inference_main+from examples.tts.magpietts_inference import run_inference_and_evaluation+ from nemo.collections.tts.modules.magpietts_inference.evaluate_generated_audio import ( FILEWISE_METRICS_TO_SAVE, _get_record_texts,+ load_evalset_config,+)+from nemo.collections.tts.modules.magpietts_inference.evaluation import (+ EvaluationConfig,+ evaluate_generated_audio_dir,+ resolve_evaluation_config_for_dataset, ) from nemo.collections.tts.modules.magpietts_inference.utils import (+ EXPERIMENT_METRICS_CSV_HEADER, _group_multiturn_filewise_metrics_by_sample, _write_grouped_multiturn_filewise_metrics_csv,+ append_metrics_to_csv,+ write_csv_header_if_needed, )+from nemo.utils import logging as nemo_logging++EVALUATE_MODULE = "nemo.collections.tts.modules.magpietts_inference.evaluate_generated_audio"+EXAMPLE_MODULE = "examples.tts.magpietts_inference" class TestMagpieTTSInferenceCLI:@@ -116,6 +130,9 @@ def test_evaluation_uses_normalized_text_for_metrics(): assert metric_reference_text == "july fifteenth" assert "tts_text_input" in FILEWISE_METRICS_TO_SAVE assert "dataloader_normalized_text" in FILEWISE_METRICS_TO_SAVE+ # Legacy phonemized manifests keep the orthography in original_text; a JSON null counts as absent.+ assert _get_record_texts({"text": "dʒʊˈlaɪ", "original_text": "July"})[2] == "July"+ assert _get_record_texts({"text": "July 15th", "original_text": None})[2] == "July 15th" @pytest.mark.unit@@ -143,3 +160,225 @@ def test_grouped_multiturn_exports_input_and_normalized_text(tmp_path): assert json.loads(csv_row["tts_text_input"]) == ["July 15th"] assert json.loads(csv_row["dataloader_normalized_text"]) == ["july fifteenth"]+++@pytest.mark.unit+def test_resolve_evaluation_config_for_dataset_overrides():+ eval_config = EvaluationConfig(language="en", eou_batch_size=7)+ meta = {+ "manifest_path": "m.json",+ "audio_dir": "a",+ "language": "de",+ "asr_model": {"name": "some/asr", "type": "whisper"},+ }++ resolved = resolve_evaluation_config_for_dataset(eval_config, meta)++ assert resolved.language == "de"+ assert (resolved.asr_model_name, resolved.asr_model_type) == ("some/asr", "whisper")+ assert resolved.eou_batch_size == 7 # fields without an override are inherited+ assert eval_config.language == "en" # the input is not mutated+ # Absent keys keep the CLI-level values.+ assert resolve_evaluation_config_for_dataset(eval_config, {"manifest_path": "m.json"}) == eval_config+++def _filewise_row(gt_text, pred_text, cer, wer):+ nan = float("nan")+ return {+ "gt_text": gt_text,+ "pred_text": pred_text,+ "gt_audio_text": None,+ "cer": cer,+ "wer": wer,+ "cer_pred_gt_audio": nan,+ "wer_pred_gt_audio": nan,+ "pred_gt_ssim": 0.5,+ "pred_context_ssim": 0.5,+ "gt_context_ssim": 0.5,+ "pred_gt_ssim_alternate": 0.5,+ "pred_context_ssim_alternate": 0.5,+ "gt_context_ssim_alternate": 0.5,+ "utmosv2": 3.0,+ "total_gen_audio_seconds": 1.0,+ }+++def _write_evalset_config(tmp_path, entry_overrides):+ (tmp_path / "audio").mkdir(exist_ok=True)+ (tmp_path / "m.json").write_text("{}\n")+ entry = {"manifest_path": "m.json", "audio_dir": "audio", **entry_overrides}+ config_path = tmp_path / "evalset.json"+ config_path.write_text(json.dumps({"ds": entry}))+ return config_path+++@pytest.mark.unit+def test_evaluate_generated_audio_dir_forwards_the_whole_config(monkeypatch):+ captured = {}++ def fake_evaluate_dir(**kwargs):+ captured.update(kwargs)+ return [_filewise_row("you want", "you want", 0.0, 0.0)]++ def fake_compute_global_metrics(**kwargs):+ captured["global_kwargs"] = kwargs+ return {}++ # Stub below evaluate() so that its real wiring, including the FCD guard, runs.+ monkeypatch.setattr(f"{EVALUATE_MODULE}.evaluate_dir", fake_evaluate_dir)+ monkeypatch.setattr(f"{EVALUATE_MODULE}.compute_global_metrics", fake_compute_global_metrics)+ config = EvaluationConfig(+ sv_model="wavlm",+ asr_model_name="some/asr",+ asr_model_type="whisper",+ language="de",+ with_fcd=False,+ with_prosody_metrics=True,+ asr_batch_size=4,+ )++ evaluate_generated_audio_dir("m.json", "audio", "generated", config)++ assert (captured["manifest_path"], captured["audio_dir"], captured["generated_audio_dir"]) == (+ "m.json",+ "audio",+ "generated",+ )+ assert (captured["language"], captured["sv_model_type"]) == ("de", "wavlm")+ assert (captured["asr_model_name"], captured["asr_model_type"]) == ("some/asr", "whisper")+ assert captured["with_prosody_metrics"] is True and captured["asr_batch_size"] == 4+ # Every field is forwarded, not a hand-picked subset: evaluate()'s own default for eou_model_name is None.+ assert captured["eou_model_name"] == config.eou_model_name+ assert (+ captured["global_kwargs"]["codec_model_path"] is None and captured["global_kwargs"]["gt_audio_paths"] is None+ )++ # FCD needs a codec model; the wrapper does not hide evaluate()'s guard.+ with pytest.raises(ValueError, match="codec_model_path is required"):+ evaluate_generated_audio_dir("m.json", "audio", "generated", EvaluationConfig())+++@pytest.mark.unit+@pytest.mark.parametrize(+ "entry_overrides, match",+ [+ ({"language": None}, "'language' must be a non-empty string"),+ ({"language": ""}, "'language' must be a non-empty string"),+ ({"language": " en"}, "'language' must be a non-empty string"),+ ({"asr_model": None}, "'asr_model' must be an object"),+ ({"asr_model": {"name": "some/asr"}}, "'asr_model' must be an object"),+ ({"asr_model": {"name": "some/asr", "type": "hf"}}, "'asr_model' must be an object"),+ ({"asr_model": {"name": " ", "type": "nemo"}}, "'asr_model' must be an object"),+ ],+)+def test_malformed_evalset_overrides_are_rejected(tmp_path, entry_overrides, match):+ # Both entry points reject the same malformed values: the resolver (hand-built meta) and the config loader,+ # which prefixes the dataset name.+ with pytest.raises(ValueError, match=match):+ resolve_evaluation_config_for_dataset(EvaluationConfig(), {"manifest_path": "m.json", **entry_overrides})+ config_path = _write_evalset_config(tmp_path, entry_overrides)+ with pytest.raises(ValueError, match=f"Dataset ds: .*{match}"):+ load_evalset_config(str(config_path), dataset_base_path=tmp_path)+++@pytest.mark.unit+def test_load_evalset_config_rejects_unrecognized_keys(tmp_path):+ # Only the keys the inference/evaluation scripts read are accepted (as in examples/tts/evalset_config.json).+ config_path = _write_evalset_config(tmp_path, {"tokenizer_names": ["english_phoneme"], "language": "en"})+ assert "ds" in load_evalset_config(str(config_path), dataset_base_path=tmp_path)++ # A misspelled override would silently leave the CLI-level value in force; it is rejected instead, and so are+ # training-only DatasetMeta fields copied from a training config.+ for entry, key in (({"langauge": "de"}, "langauge"), ({"feature_dir": None}, "feature_dir")):+ config_path = _write_evalset_config(tmp_path, entry)+ with pytest.raises(ValueError, match=rf"Dataset ds: unrecognized evalset keys \['{key}'\]; recognized keys"):+ load_evalset_config(str(config_path), dataset_base_path=tmp_path)+++@pytest.mark.unit+def test_experiment_metrics_csv_header_and_rows_stay_aligned(tmp_path, monkeypatch):+ warnings_seen = []+ monkeypatch.setattr(nemo_logging, "warning", lambda msg, *args, **kwargs: warnings_seen.append(msg))+ csv_path = tmp_path / "all_experiment_metrics.csv"++ write_csv_header_if_needed(str(csv_path), EXPERIMENT_METRICS_CSV_HEADER)+ write_csv_header_if_needed(str(csv_path), EXPERIMENT_METRICS_CSV_HEADER) # matching header: nothing to report+ append_metrics_to_csv(str(csv_path), "ckpt", "ds", {"cer_filewise_avg": 0.1, "katakana_cer_cumulative": 0.2})++ with open(csv_path) as f:+ rows = list(csv.DictReader(f))++ assert len(rows) == 1 and warnings_seen == []+ assert (rows[0]["checkpoint_name"], rows[0]["dataset"]) == ("ckpt", "ds")+ assert rows[0]["cer_filewise_avg"] == "0.1"+ assert rows[0]["katakana_cer_cumulative"] == "0.2"+ assert rows[0]["wer_filewise_avg"] == "" # absent metrics leave an empty cell+ assert None not in rows[0] # every value has a header column+ assert len(rows[0]) == len(EXPERIMENT_METRICS_CSV_HEADER.split(","))++ # A CSV written before a column was appended keeps its header; the changed layout is reported.+ old_header, dropped_column = EXPERIMENT_METRICS_CSV_HEADER.rsplit(",", 1)+ csv_path.write_text(old_header + "\n")+ write_csv_header_if_needed(str(csv_path), EXPERIMENT_METRICS_CSV_HEADER)+ assert len(warnings_seen) == 1 and dropped_column in warnings_seen[0]+ assert csv_path.read_text() == old_header + "\n"+++class _FakeRunner:+ def create_dataset(self, dataset_meta):+ return [0]++ def run_inference_on_dataset(self, **kwargs):+ return [{"rtf": 1.0}], None, []++ def compute_mean_rtf_metrics(self, rtf_metrics_list):+ return {}+++class _FakeInferenceConfig:+ def build_identifier(self):+ return "_id"+++@pytest.mark.unit+def test_run_inference_and_evaluation_applies_evalset_override(tmp_path, monkeypatch):+ # The example script used to rebuild EvaluationConfig by hand from the evalset entry; it now goes through+ # resolve_evaluation_config_for_dataset, so overrides and inherited CLI-level fields are handled in one place.+ manifest = tmp_path / "m.json"+ manifest.write_text(json.dumps({"audio_filepath": "a.wav", "text": "Hello there."}) + "\n")+ captured = {}++ def fake_evaluate_generated_audio_dir(manifest_path, audio_dir, generated_audio_dir, config):+ captured["config"] = config+ return {"cer_cumulative": 0.0, "ssim_pred_context_avg": 1.0}, [{"cer": 0.0}]++ monkeypatch.setattr(f"{EXAMPLE_MODULE}.evaluate_generated_audio_dir", fake_evaluate_generated_audio_dir)+ for name in ("create_violin_plot", "append_metrics_to_csv", "write_csv_header_if_needed"):+ monkeypatch.setattr(f"{EXAMPLE_MODULE}.{name}", lambda *args, **kwargs: None)++ eval_config = EvaluationConfig(language="en", with_fcd=False, with_utmosv2=False)+ dataset_meta_info = {+ "ds": {+ "manifest_path": str(manifest),+ "audio_dir": str(tmp_path),+ "language": "de",+ "asr_model": {"name": "some/asr", "type": "whisper"},+ }+ }++ cer, ssim = run_inference_and_evaluation(+ runner=_FakeRunner(),+ checkpoint_name="ckpt",+ inference_config=_FakeInferenceConfig(),+ eval_config=eval_config,+ dataset_meta_info=dataset_meta_info,+ datasets=["ds"],+ out_dir=str(tmp_path / "out"),+ flops_per_component={},+ moe_info="",+ )++ assert captured["config"].language == "de"+ assert (captured["config"].asr_model_name, captured["config"].asr_model_type) == ("some/asr", "whisper")+ assert captured["config"].with_utmosv2 is False # CLI-level settings without an override are inherited+ assert (cer, ssim) == (0.0, 1.0)