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

183 lines
7.7 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.
from io import BytesIO
from unittest.mock import MagicMock, PropertyMock
import numpy as np
import pytest
import soundfile as sf
from lhotse import AudioSource, MonoCut, Recording, SupervisionSegment
from nemo.collections.speechlm2.data import DuplexSTTDataset
def _make_in_memory_recording(recording_id, duration, sampling_rate, signal_freq=440.0):
"""Create a lhotse Recording backed by in-memory WAV bytes with a sine wave."""
num_samples = int(duration * sampling_rate)
t = np.linspace(0, duration, num_samples, endpoint=False, dtype=np.float32)
audio = 0.5 * np.sin(2 * np.pi * signal_freq * t)
buf = BytesIO()
sf.write(buf, audio, sampling_rate, format='wav')
buf.seek(0)
return Recording(
id=recording_id,
sampling_rate=sampling_rate,
num_samples=num_samples,
duration=duration,
sources=[AudioSource(type="memory", channels=[0], source=buf.getvalue())],
)
def test_create_role_swapped_cut():
"""
Test _create_role_swapped_cut with a 6-turn conversation.
Original:
Turn 1: User 0.0-1.0 "hello"
Turn 2: Assistant 1.5-2.5 "hi there"
Turn 3: User 3.0-4.0 "how are you"
Turn 4: Assistant 4.5-5.5 "doing well"
Turn 5: User 6.0-7.0 "great"
Turn 6: Assistant 7.5-8.5 "bye"
After swap + filter (remove first Assistant, last User):
User 0.0-1.0 "hi there" (originally Assistant turn 2)
Assistant 1.5-2.5 "how are you" (originally User turn 3)
User 3.0-4.0 "doing well" (originally Assistant turn 4)
Assistant 4.5-5.5 "great" (originally User turn 5)
"""
tokenizer = MagicMock()
type(tokenizer).bos = PropertyMock(return_value=1)
type(tokenizer).eos = PropertyMock(return_value=2)
type(tokenizer).pad = PropertyMock(return_value=0)
type(tokenizer).pad_id = PropertyMock(return_value=0)
type(tokenizer).unk_id = PropertyMock(return_value=None)
tokenizer.text_to_ids = MagicMock(return_value=[1])
dataset = DuplexSTTDataset(
tokenizer=tokenizer,
frame_length=0.08,
source_sample_rate=16000,
input_roles=["User"],
output_roles=["Assistant"],
aug_by_swap_role=True,
cfg={"early_interruption_prob": 0.0},
model_cfg={"predict_user_text": False, "force_align_user_text": False},
)
# Build a 6-turn cut with source (440Hz) and target (880Hz) audio
sampling_rate = 16000
total_duration = 9.0
source_rec = _make_in_memory_recording("src", total_duration, sampling_rate, signal_freq=440.0)
target_rec = _make_in_memory_recording("tgt", total_duration, sampling_rate, signal_freq=880.0)
supervisions = [
SupervisionSegment(id="s1", recording_id="src", start=0.0, duration=1.0, speaker="User", text="hello"),
SupervisionSegment(id="s2", recording_id="src", start=1.5, duration=1.0, speaker="Assistant", text="hi there"),
SupervisionSegment(id="s3", recording_id="src", start=3.0, duration=1.0, speaker="User", text="how are you"),
SupervisionSegment(
id="s4", recording_id="src", start=4.5, duration=1.0, speaker="Assistant", text="doing well"
),
SupervisionSegment(id="s5", recording_id="src", start=6.0, duration=1.0, speaker="User", text="great"),
SupervisionSegment(id="s6", recording_id="src", start=7.5, duration=1.0, speaker="Assistant", text="bye"),
]
cut = MonoCut(
id="test_cut",
start=0,
duration=total_duration,
channel=0,
supervisions=supervisions,
recording=source_rec,
custom={'target_audio': target_rec, 'total_turns': 6},
)
swapped_cut = dataset._create_role_swapped_cut(cut)
assert swapped_cut is not None
sups = swapped_cut.supervisions
# 4 turns remain after removing first Assistant and last User
assert len(sups) == 4
# Roles alternate User, Assistant, User, Assistant
assert [s.speaker for s in sups] == ["User", "Assistant", "User", "Assistant"]
# Texts match the kept turns
assert [s.text for s in sups] == ["hi there", "how are you", "doing well", "great"]
# Timestamps adjusted (first_remaining_start=1.5 subtracted), all start at 0
assert abs(sups[0].start - 0.0) < 1e-6
assert abs(sups[1].start - 1.5) < 1e-6
assert abs(sups[2].start - 3.0) < 1e-6
assert abs(sups[3].start - 4.5) < 1e-6
# Source audio: User regions have signal (from target_audio), Assistant regions are silent
audio = swapped_cut.recording.to_cut().load_audio().squeeze()
for s in sups:
start_sample = int(s.start * sampling_rate)
end_sample = int((s.start + s.duration) * sampling_rate)
rms = np.sqrt(np.mean(audio[start_sample:end_sample] ** 2))
if s.speaker == 'User':
assert rms > 0.1, f"User turn '{s.text}' should have audio from target_audio, RMS={rms:.4f}"
else:
assert rms < 0.01, f"Assistant turn '{s.text}' should be silent in STT source, RMS={rms:.4f}"
# Verify User audio is similar to target audio (880Hz)
# Load the original target audio for comparison
target_audio = target_rec.to_cut().load_audio().squeeze()
for s in sups:
if s.speaker == 'User':
# User turns should be from target_audio (originally Assistant turns)
# The original supervisions had these start times: 1.5 (hi there), 4.5 (doing well)
# which map to indices 0 and 2 in sups (User turns after swap)
start_sample = int(s.start * sampling_rate)
end_sample = int((s.start + s.duration) * sampling_rate)
segment = audio[start_sample:end_sample]
idx = list(sups).index(s)
original_start_times = {0: 1.5, 2: 4.5} # map swapped index to original time
if idx in original_start_times:
orig_start = original_start_times[idx]
orig_start_sample = int(orig_start * sampling_rate)
orig_end_sample = int((orig_start + s.duration) * sampling_rate)
target_segment = target_audio[orig_start_sample:orig_end_sample]
# Verify lengths match exactly
len_diff = abs(len(segment) - len(target_segment))
assert len_diff == 0, (
f"User turn '{s.text}' length must match exactly: "
f"segment={len(segment)} samples, "
f"target_segment={len(target_segment)} samples, "
f"diff={len_diff}"
)
# Compute correlation between user audio and target audio
# Waveforms should be nearly identical
correlation = np.corrcoef(segment, target_segment)[0, 1]
assert correlation > 0.999, (
f"User turn '{s.text}' should have nearly identical waveform to target audio. "
f"Correlation={correlation:.6f}, expected > 0.999"
)
# Metadata
assert swapped_cut.custom['role_swapped'] is True
assert swapped_cut.custom['total_turns'] == 4
if __name__ == "__main__":
pytest.main([__file__, "-v"])