nvidia-nemo--speech
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
416 行
15 KiB
Python
416 行
15 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 re
|
|
|
|
import pytest
|
|
import torch
|
|
from lhotse import CutSet, SupervisionSegment, compute_num_frames
|
|
from lhotse.dataset.collation import collate_audio, collate_vectors
|
|
from lhotse.testing.dummies import dummy_cut, dummy_recording
|
|
|
|
from nemo.collections.common.tokenizers import AutoTokenizer
|
|
from nemo.collections.speechlm2.data.duplex_stt_dataset import (
|
|
DuplexSTTDataset,
|
|
collate_system_prompt,
|
|
collate_token_channel,
|
|
)
|
|
from nemo.collections.speechlm2.data.utils import get_pad_id
|
|
|
|
SR = 16000
|
|
FL = 0.08
|
|
|
|
|
|
def _clean_text(text):
|
|
"""Strip timestamp tokens and normalize whitespace, matching _text_to_ids(remove_timestamps=True)."""
|
|
text = re.sub(r'<\|\d+\|>', '', text)
|
|
return ' '.join(text.strip().split())
|
|
|
|
|
|
def _verify_supervision_tokens(tokens_1d, start, duration, raw_text, tokenizer, pad, bos, eos, total_frames):
|
|
"""Verify BOS/EOS placement and that decoded token IDs match the original supervision text.
|
|
|
|
Checks:
|
|
1. BOS is placed at the frame corresponding to supervision start.
|
|
2. EOS is placed at the frame corresponding to supervision end (if within cut).
|
|
3. Text token IDs between BOS and EOS are a prefix of tokenizer.text_to_ids(clean_text).
|
|
4. Decoding those IDs back to text matches (or is a prefix of) the original text.
|
|
"""
|
|
pos = compute_num_frames(start, FL, SR)
|
|
eospos = compute_num_frames(start + duration, FL, SR)
|
|
clean = _clean_text(raw_text)
|
|
|
|
# 1. BOS at turn start
|
|
assert tokens_1d[pos].item() == bos, f"Expected BOS={bos} at frame {pos}, got {tokens_1d[pos].item()}"
|
|
|
|
# 2. EOS at turn end (only if within cut bounds)
|
|
if eospos < total_frames:
|
|
assert tokens_1d[eospos].item() == eos, f"Expected EOS={eos} at frame {eospos}, got {tokens_1d[eospos].item()}"
|
|
|
|
# 3. Extract text token IDs between BOS and EOS, filtering out pad
|
|
end = min(eospos, total_frames)
|
|
actual_ids = [t for t in tokens_1d[pos + 1 : end].tolist() if t != pad]
|
|
expected_ids = tokenizer.text_to_ids(clean)
|
|
|
|
# Actual IDs should be a prefix of expected (truncation may occur when text is long)
|
|
assert actual_ids == expected_ids[: len(actual_ids)], (
|
|
f"Token ID mismatch for '{clean}':\n"
|
|
f" actual_ids = {actual_ids}\n"
|
|
f" expected_prefix = {expected_ids[: len(actual_ids)]}\n"
|
|
f" full_expected = {expected_ids}"
|
|
)
|
|
|
|
# 4. Decode IDs back to text and verify against original
|
|
if actual_ids:
|
|
decoded = tokenizer.ids_to_text(actual_ids).strip()
|
|
if len(actual_ids) == len(expected_ids):
|
|
assert decoded == clean, f"Full decode mismatch: '{decoded}' != '{clean}', ids={actual_ids}"
|
|
else:
|
|
assert clean.startswith(decoded), (
|
|
f"Truncated decode '{decoded}' is not a prefix of '{clean}'\n"
|
|
f" ids={actual_ids} (truncated {len(expected_ids)} → {len(actual_ids)})"
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def tokenizer():
|
|
if os.path.exists("/home/TestData/speechlm/pretrained_models"):
|
|
model_path = "/home/TestData/speechlm/pretrained_models/TinyLlama--TinyLlama_v1.1"
|
|
else:
|
|
model_path = "TinyLlama/TinyLlama_v1.1"
|
|
return AutoTokenizer(model_path, use_fast=True)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def cuts():
|
|
"""Two cuts: cut1 with plain text, cut2 with timestamped text and a system prompt."""
|
|
cut1 = dummy_cut(0, duration=1.0, recording=dummy_recording(0, duration=1.0, with_data=True))
|
|
cut1.supervisions = [
|
|
SupervisionSegment(
|
|
id="s0-user", recording_id=cut1.recording_id, start=0, duration=0.3, text="hi", speaker="user"
|
|
),
|
|
SupervisionSegment(
|
|
id="s0-agent", recording_id=cut1.recording_id, start=0.4, duration=0.3, text="hello", speaker="assistant"
|
|
),
|
|
]
|
|
|
|
cut2 = dummy_cut(1, duration=2.0, recording=dummy_recording(1, duration=2.0, with_data=True))
|
|
cut2.supervisions = [
|
|
SupervisionSegment(
|
|
id="s1-user",
|
|
recording_id=cut2.recording_id,
|
|
start=0,
|
|
duration=0.5,
|
|
text="<|0|> good <|1|> <|3|> morning <|5|>",
|
|
speaker="user",
|
|
),
|
|
SupervisionSegment(
|
|
id="s1-agent",
|
|
recording_id=cut2.recording_id,
|
|
start=0.6,
|
|
duration=0.5,
|
|
text="<|0|> good <|2|> <|2|> morning <|4|> <|4|> to <|5|> <|5|> you <|6|>",
|
|
speaker="assistant",
|
|
),
|
|
SupervisionSegment(
|
|
id="s1-user2",
|
|
recording_id=cut2.recording_id,
|
|
start=1.2,
|
|
duration=0.3,
|
|
text="<|0|> thanks <|3|>",
|
|
speaker="user",
|
|
),
|
|
SupervisionSegment(
|
|
id="s1-agent2",
|
|
recording_id=cut2.recording_id,
|
|
start=1.6,
|
|
duration=0.4,
|
|
text="<|0|> welcome <|4|>",
|
|
speaker="assistant",
|
|
),
|
|
]
|
|
cut2.custom = {"system_prompt": "be helpful"}
|
|
|
|
return CutSet([cut1, cut2])
|
|
|
|
|
|
def test_collate_audio(cuts):
|
|
"""Test collate_audio: shapes, lengths, and zero-padding for shorter cuts."""
|
|
audio, audio_lens = collate_audio(cuts.resample(SR))
|
|
|
|
assert audio.shape == (2, 32000)
|
|
assert audio_lens.tolist() == [16000, 32000]
|
|
# Padding region for the shorter cut must be zero
|
|
assert (audio[0, 16000:] == 0).all(), "Audio padding should be zero"
|
|
# Non-padding region should have non-zero data (random audio from dummy_recording)
|
|
assert (audio[0, :16000] != 0).any(), "Audio data should be non-zero"
|
|
assert (audio[1, :32000] != 0).any(), "Audio data should be non-zero"
|
|
|
|
|
|
def test_collate_token_channel_target(cuts, tokenizer):
|
|
"""Test collate_token_channel for target (assistant) role: BOS/EOS placement, token decode."""
|
|
pad = get_pad_id(tokenizer)
|
|
bos = tokenizer.bos
|
|
eos = tokenizer.eos
|
|
total1 = compute_num_frames(1.0, FL, SR) # 13
|
|
total2 = compute_num_frames(2.0, FL, SR) # 25
|
|
|
|
target_tokens, target_token_lens = collate_token_channel(
|
|
cuts,
|
|
tokenizer,
|
|
frame_length=FL,
|
|
roles={"assistant"},
|
|
bos_id=bos,
|
|
eos_id=eos,
|
|
remove_timestamps=True,
|
|
)
|
|
|
|
assert target_token_lens.tolist() == [total1, total2]
|
|
|
|
# fmt: off
|
|
# Cut 1: "hello"(22172) at frames 5–9, padded to 25
|
|
# Cut 2: "good morning to you"(1781,7250,304,366) at frames 8–14, "welcome"(12853) at frames 20–24
|
|
expected_target = torch.tensor([
|
|
[0, 0, 0, 0, 0, 1, 22172, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
[0, 0, 0, 0, 0, 0, 0, 0, 1, 1781, 7250, 304, 366, 0, 2, 0, 0, 0, 0, 0, 1, 12853, 0, 0, 0],
|
|
])
|
|
# fmt: on
|
|
assert torch.equal(target_tokens, expected_target)
|
|
|
|
# Decode verification
|
|
_verify_supervision_tokens(target_tokens[0], 0.4, 0.3, "hello", tokenizer, pad, bos, eos, total1)
|
|
_verify_supervision_tokens(
|
|
target_tokens[1],
|
|
0.6,
|
|
0.5,
|
|
"<|0|> good <|2|> <|2|> morning <|4|> <|4|> to <|5|> <|5|> you <|6|>",
|
|
tokenizer,
|
|
pad,
|
|
bos,
|
|
eos,
|
|
total2,
|
|
)
|
|
_verify_supervision_tokens(target_tokens[1], 1.6, 0.4, "<|0|> welcome <|4|>", tokenizer, pad, bos, eos, total2)
|
|
|
|
|
|
def test_collate_token_channel_source(cuts, tokenizer):
|
|
"""Test collate_token_channel for source (user) role with timestamp-based word alignment."""
|
|
pad = get_pad_id(tokenizer)
|
|
bos = tokenizer.bos
|
|
eos = tokenizer.eos
|
|
total1 = compute_num_frames(1.0, FL, SR) # 13
|
|
total2 = compute_num_frames(2.0, FL, SR) # 25
|
|
|
|
source_tokens, source_token_lens = collate_token_channel(
|
|
cuts,
|
|
tokenizer,
|
|
frame_length=FL,
|
|
roles={"user"},
|
|
bos_id=bos,
|
|
eos_id=eos,
|
|
remove_timestamps=False,
|
|
prepend_word_space=False,
|
|
)
|
|
|
|
assert source_tokens.shape == (2, total2)
|
|
assert source_token_lens.tolist() == [total1, total2]
|
|
|
|
# fmt: off
|
|
# Cut 1: "hi"(7251) plain text, placed contiguously at frames 0–4
|
|
# Cut 2: "good"(1781) at ts 0–1, pad gap, "morning"(7250) at ts 3–5 → frames 0–6
|
|
# "thanks"(3969) at ts 0–3 → frames 15–19
|
|
expected_source = torch.tensor([
|
|
[1, 7251, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
[1, 1781, 0, 0, 7250, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 1, 3969, 0, 0, 2, 0, 0, 0, 0, 0],
|
|
])
|
|
# fmt: on
|
|
assert torch.equal(source_tokens, expected_source)
|
|
|
|
# Decode verification
|
|
# ── Cut 1: no timestamps, sentence-level tokenization ──
|
|
_verify_supervision_tokens(source_tokens[0], 0.0, 0.3, "hi", tokenizer, pad, bos, eos, total1)
|
|
assert (source_tokens[0, total1:] == pad).all(), "Batch padding should be pad"
|
|
|
|
# ── Cut 2: two user turns with timestamp alignment ──
|
|
_verify_supervision_tokens(
|
|
source_tokens[1],
|
|
0.0,
|
|
0.5,
|
|
"<|0|> good <|1|> <|3|> morning <|5|>",
|
|
tokenizer,
|
|
pad,
|
|
bos,
|
|
eos,
|
|
total2,
|
|
)
|
|
_verify_supervision_tokens(source_tokens[1], 1.2, 0.3, "<|0|> thanks <|3|>", tokenizer, pad, bos, eos, total2)
|
|
|
|
|
|
def test_collate_system_prompt(cuts, tokenizer):
|
|
"""Test collate_system_prompt: cut1 has no prompt, cut2 has 'be helpful'."""
|
|
|
|
prompt_tokens, prompt_token_lens = collate_system_prompt(cuts, tokenizer)
|
|
|
|
# fmt: off
|
|
# cut1: no system_prompt → all pad, len=0
|
|
# cut2: "be helpful" → [BOS=1, "be"=367, "helpful"=8444, EOS=2], len=4
|
|
expected_prompt = torch.tensor([
|
|
[0, 0, 0, 0],
|
|
[1, 367, 8444, 2],
|
|
])
|
|
# fmt: on
|
|
assert prompt_token_lens.tolist() == [0, 4]
|
|
assert torch.equal(prompt_tokens, expected_prompt)
|
|
|
|
# Decode prompt tokens back to text
|
|
prompt_ids = tokenizer.text_to_ids("be helpful")
|
|
decoded_prompt = tokenizer.ids_to_text(prompt_ids).strip()
|
|
assert decoded_prompt == "be helpful", f"Prompt decode: '{decoded_prompt}'"
|
|
|
|
|
|
def test_collate_text_data(tokenizer):
|
|
"""Test collate_vectors for text token inputs: padding and lengths."""
|
|
pad = get_pad_id(tokenizer)
|
|
|
|
# "hi" → [7251], "good morning" → [1781, 7250]
|
|
text_tokens_list = [
|
|
torch.tensor([7251], dtype=torch.long),
|
|
torch.tensor([1781, 7250], dtype=torch.long),
|
|
]
|
|
text_token_lens = torch.tensor([t.shape[0] for t in text_tokens_list], dtype=torch.long)
|
|
text_tokens = collate_vectors(text_tokens_list, padding_value=pad)
|
|
|
|
assert text_token_lens.tolist() == [1, 2]
|
|
assert text_tokens.shape == (2, 2)
|
|
# Shorter sequence is right-padded
|
|
assert text_tokens[0].tolist() == [7251, pad]
|
|
assert text_tokens[1].tolist() == [1781, 7250]
|
|
# Decode back to verify
|
|
assert tokenizer.ids_to_text([7251]).strip() == "hi"
|
|
assert tokenizer.ids_to_text([1781, 7250]).strip() == "good morning"
|
|
|
|
|
|
def test_duplex_stt_dataset(cuts, tokenizer):
|
|
"""End-to-end test of DuplexSTTDataset.__getitem__: covers all collate outputs including timestamps."""
|
|
dataset = DuplexSTTDataset(
|
|
tokenizer=tokenizer,
|
|
frame_length=FL,
|
|
source_sample_rate=SR,
|
|
input_roles=["user"],
|
|
output_roles=["assistant"],
|
|
cfg={"prepend_word_space": False},
|
|
model_cfg={"predict_user_text": True},
|
|
)
|
|
batch = dataset[cuts]
|
|
ad = batch["audio_data"]
|
|
|
|
total1 = compute_num_frames(1.0, FL, SR) # 13
|
|
total2 = compute_num_frames(2.0, FL, SR) # 25
|
|
|
|
# sample_id
|
|
assert len(ad["sample_id"]) == 2
|
|
|
|
# source_audio
|
|
assert ad["source_audio"].shape == (2, 32000)
|
|
assert ad["source_audio_lens"].tolist() == [16000, 32000]
|
|
|
|
# target_tokens (remove_timestamps=True, same as unit test)
|
|
assert ad["target_token_lens"].tolist() == [total1, total2]
|
|
# fmt: off
|
|
assert torch.equal(ad["target_tokens"], torch.tensor([
|
|
[0, 0, 0, 0, 0, 1, 22172, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
[0, 0, 0, 0, 0, 0, 0, 0, 1, 1781, 7250, 304, 366, 0, 2, 0, 0, 0, 0, 0, 1, 12853, 0, 0, 0],
|
|
]))
|
|
# fmt: on
|
|
|
|
# source_tokens (remove_timestamps=False via predict_user_text=True, timestamp-aligned)
|
|
assert ad["source_token_lens"].tolist() == [total1, total2]
|
|
# fmt: off
|
|
assert torch.equal(ad["source_tokens"], torch.tensor([
|
|
[1, 7251, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
[1, 1781, 0, 0, 7250, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 1, 3969, 0, 0, 2, 0, 0, 0, 0, 0],
|
|
]))
|
|
# fmt: on
|
|
|
|
# system prompt (cut2 has "be helpful")
|
|
assert "prompt_tokens" in ad
|
|
assert ad["prompt_token_lens"].tolist() == [0, 4]
|
|
# fmt: off
|
|
assert torch.equal(ad["prompt_tokens"], torch.tensor([
|
|
[0, 0, 0, 0],
|
|
[1, 367, 8444, 2],
|
|
]))
|
|
# fmt: on
|
|
|
|
# source/target texts
|
|
assert ad["source_texts"] == ["hi", "good morning thanks"]
|
|
assert ad["target_texts"] == [
|
|
"hello",
|
|
"<|0|> good <|2|> <|2|> morning <|4|> <|4|> to <|5|> <|5|> you <|6|> <|0|> welcome <|4|>",
|
|
]
|
|
|
|
# task
|
|
assert ad["task"] == ["s2s_duplex", "s2s_duplex"]
|
|
|
|
# no text data (no Formattable cuts)
|
|
assert batch["text_data"] is None
|
|
|
|
# source_audio is not augmented when augmenter is not configured (audio is unchanged from collate_audio)
|
|
|
|
|
|
def test_duplex_stt_dataset_augmentation(cuts, tokenizer, tmp_path):
|
|
"""Test that audio augmentation modifies source_audio in-place when configured."""
|
|
import numpy as np
|
|
import soundfile as sf
|
|
|
|
# Create dummy noise files for the augmenter
|
|
noise_dir = tmp_path / "noise" / "all"
|
|
noise_dir.mkdir(parents=True)
|
|
for i in range(3):
|
|
noise = np.random.randn(SR).astype(np.float32) * 0.01
|
|
sf.write(str(noise_dir / f"noise_{i}.wav"), noise, SR)
|
|
|
|
cfg = {
|
|
"prepend_word_space": False,
|
|
"use_noise_aug": True,
|
|
"noise_prob": 1.0,
|
|
"noise_aug_path": str(tmp_path / "noise"),
|
|
"noise_min_snr": 20,
|
|
"noise_max_snr": 20,
|
|
}
|
|
|
|
dataset = DuplexSTTDataset(
|
|
tokenizer=tokenizer,
|
|
frame_length=FL,
|
|
source_sample_rate=SR,
|
|
input_roles=["user"],
|
|
output_roles=["assistant"],
|
|
cfg=cfg,
|
|
model_cfg={"predict_user_text": True},
|
|
)
|
|
|
|
assert dataset.audio_augmenter is not None
|
|
|
|
# Get original audio for comparison
|
|
original_audio, _ = collate_audio(cuts.resample(SR))
|
|
|
|
batch = dataset[cuts]
|
|
ad = batch["audio_data"]
|
|
|
|
# source_audio should be augmented (different from original)
|
|
assert "source_audio_aug" not in ad
|
|
assert ad["source_audio"].shape == original_audio.shape
|
|
assert not torch.equal(ad["source_audio"], original_audio)
|