Skip to content

Commit 5435e1d

Browse files
authored
SALM Backend Support and HF ASR Leaderboard Evaluation Recipe (#1344)
Signed-off-by: mmkrtchyan <mmkrtchyan@nvidia.com>
1 parent b306617 commit 5435e1d

9 files changed

Lines changed: 652 additions & 64 deletions

File tree

nemo_skills/dataset/asr-leaderboard/prepare.py

Lines changed: 57 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -16,121 +16,122 @@
1616
1717
Downloads and formats datasets from the official HF Open ASR Leaderboard ESB
1818
test-only sorted dataset (hf-audio/esb-datasets-test-only-sorted). This is the
19-
same data source used by the official leaderboard and the offline NeMo eval
20-
pipeline, ensuring apples-to-apples WER comparison.
19+
same data source used by the official leaderboard, ensuring apples-to-apples
20+
WER comparison.
2121
2222
Audio paths in JSONL: /dataset/asr-leaderboard/data/{dataset}/{sample_id}.flac
2323
2424
Usage:
2525
ns prepare_data asr-leaderboard
2626
ns prepare_data asr-leaderboard --datasets librispeech_clean ami
27-
ns prepare_data asr-leaderboard --datasets earnings22
28-
ns prepare_data asr-leaderboard --no-audio # skip saving audio files
27+
ns prepare_data asr-leaderboard --no-audio
2928
"""
3029

3130
import argparse
3231
import json
3332
from pathlib import Path
3433

34+
import numpy as np
3535
import soundfile as sf
36-
from datasets import load_dataset
36+
from datasets import Audio, load_dataset
3737
from tqdm import tqdm
3838

39+
HF_REPO = "hf-audio/esb-datasets-test-only-sorted"
3940
SYSTEM_MESSAGE = "You are a helpful assistant. /no_think"
40-
MIN_AUDIO_DURATION = 0.1 # Skip audio shorter than this (causes mel spectrogram errors)
41+
AUDIO_SAMPLE_RATE = 16000
4142

42-
# (hf_repo, config, split, text_field, id_field)
43+
# (config, split, text_field, id_field)
4344
DATASET_CONFIGS = {
44-
"librispeech_clean": ("hf-audio/esb-datasets-test-only-sorted", "librispeech", "test.clean", "text", "id"),
45-
"librispeech_other": ("hf-audio/esb-datasets-test-only-sorted", "librispeech", "test.other", "text", "id"),
46-
"voxpopuli": ("hf-audio/esb-datasets-test-only-sorted", "voxpopuli", "test", "text", "id"),
47-
"tedlium": ("hf-audio/esb-datasets-test-only-sorted", "tedlium", "test", "text", "id"),
48-
"gigaspeech": ("hf-audio/esb-datasets-test-only-sorted", "gigaspeech", "test", "text", "id"),
49-
"spgispeech": ("hf-audio/esb-datasets-test-only-sorted", "spgispeech", "test", "text", "id"),
50-
"earnings22": ("hf-audio/esb-datasets-test-only-sorted", "earnings22", "test", "text", "id"),
51-
"ami": ("hf-audio/esb-datasets-test-only-sorted", "ami", "test", "text", "id"),
45+
"librispeech_clean": ("librispeech", "test.clean", "text", "id"),
46+
"librispeech_other": ("librispeech", "test.other", "text", "id"),
47+
"voxpopuli": ("voxpopuli", "test", "text", "id"),
48+
"tedlium": ("tedlium", "test", "text", "id"),
49+
"gigaspeech": ("gigaspeech", "test", "text", "id"),
50+
"spgispeech": ("spgispeech", "test", "text", "id"),
51+
"earnings22": ("earnings22", "test", "text", "id"),
52+
"ami": ("ami", "test", "text", "id"),
5253
}
5354

5455

55-
def save_audio_and_format_entry(
56-
entry, dataset_name, audio_dir, sample_idx, text_field="text", id_field="id", with_audio=True
57-
):
58-
"""Format a dataset entry and optionally save audio file."""
59-
text = entry[text_field].strip()
56+
def extract_audio(audio_info):
57+
"""Extract audio array and sampling rate from a HF dataset audio entry.
6058
61-
system_message = {"role": "system", "content": SYSTEM_MESSAGE}
62-
user_message = {"role": "user", "content": "Transcribe the following audio."}
59+
Handles both the legacy dict format ({"array": ..., "sampling_rate": ...})
60+
and the newer AudioDecoder object from torchcodec-based datasets library.
61+
"""
62+
if audio_info is None:
63+
return None, None
64+
try:
65+
audio_array = np.array(audio_info["array"])
66+
sampling_rate = int(audio_info["sampling_rate"])
67+
return audio_array, sampling_rate
68+
except (KeyError, TypeError, IndexError):
69+
return None, None
70+
71+
72+
def format_entry(entry, dataset_name, audio_dir, text_field, id_field, with_audio):
73+
"""Format a dataset entry into JSONL and optionally save the audio file."""
74+
text = entry[text_field].strip()
75+
if not text:
76+
return None
6377

6478
sample_id = str(entry[id_field]).replace("/", "_")
6579
audio_filename = f"{Path(sample_id).stem}.flac"
6680

67-
audio_info = entry.get("audio", {})
81+
audio_array, sampling_rate = extract_audio(entry.get("audio"))
6882
duration = None
69-
if isinstance(audio_info, dict) and "array" in audio_info and "sampling_rate" in audio_info:
70-
audio_array = audio_info["array"]
71-
sampling_rate = audio_info["sampling_rate"]
72-
duration = len(audio_array) / sampling_rate
73-
74-
if duration < MIN_AUDIO_DURATION:
75-
return None
7683

84+
if audio_array is not None and sampling_rate is not None:
85+
duration = len(audio_array) / sampling_rate
7786
if with_audio:
7887
sf.write(str(audio_dir / audio_filename), audio_array, sampling_rate)
7988

89+
user_message = {"role": "user", "content": "Transcribe the following audio."}
8090
audio_meta = {"path": f"/dataset/asr-leaderboard/data/{dataset_name}/{audio_filename}"}
8191
if duration is not None:
8292
audio_meta["duration"] = float(duration)
8393
user_message["audio"] = audio_meta
8494

85-
formatted_entry = {
95+
formatted = {
8696
"task_type": "ASR",
8797
"expected_answer": text,
88-
"messages": [system_message, user_message],
98+
"messages": [{"role": "system", "content": SYSTEM_MESSAGE}, user_message],
8999
"subset_for_metrics": dataset_name,
100+
"id": entry[id_field],
90101
}
91-
92-
formatted_entry["id"] = entry[id_field]
93102
if "speaker_id" in entry:
94-
formatted_entry["speaker_id"] = entry["speaker_id"]
103+
formatted["speaker_id"] = entry["speaker_id"]
95104

96-
return formatted_entry
105+
return formatted
97106

98107

99108
def prepare_dataset(dataset_name, output_dir, with_audio=True):
100-
"""Prepare a single ASR dataset."""
109+
"""Download, decode, and write a single ASR dataset to JSONL + audio files."""
101110
if dataset_name not in DATASET_CONFIGS:
102111
raise ValueError(f"Unknown dataset: {dataset_name}. Available: {list(DATASET_CONFIGS.keys())}")
103112

104-
hf_repo, hf_config, hf_split, text_field, id_field = DATASET_CONFIGS[dataset_name]
113+
hf_config, hf_split, text_field, id_field = DATASET_CONFIGS[dataset_name]
105114

106-
print(f"Loading {dataset_name} from {hf_repo} (config={hf_config}, split={hf_split})...")
107-
dataset = load_dataset(hf_repo, hf_config, split=hf_split, trust_remote_code=True)
115+
print(f"Loading {dataset_name} from {HF_REPO} (config={hf_config}, split={hf_split})...")
116+
dataset = load_dataset(HF_REPO, hf_config, split=hf_split)
117+
if with_audio and "audio" in dataset.column_names:
118+
dataset = dataset.cast_column("audio", Audio(sampling_rate=AUDIO_SAMPLE_RATE))
108119

109120
output_file = output_dir / f"{dataset_name}.jsonl"
110121
audio_dir = output_dir / "data" / dataset_name
111122

112123
if with_audio:
113124
audio_dir.mkdir(parents=True, exist_ok=True)
114-
print(f"Saving audio files to {audio_dir}")
115125

116126
print(f"Processing {len(dataset)} samples from {dataset_name}...")
117-
118127
count = 0
119-
skipped = 0
120128
with open(output_file, "w", encoding="utf-8") as fout:
121-
for idx, entry in enumerate(tqdm(dataset, desc=dataset_name)):
122-
formatted = save_audio_and_format_entry(
123-
entry, dataset_name, audio_dir, idx, text_field=text_field, id_field=id_field, with_audio=with_audio
124-
)
129+
for entry in tqdm(dataset, desc=dataset_name):
130+
formatted = format_entry(entry, dataset_name, audio_dir, text_field, id_field, with_audio)
125131
if formatted is None:
126-
skipped += 1
127132
continue
128-
if formatted["expected_answer"]:
129-
fout.write(json.dumps(formatted) + "\n")
130-
count += 1
131-
132-
if skipped > 0:
133-
print(f"Skipped {skipped} samples with audio < {MIN_AUDIO_DURATION}s")
133+
fout.write(json.dumps(formatted) + "\n")
134+
count += 1
134135

135136
print(f"Saved {count} samples to {output_file}")
136137
return count
@@ -157,25 +158,19 @@ def main():
157158
output_dir.mkdir(parents=True, exist_ok=True)
158159

159160
with_audio = not args.no_audio
160-
161-
if args.no_audio:
161+
if not with_audio:
162162
print("Running without saving audio files.")
163-
else:
164-
print("Running with audio. Saving to data/{dataset}/")
165163

166164
datasets_to_prepare = list(DATASET_CONFIGS.keys()) if "all" in args.datasets else args.datasets
167165

168166
total_samples = 0
169167
for dataset_name in datasets_to_prepare:
170168
total_samples += prepare_dataset(dataset_name, output_dir, with_audio=with_audio)
171169

172-
# Combine all dataset JSONLs into test.jsonl
173170
combined_file = output_dir / "test.jsonl"
174171
print(f"\nCreating combined file: {combined_file}")
175172

176-
all_jsonl_files = sorted(output_dir.glob("*.jsonl"))
177-
dataset_files = [f for f in all_jsonl_files if f.name != "test.jsonl"]
178-
173+
dataset_files = sorted(f for f in output_dir.glob("*.jsonl") if f.name != "test.jsonl")
179174
combined_count = 0
180175
with open(combined_file, "w", encoding="utf-8") as fout:
181176
for dataset_file in dataset_files:

recipes/asr/run_hf_leaderboard.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""HuggingFace Open ASR Leaderboard evaluation for NeMo ASR models.
16+
17+
Runs the HF Open ASR Leaderboard benchmark (8 datasets, WER metric) on
18+
NeMo ASR models using the unified server with the nemo_asr or salm backend.
19+
20+
Uses 1 GPU for the server (NeMo ASR model). The evaluation client runs
21+
on CPU alongside it.
22+
23+
Example usage::
24+
25+
# Evaluate parakeet-v3 (traditional ASR model -> nemo_asr backend)
26+
python recipes/asr/run_hf_leaderboard.py \\
27+
--model nvidia/parakeet-tdt-0.6b-v3 \\
28+
--cluster oci_iad \\
29+
--output_dir /lustre/.../parakeet-v3-asr-leaderboard
30+
31+
# Evaluate canary-qwen-2.5b (SALM model -> salm backend)
32+
python recipes/asr/run_hf_leaderboard.py \\
33+
--model nvidia/canary-qwen-2.5b \\
34+
--backend salm \\
35+
--cluster oci_iad \\
36+
--output_dir /lustre/.../canary-qwen-asr-leaderboard
37+
"""
38+
39+
import argparse
40+
41+
from nemo_skills.pipeline.cli import eval as run_eval
42+
from nemo_skills.pipeline.cli import wrap_arguments
43+
44+
DEFAULT_SERVER_CONTAINER = "nvcr.io/nvidia/nemo:25.11"
45+
DEFAULT_INSTALLATION_COMMAND = "pip install -r requirements/audio.txt"
46+
47+
48+
def main():
49+
parser = argparse.ArgumentParser(description="Run HF Open ASR Leaderboard evaluation on a NeMo ASR model")
50+
parser.add_argument("--model", required=True, help="NeMo ASR model name or path (e.g. nvidia/canary-qwen-2.5b)")
51+
parser.add_argument("--cluster", required=True, help="Cluster name (e.g. oci_iad)")
52+
parser.add_argument("--output_dir", required=True, help="Directory for evaluation outputs")
53+
parser.add_argument("--data_dir", default="/dataset", help="Root data directory (must contain asr-leaderboard/)")
54+
parser.add_argument("--server_container", default=DEFAULT_SERVER_CONTAINER, help="NeMo container image")
55+
parser.add_argument("--server_gpus", type=int, default=1, help="Number of GPUs for the ASR server")
56+
parser.add_argument(
57+
"--backend",
58+
default="nemo_asr",
59+
choices=["nemo_asr", "salm"],
60+
help="Server backend: nemo_asr for traditional ASR models (parakeet), salm for SALM models (canary-qwen)",
61+
)
62+
parser.add_argument("--batch_size", type=int, default=16, help="NeMo ASR transcription batch size")
63+
parser.add_argument(
64+
"--num_chunks", type=int, default=None, help="Split dataset into N chunks for data parallelism"
65+
)
66+
parser.add_argument("--expname", default="asr-leaderboard", help="Experiment name")
67+
parser.add_argument("--partition", default=None, help="Slurm partition (e.g. interactive)")
68+
parser.add_argument("--config_dir", default=None, help="Directory containing cluster config YAMLs")
69+
parser.add_argument("--split", default=None, help="Dataset split to evaluate (default: test = all datasets)")
70+
71+
args = parser.parse_args()
72+
73+
run_eval(
74+
ctx=wrap_arguments(
75+
"++prompt_format=openai "
76+
"++prompt_config=null "
77+
"++enable_audio=true "
78+
"++server.server_type=vllm_multimodal "
79+
"++max_concurrent_requests=16 "
80+
"++inference.tokens_to_generate=256"
81+
),
82+
cluster=args.cluster,
83+
output_dir=args.output_dir,
84+
benchmarks="asr-leaderboard",
85+
model=args.model,
86+
server_type="generic",
87+
server_gpus=args.server_gpus,
88+
server_entrypoint=(
89+
"MKL_SERVICE_FORCE_INTEL=1 MKL_THREADING_LAYER=GNU "
90+
"MKL_NUM_THREADS=1 VML_NUM_THREADS=1 "
91+
"python -m nemo_skills.inference.server.serve_unified"
92+
),
93+
server_args=f"--backend {args.backend} --batch_size {args.batch_size}",
94+
server_container=args.server_container,
95+
num_chunks=args.num_chunks,
96+
data_dir=args.data_dir,
97+
config_dir=args.config_dir,
98+
partition=args.partition,
99+
split=args.split,
100+
installation_command=DEFAULT_INSTALLATION_COMMAND,
101+
expname=args.expname,
102+
)
103+
104+
105+
if __name__ == "__main__":
106+
main()

recipes/multimodal/server/backends/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
Available backends:
1919
- magpie_tts: MagpieTTS text-to-speech (audio output from text input)
2020
- nemo_asr: NeMo ASR speech-to-text (text output from audio input)
21+
- salm: SALM speech-to-text using chat-style generate API (e.g. canary-qwen-2.5b)
2122
2223
Backends are lazily loaded to avoid importing heavy dependencies upfront.
2324
"""
@@ -40,6 +41,7 @@
4041
BACKEND_REGISTRY = {
4142
"magpie_tts": ("magpie_tts_backend", "MagpieTTSBackend"),
4243
"nemo_asr": ("nemo_asr_backend", "NeMoASRBackend"),
44+
"salm": ("salm_backend", "SALMBackend"),
4345
}
4446

4547

recipes/multimodal/server/backends/nemo_asr_backend.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,13 @@ def _parse_single_hypothesis(self, hyp: Any) -> tuple[str, List[Dict[str, Any]]]
217217
return hyp, []
218218

219219
if isinstance(hyp, dict):
220-
text = hyp.get("text") or hyp.get("pred_text") or hyp.get("transcript") or ""
220+
text = hyp.get("text")
221+
if text is None:
222+
text = hyp.get("pred_text")
223+
if text is None:
224+
text = hyp.get("transcript")
225+
if text is None:
226+
text = ""
221227
words = hyp.get("words")
222228
if words is None:
223229
ts = hyp.get("timestamp")
@@ -229,7 +235,11 @@ def _parse_single_hypothesis(self, hyp: Any) -> tuple[str, List[Dict[str, Any]]]
229235
words = ts["word"]
230236
return text, self._normalize_words(words)
231237

232-
text = getattr(hyp, "text", None) or getattr(hyp, "pred_text", None) or str(hyp)
238+
text = getattr(hyp, "text", None)
239+
if text is None:
240+
text = getattr(hyp, "pred_text", None)
241+
if text is None:
242+
text = ""
233243
words = getattr(hyp, "words", None)
234244
if words is None:
235245
ts = getattr(hyp, "timestamp", None)

0 commit comments

Comments
 (0)