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 lines
15 KiB
Python
416 lines
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)
|