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
289 lines
11 KiB
Python
289 lines
11 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.
|
|
|
|
# pyright: reportMissingImports=false
|
|
# pylint: disable=import-error,redefined-outer-name
|
|
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Tuple
|
|
|
|
import pytest
|
|
import soundfile as sf
|
|
import torch
|
|
from torch.nn.utils.rnn import pad_sequence
|
|
from torch.utils.data import DataLoader, Dataset
|
|
|
|
from nemo.collections.asr.parts.mixins.diarization import DiarizeConfig, SpkDiarizationMixin
|
|
|
|
|
|
class DummyModel(torch.nn.Module):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.encoder = torch.nn.Linear(1, 1)
|
|
# Make the test deterministic and ensure positive outputs.
|
|
with torch.no_grad():
|
|
self.encoder.weight.fill_(1.0)
|
|
self.encoder.bias.fill_(1.0)
|
|
|
|
self.execution_count = 0
|
|
self.flag_begin = False
|
|
|
|
def forward(self, x):
|
|
# Input: [1, 1] Output = [1, 1
|
|
out = self.encoder(x)
|
|
return out
|
|
|
|
|
|
class AudioPathDataset(Dataset):
|
|
def __init__(self, audio_filepaths: List[str]):
|
|
self._audio_filepaths = audio_filepaths
|
|
|
|
def __len__(self) -> int:
|
|
return len(self._audio_filepaths)
|
|
|
|
def __getitem__(self, index: int) -> Tuple[torch.Tensor, torch.Tensor]:
|
|
samples, _ = sf.read(self._audio_filepaths[index], dtype="float32", always_2d=False)
|
|
if hasattr(samples, "ndim") and samples.ndim == 2:
|
|
samples = samples.mean(axis=1)
|
|
waveform = torch.as_tensor(samples, dtype=torch.float32)
|
|
length = torch.tensor(waveform.shape[0], dtype=torch.long)
|
|
return waveform, length
|
|
|
|
|
|
def collate(batch):
|
|
waveforms, lengths = zip(*batch)
|
|
padded = pad_sequence(waveforms, batch_first=True) # (B, T)
|
|
lengths_tensor = torch.stack(lengths, dim=0)
|
|
return padded, lengths_tensor
|
|
|
|
|
|
@pytest.fixture()
|
|
def audio_files(test_data_dir):
|
|
"""
|
|
Returns audio arrays + sample rate + filepaths for testing.
|
|
"""
|
|
|
|
audio_file1 = os.path.join(test_data_dir, "an4_speaker", "an4", "wav", "an4_clstk", "fash", "an251-fash-b.wav")
|
|
audio_file2 = os.path.join(test_data_dir, "an4_speaker", "an4", "wav", "an4_clstk", "ffmm", "cen1-ffmm-b.wav")
|
|
|
|
audio1, sample_rate1 = sf.read(audio_file1, dtype='float32', always_2d=False)
|
|
audio2, sample_rate2 = sf.read(audio_file2, dtype='float32', always_2d=False)
|
|
assert int(sample_rate1) == int(sample_rate2)
|
|
|
|
return audio1, audio2, int(sample_rate1), audio_file1, audio_file2
|
|
|
|
|
|
class DiarizableDummy(DummyModel, SpkDiarizationMixin):
|
|
def _diarize_on_begin(self, audio, diarcfg: DiarizeConfig):
|
|
super()._diarize_on_begin(audio, diarcfg)
|
|
self.flag_begin = True
|
|
|
|
def _setup_diarize_dataloader(self, config: Dict) -> DataLoader:
|
|
if "manifest_filepath" in config:
|
|
filepaths: List[str] = []
|
|
with open(config["manifest_filepath"], "r", encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
filepaths.append(json.loads(line)["audio_filepath"])
|
|
else:
|
|
filepaths = list(config["paths2audio_files"])
|
|
|
|
return DataLoader(
|
|
dataset=AudioPathDataset(filepaths),
|
|
batch_size=int(config.get("batch_size", 1)),
|
|
num_workers=int(config.get("num_workers", 0)),
|
|
pin_memory=False,
|
|
drop_last=False,
|
|
collate_fn=collate,
|
|
)
|
|
|
|
def _diarize_forward(self, batch: Any):
|
|
"""
|
|
Real inference step for diarization tests.
|
|
|
|
The dataloader yields `(padded_waveforms, lengths)` where:
|
|
- padded_waveforms: (B, T)
|
|
- lengths: (B,)
|
|
|
|
We compute a masked mean per sample -> (B, 1) and run it through the model.
|
|
"""
|
|
if not isinstance(batch, (tuple, list)) or len(batch) != 2:
|
|
raise TypeError(f"Expected batch=(waveforms, lengths), got: {type(batch)}")
|
|
|
|
waveforms, lengths = batch
|
|
if waveforms.dim() != 2:
|
|
raise ValueError(f"Expected waveforms of shape (B, T), got {tuple(waveforms.shape)}")
|
|
if lengths.dim() != 1:
|
|
raise ValueError(f"Expected lengths of shape (B,), got {tuple(lengths.shape)}")
|
|
|
|
# Masked mean pooling over time dimension.
|
|
_, T = waveforms.shape
|
|
device = waveforms.device
|
|
t = torch.arange(T, device=device).unsqueeze(0) # (1, T)
|
|
mask = (t < lengths.unsqueeze(1)).to(waveforms.dtype) # (B, T)
|
|
denom = lengths.to(waveforms.dtype).clamp_min(1.0).unsqueeze(1) # (B, 1)
|
|
pooled = (waveforms * mask).sum(dim=1, keepdim=True) / denom # (B, 1)
|
|
|
|
preds = self(pooled) # (B, 1)
|
|
return preds
|
|
|
|
def _diarize_output_processing(self, outputs, uniq_ids, diarcfg: DiarizeConfig):
|
|
self.execution_count += 1
|
|
# Ensure "one scalar per input sample".
|
|
outputs = outputs.detach().cpu().view(outputs.shape[0], -1).mean(dim=1)
|
|
return [float(x) for x in outputs]
|
|
|
|
|
|
@pytest.fixture()
|
|
def dummy_model():
|
|
return DiarizableDummy()
|
|
|
|
|
|
class TestSpkDiarizationMixin:
|
|
pytestmark = pytest.mark.with_downloads()
|
|
|
|
@pytest.mark.unit
|
|
def test_constructor_non_instance(self):
|
|
model = DummyModel()
|
|
assert not isinstance(model, SpkDiarizationMixin)
|
|
assert not hasattr(model, 'diarize')
|
|
|
|
@pytest.mark.unit
|
|
def test_diarize_wav_path_single(self, dummy_model, audio_files):
|
|
dummy_model = dummy_model.eval()
|
|
_, _, _, audio_file1, _ = audio_files
|
|
outputs = dummy_model.diarize(audio_file1, batch_size=1)
|
|
assert isinstance(outputs, list)
|
|
assert len(outputs) == 1
|
|
assert outputs[0] > 0
|
|
|
|
@pytest.mark.unit
|
|
def test_diarize_wav_path_list(self, dummy_model, audio_files):
|
|
dummy_model = dummy_model.eval()
|
|
_, _, _, audio_file1, audio_file2 = audio_files
|
|
outputs = dummy_model.diarize([audio_file1, audio_file2], batch_size=1)
|
|
assert isinstance(outputs, list)
|
|
assert len(outputs) == 2
|
|
assert outputs[0] > 0
|
|
assert outputs[1] > 0
|
|
|
|
@pytest.mark.unit
|
|
def test_diarize_manifest_jsonl_path(self, dummy_model, audio_files, tmp_path: Path):
|
|
dummy_model = dummy_model.eval()
|
|
_, _, _, audio_file1, audio_file2 = audio_files
|
|
manifest_path = tmp_path / "manifest.jsonl"
|
|
with manifest_path.open("w", encoding="utf-8") as f:
|
|
for audio_file in (audio_file1, audio_file2):
|
|
f.write(json.dumps({"audio_filepath": audio_file, "offset": 0, "duration": None, "text": "-"}) + "\n")
|
|
outputs = dummy_model.diarize(str(manifest_path), batch_size=1)
|
|
assert isinstance(outputs, list)
|
|
assert len(outputs) == 2
|
|
|
|
@pytest.mark.unit
|
|
def test_diarize_numpy_single_requires_sample_rate(self, dummy_model, audio_files):
|
|
dummy_model = dummy_model.eval()
|
|
audio1, _, _, _, _ = audio_files
|
|
|
|
# Check if it raises an error without sample rate when using a single numpy variable input
|
|
with pytest.raises(ValueError):
|
|
_ = dummy_model.diarize(audio=audio1, batch_size=1)
|
|
|
|
# Set sample rate and check if it works
|
|
sample_rate = 16000
|
|
outputs = dummy_model.diarize(audio1, batch_size=1, sample_rate=sample_rate)
|
|
assert isinstance(outputs, list)
|
|
assert len(outputs) == 1
|
|
assert outputs[0] > 0
|
|
|
|
@pytest.mark.unit
|
|
def test_diarize_numpy_list_requires_sample_rate(self, dummy_model, audio_files):
|
|
dummy_model = dummy_model.eval()
|
|
audio1, audio2, _, _, _ = audio_files
|
|
numpy_audio_list = [audio1, audio2]
|
|
# Check if it raises an error without sample rate when using numpy list input
|
|
with pytest.raises(ValueError):
|
|
_ = dummy_model.diarize(audio=numpy_audio_list, batch_size=2)
|
|
|
|
# Set sample rate and check if it works
|
|
sample_rate = 16000
|
|
outputs = dummy_model.diarize(audio=numpy_audio_list, batch_size=2, sample_rate=sample_rate)
|
|
assert isinstance(outputs, list)
|
|
assert len(outputs) == 2
|
|
assert outputs[0] > 0
|
|
assert outputs[1] > 0
|
|
|
|
@pytest.mark.unit
|
|
def test_diarize_numpy_single(self, dummy_model, audio_files):
|
|
dummy_model = dummy_model.eval()
|
|
audio1, _, sample_rate, _, _ = audio_files
|
|
outputs = dummy_model.diarize(audio1, batch_size=1, sample_rate=sample_rate)
|
|
assert isinstance(outputs, list)
|
|
assert len(outputs) == 1
|
|
assert outputs[0] > 0
|
|
|
|
@pytest.mark.unit
|
|
def test_diarize_numpy_list(self, dummy_model, audio_files):
|
|
dummy_model = dummy_model.eval()
|
|
audio1, audio2, sample_rate, _, _ = audio_files
|
|
outputs = dummy_model.diarize([audio1, audio2], batch_size=1, sample_rate=sample_rate)
|
|
assert isinstance(outputs, list)
|
|
assert len(outputs) == 2
|
|
assert outputs[0] > 0
|
|
assert outputs[1] > 0
|
|
|
|
@pytest.mark.unit
|
|
def test_diarize_numpy_list_but_no_sample_rate(self, dummy_model, audio_files):
|
|
dummy_model = dummy_model.eval()
|
|
# Numpy audio inputs require sample_rate; the mixin should raise with a clear message.
|
|
with pytest.raises(
|
|
ValueError, match=r"Sample rate is not set\. Numpy audio inputs require sample_rate to be set\."
|
|
):
|
|
_ = dummy_model.diarize(audio_files, batch_size=1)
|
|
|
|
@pytest.mark.unit
|
|
def test_transribe_override_config_incorrect(self, dummy_model, audio_files):
|
|
# Not subclassing DiarizeConfig
|
|
@dataclass
|
|
class OverrideConfig:
|
|
batch_size: int = 1
|
|
output_type: str = 'dict'
|
|
|
|
dummy_model = dummy_model.eval()
|
|
|
|
audio1, _, _, _, _ = audio_files
|
|
override_cfg = OverrideConfig(batch_size=1, output_type='dict')
|
|
with pytest.raises(ValueError):
|
|
_ = dummy_model.diarize(audio1, override_config=override_cfg)
|
|
|
|
@pytest.mark.unit
|
|
def test_transribe_override_config_correct(self, dummy_model, audio_files):
|
|
@dataclass
|
|
class OverrideConfig(DiarizeConfig):
|
|
output_type: str = 'dict'
|
|
verbose: bool = False
|
|
|
|
dummy_model = dummy_model.eval()
|
|
audio1, _, sample_rate, _, _ = audio_files
|
|
override_cfg = OverrideConfig(batch_size=1, output_type='list', sample_rate=sample_rate)
|
|
outputs = dummy_model.diarize(audio1, override_config=override_cfg)
|
|
|
|
assert isinstance(outputs, list)
|
|
assert len(outputs) == 1
|