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

416 lines
15 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.
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 59, padded to 25
# Cut 2: "good morning to you"(1781,7250,304,366) at frames 814, "welcome"(12853) at frames 2024
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 04
# Cut 2: "good"(1781) at ts 01, pad gap, "morning"(7250) at ts 35 → frames 06
# "thanks"(3969) at ts 03 → frames 1519
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)