Skip to content

Add VADSegmentationStage for voice activity detection - #1578

Merged
sarahyurick merged 30 commits into
NVIDIA-NeMo:mainfrom
shubhamNvidia:pr/audio-vad
Apr 6, 2026
Merged

Add VADSegmentationStage for voice activity detection#1578
sarahyurick merged 30 commits into
NVIDIA-NeMo:mainfrom
shubhamNvidia:pr/audio-vad

Conversation

@shubhamNvidia

Copy link
Copy Markdown
Contributor

Adds VADSegmentationStage which segments audio into speech chunks using Silero VAD. Produces a fan-out list of AudioBatch objects, one per detected speech segment, with start_ms, end_ms, segment_num, duration_sec, and both PyDub AudioSegment and torch waveform outputs. Supports configurable min_duration_sec, max_duration_sec, threshold, and speech_pad_ms. Works on both CPU and GPU. Includes VADConfig, pickling support for Ray workers, and unit tests with mocked Silero VAD.

@copy-pr-bot

copy-pr-bot Bot commented Mar 5, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@greptile-apps

greptile-apps Bot commented Mar 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds VADSegmentationStage, a new audio processing stage that uses Silero VAD to segment audio into speech chunks, supporting either a fan-out mode (one AudioTask per segment) or a nested mode (all segments inlined in a single AudioTask). The implementation supports CPU and GPU execution, configurable VAD parameters (threshold, speech_pad_ms, min_interval_ms, min_duration_sec, max_duration_sec), and custom waveform/sample-rate key names.

Compared to earlier review rounds, many issues have been resolved: warnings.filterwarnings is now correctly scoped to a catch_warnings context manager, the resampling condition checks SILERO_SUPPORTED_RATES directly without the previous % 16000 escape hatch, timestamp-to-seconds conversion in _get_vad_segments is correct, and _resolve_audio correctly uses self.waveform_key/self.sample_rate_key when loading from file.

Two issues remain before merge:

  • @pytest.mark.gpu on all tests (P1): Every test mocks both Silero VAD functions and does not invoke any GPU code, yet the class-level decorator will skip all tests — including the pure-Python pickling test — on any CPU-only CI runner, giving zero coverage signal.
  • process() nested-mode early-exit type contract (P1): When _resolve_audio returns None, the method always returns [] regardless of the nested flag, breaking the nested=True contract of always returning a single AudioTask.
  • outputs() missing original_file (P2): The field is unconditionally written to every segment dict and listed in the class docstring, but is absent from the declared output schema.

Confidence Score: 4/5

Near-safe to merge; two P1 issues — test suite skipped on CPU CI and nested-mode early-exit returning the wrong type — should be addressed before merging.

The implementation has improved significantly across review rounds and the core VAD logic is correct. The two P1 findings are a test infrastructure problem that leaves regressions undetected, and a type contract violation in an error path of nested mode. Neither causes data corruption in normal operation but both affect correctness.

tests/stages/audio/segmentation/test_vad_segmentation.py (GPU mark skips the entire suite on CPU CI) and nemo_curator/stages/audio/segmentation/vad_segmentation.py (nested-mode early-exit returns a list instead of AudioTask).

Important Files Changed

Filename Overview
nemo_curator/stages/audio/segmentation/vad_segmentation.py New VAD segmentation stage with correct resampling, scoped warning suppression, and configurable key handling; nested-mode early-exit breaks the AudioTask return-type contract, and outputs() is missing the original_file key
tests/stages/audio/segmentation/test_vad_segmentation.py Comprehensive mock-based test suite covering fan-out, nested, and pickling paths, but @pytest.mark.gpu at the class level skips all tests on CPU-only CI runners
nemo_curator/stages/audio/init.py Adds VADSegmentationStage to the audio package exports; docstring prose cleaned up correctly
nemo_curator/stages/audio/segmentation/init.py Adds VADSegmentationStage to the segmentation sub-package exports alongside SpeakerSeparationStage

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[AudioTask input] --> B{_vad_model loaded?}
    B -- No --> ERR[raise RuntimeError]
    B -- Yes --> D[_resolve_audio]
    D -- waveform present --> E[ensure_waveform_2d]
    D -- waveform missing, filepath valid --> F[load_audio_file]
    D -- both missing --> G[return None]
    F --> E
    G --> BUG{nested mode?}
    BUG -- nested=True --> BUG2[BUG: returns empty list instead of AudioTask]
    BUG -- nested=False --> EL[return empty list]
    E --> K[_get_vad_segments]
    K --> L{sample_rate in SILERO_SUPPORTED_RATES?}
    L -- Yes --> M[pass waveform as-is]
    L -- No --> N[Resample to 16kHz via torchaudio]
    M --> O[get_speech_timestamps]
    N --> O
    O --> P{segments empty?}
    P -- Yes, nested=True --> R[return task with segments=[]]
    P -- Yes, nested=False --> S[return empty list]
    P -- No, nested=True --> U[build segment dicts stored in task.data.segments]
    U --> V[delete waveform from task.data]
    V --> W[return single AudioTask]
    P -- No, nested=False --> X[build one AudioTask per segment]
    X --> Y[return list of AudioTasks fan-out]
Loading

Comments Outside Diff (1)

  1. nemo_curator/stages/audio/segmentation/vad_segmentation.py, line 228-244 (link)

    process() returns [] in nested=True mode when audio resolution fails

    When _resolve_audio returns None (line 244), process returns [] unconditionally, regardless of the nested flag. In nested=True mode the declared contract is to return a single AudioTask, not a list — test_nested_mode_no_speech_returns_task_with_empty_segments (line 185-203) already verifies that the empty-speech path correctly returns an AudioTask. The missing-audio early-exit silently breaks the same contract.

    Callers in nested mode that receive [] instead of an AudioTask will encounter a type error downstream or silently swallow the failure with no indication that audio was missing.

    Fix by mirroring the empty-speech handling:

    if audio_result is None:
        if self.nested:
            task.data["segments"] = []
            return task
        return []

Reviews (35): Last reviewed commit: "Address review: default VAD to CPU, fix ..." | Re-trigger Greptile

Comment on lines +329 to +333
'duration_sec': (end_ms - start_ms) / 1000.0,
'original_file': item.get('audio_filepath', 'unknown'),
}

# Copy any metadata from original item

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lazy lock creation is not thread-safe

_get_lock() itself is not protected by a lock. Two threads can both observe _init_lock is None concurrently and each create a separate threading.Lock() instance. When _initialize_model then calls with self._get_lock(), the two threads could be holding different lock objects, so the double-checked locking pattern no longer prevents concurrent model initialization.

The idiomatic fix is to create the lock once, before it can be shared across threads. Because threading.Lock is not picklable, it must be created lazily — but the creation itself must be guarded. A common solution is to use a module-level or class-level "meta-lock" for the creation step, or to simply create the lock unconditionally in __setstate__ and in __post_init__ using a sentinel that is reset on unpickling:

# In __post_init__:
self._init_lock = threading.Lock()   # safe, object not yet shared

# In __setstate__:
self.__dict__.update(state)
self._init_lock = threading.Lock()   # recreate after unpickling

This way _get_lock() is no longer needed and the double-checked locking in _initialize_model becomes correct.

Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
@sarahyurick
sarahyurick self-requested a review March 5, 2026 18:10
Comment thread nemo_curator/stages/audio/configs/__init__.py Outdated


@dataclass
class VADConfig:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed offline. We do not need a config class per stage.

Comment thread nemo_curator/stages/audio/segmentation/__init__.py
Comment on lines +162 to +184
def __getstate__(self):
"""Return state for pickling, excluding unpicklable objects."""
state = self.__dict__.copy()
# Remove the lock and model - they'll be recreated
state['_init_lock'] = None
state['_vad_model'] = None
state['_vad_utils'] = None
state['_device'] = None
return state

def __setstate__(self, state):
"""Restore state after unpickling."""
self.__dict__.update(state)
self._init_lock = None
self._vad_model = None
self._vad_utils = None
self._device = None

def _get_lock(self):
"""Get or create the initialization lock (lazy initialization)."""
if self._init_lock is None:
self._init_lock = threading.Lock()
return self._init_lock

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC we can remove these.

return [], ['audio', 'waveform', 'sample_rate', 'start_ms', 'end_ms', 'segment_num', 'duration_sec']

def ray_stage_spec(self) -> dict[str, Any]:
from nemo_curator.backends.experimental.utils import RayStageSpecKeys

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be a top-level import.

Comment on lines +220 to +222
# Determine device based on resources and CUDA availability
# Use GPU if _resources.gpus > 0 and CUDA is available
use_gpu = self._resources.gpus > 0 and torch.cuda.is_available()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would you recommend as the default here? GPU or no GPU?

Comment on lines +224 to +227
if use_gpu:
self._device = torch.device(f'cuda:{torch.cuda.current_device()}')
model = model.to(self._device)
logger.info(f"Silero VAD model loaded on GPU: {self._device}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should just work OOTB with self._device = torch.device("cuda").

Comment on lines +27 to +47
def test_defaults(self) -> None:
cfg = VADConfig()
assert cfg.min_interval_ms == 500
assert cfg.min_duration_sec == 2.0
assert cfg.max_duration_sec == 60.0
assert cfg.threshold == 0.5
assert cfg.speech_pad_ms == 300

def test_from_dict(self) -> None:
cfg = VADConfig.from_dict({"min_duration_sec": 3.0, "threshold": 0.6})
assert cfg.min_duration_sec == 3.0
assert cfg.threshold == 0.6
assert cfg.max_duration_sec == 60.0

def test_from_dict_none(self) -> None:
cfg = VADConfig.from_dict(None)
assert cfg.threshold == 0.5

def test_from_dict_ignores_unknown(self) -> None:
cfg = VADConfig.from_dict({"unknown": 99, "threshold": 0.3})
assert cfg.threshold == 0.3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These can be removed.

assert restored.threshold == original.threshold
assert restored.speech_pad_ms == original.speech_pad_ms

def test_get(self) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be removed.

Comment on lines +71 to +91
def test_stage_properties(self) -> None:
stage = VADSegmentationStage()
assert stage.name == "VADSegmentation"
assert stage.inputs() == (["data"], [])
_, output_keys = stage.outputs()
for key in ["audio", "waveform", "sample_rate", "start_ms", "end_ms",
"segment_num", "duration_sec"]:
assert key in output_keys

def test_config_overrides_params(self) -> None:
cfg = VADConfig(min_duration_sec=3.0, max_duration_sec=20.0, threshold=0.7)
stage = VADSegmentationStage(config=cfg)
assert stage.min_duration_sec == 3.0
assert stage.max_duration_sec == 20.0
assert stage.threshold == 0.7

def test_ray_stage_spec_is_fanout(self) -> None:
stage = VADSegmentationStage()
spec = stage.ray_stage_spec()
from nemo_curator.backends.experimental.utils import RayStageSpecKeys
assert spec[RayStageSpecKeys.IS_FANOUT_STAGE] is True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These can be removed.

Comment on lines +45 to +55
@property
def min_interval_ms(self) -> int:
return _MIN_INTERVAL_MS

@property
def threshold(self) -> float:
return _THRESHOLD

@property
def speech_pad_ms(self) -> int:
return _SPEECH_PAD_MS

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

threshold, speech_pad_ms, and min_interval_ms are non-configurable read-only properties

These three parameters are declared as @property methods returning module-level constants, not as dataclass fields. This means they can never be configured by the caller, and the following all break at runtime:

  1. VADConfig(threshold=0.7)TypeError: __init__() got an unexpected keyword argument 'threshold'
  2. VADConfig.from_dict({"threshold": 0.6}) silently ignores "threshold" because __dataclass_fields__ only contains min_duration_sec and max_duration_sec
  3. to_dict() doesn't serialize them, so a round-trip always loses these values

This directly breaks multiple tests:

  • test_from_dict (line 36-39): calls from_dict({"threshold": 0.6}) and asserts cfg.threshold == 0.6 — always 0.5, fails
  • test_from_dict_ignores_unknown (line 46-47): asserts cfg.threshold == 0.3 after from_dict({"threshold": 0.3}) — always 0.5, fails
  • test_roundtrip (line 56): calls VADConfig(min_duration_sec=1.5, threshold=0.7, speech_pad_ms=200) — raises TypeError
  • test_config_overrides_params in the stage tests (line 81): calls VADConfig(threshold=0.7) — raises TypeError

These fields must be promoted to proper dataclass fields to be configurable:

@dataclass
class VADConfig:
    min_duration_sec: float = 2.0
    max_duration_sec: float = 60.0
    threshold: float = _THRESHOLD
    speech_pad_ms: int = _SPEECH_PAD_MS
    min_interval_ms: int = _MIN_INTERVAL_MS

And to_dict() must include them so the round-trip works correctly.

Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
from dataclasses import dataclass, field
from typing import Any, List, Dict, Tuple, Optional

import numpy as np

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import

numpy (import numpy as np) is imported at line 44 but never referenced anywhere in the file. This will trigger linting warnings and adds unnecessary overhead.

Suggested change
import numpy as np
import torch

Comment on lines +334 to +338
def _get_vad_segments(self, waveform: torch.Tensor, sample_rate: int) -> List[Dict[str, float]]:
"""Get speech segments using VAD."""
# Ensure waveform is 1D for VAD
if waveform.dim() > 1:
waveform = waveform.squeeze(0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

squeeze(0) silently no-ops on multi-channel waveforms

The intent here is to reduce a (1, N) waveform to (N,) for Silero VAD, which requires a 1-D tensor. However squeeze(0) only removes a dimension if its size is 1. For a stereo or multi-channel waveform with shape (C, N) where C > 1, squeeze(0) is a no-op and a 2-D tensor is silently passed to get_speech_timestamps. Silero VAD does not handle 2-D input correctly and will raise an error deep in the model.

A more defensive approach is to first convert to mono and then squeeze:

if waveform.dim() > 1:
    if waveform.shape[0] > 1:
        waveform = waveform.mean(dim=0)  # stereo → mono (N,)
    else:
        waveform = waveform.squeeze(0)   # (1, N) → (N,)

This is consistent with the existing mono-conversion logic in _load_audio_file.

Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
Comment on lines +357 to +358
resampler = torchaudio.transforms.Resample(orig_freq=sample_rate, new_freq=SILERO_TARGET_RATE)
vad_waveform = resampler(waveform_cpu).squeeze(0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Resampler is re-instantiated on every _get_vad_segments call

A new torchaudio.transforms.Resample object is constructed each time this method is called for a non-standard sample rate. In a pipeline processing many audio files at the same sample rate (e.g., all at 22050 Hz), this allocates and initialises a fresh transform — including its internal filter kernel — on every single call.

Consider caching the resampler as an instance attribute keyed by (orig_freq, new_freq), for example:

# In __post_init__ / __setstate__:
self._resamplers: dict[int, torchaudio.transforms.Resample] = {}

# In _get_vad_segments:
if sample_rate not in self._resamplers:
    self._resamplers[sample_rate] = torchaudio.transforms.Resample(
        orig_freq=sample_rate, new_freq=SILERO_TARGET_RATE
    )
resampler = self._resamplers[sample_rate]

Remember to also exclude _resamplers from __getstate__ (if pickling support is added) since Resample objects may not be picklable.

Comment thread nemo_curator/stages/audio/configs/vad.py Outdated
from silero_vad import load_silero_vad, get_speech_timestamps

# Suppress Silero VAD sample rate warning (48kHz -> 16kHz is expected)
warnings.filterwarnings('ignore', message='Sampling rate is a multiply of 16000')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Global warnings.filterwarnings is a process-wide side effect

warnings.filterwarnings('ignore', ...) is called at module import time and silences the matched warning globally for the entire Python process — not just within this file. Any other code (tests, user code, third-party libraries) that would legitimately emit a warning matching 'Sampling rate is a multiply of 16000' will also be silenced after this module is first imported.

The idiomatic way to suppress a known-noisy warning only at the specific call site is to use warnings.catch_warnings() as a context manager around the load_silero_vad() call:

import contextlib

with warnings.catch_warnings():
    warnings.filterwarnings('ignore', message='Sampling rate is a multiply of 16000')
    model = load_silero_vad()

This keeps the filter scoped to where it's actually needed.

# Sample rates like 22050 Hz need to be resampled to 16kHz
vad_sample_rate = sample_rate
vad_waveform = waveform
if sample_rate not in SILERO_SUPPORTED_RATES and sample_rate % 16000 != 0:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 sample_rate % 16000 != 0 silently bypasses resampling for unsupported rates

The condition sample_rate not in SILERO_SUPPORTED_RATES and sample_rate % 16000 != 0 means that any rate which is a multiple of 16000 but is not in SILERO_SUPPORTED_RATES (e.g. 80 000 Hz, 112 000 Hz) will skip resampling and be fed raw to Silero VAD. Silero's documented supported rates are only 8 kHz and 16 kHz (with the filter-bank supporting 8 k multiples up to 96 k in some versions), but very high rates like 80 kHz are not guaranteed to work and will silently produce incorrect timestamps or a runtime error deep inside the model.

The condition should be tightened to only skip resampling for rates that are explicitly known to be supported:

if sample_rate not in SILERO_SUPPORTED_RATES:
    # resample to 16 kHz for all unsupported rates
    ...

This also makes SILERO_SUPPORTED_RATES the single source of truth, instead of having a secondary % 16000 escape hatch.

Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
Comment on lines +355 to +365
resampler = torchaudio.transforms.Resample(orig_freq=sample_rate, new_freq=SILERO_TARGET_RATE)
vad_waveform = resampler(waveform_cpu).squeeze(0)
# Move back to original device
if device.type != 'cpu':
vad_waveform = vad_waveform.to(device)
vad_sample_rate = SILERO_TARGET_RATE

# Get speech timestamps using silero_vad package
speech_timestamps = get_speech_timestamps(
vad_waveform,
self.vad_model,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Waveform moved to device before resampling but device variable captures pre-move device

The code moves waveform to self._device before determining whether resampling is needed:

if self._device is not None and waveform.device != self._device:
    waveform = waveform.to(self._device)

vad_sample_rate = sample_rate
vad_waveform = waveform
if sample_rate not in SILERO_SUPPORTED_RATES and sample_rate % 16000 != 0:
    device = waveform.device   # ← captures self._device (GPU)
    waveform_cpu = waveform.cpu() if waveform.device.type != 'cpu' else waveform

device = waveform.device correctly captures self._device (since waveform was just moved there). The resampled tensor is then moved back to device (i.e., self._device). This is correct in the current code.

However, if self._device is None (e.g., before _initialize_model has been called, or if the model is not loaded), the waveform.to(self._device) guard is skipped, so device will be whatever device the input tensor lives on. If the caller passes a CPU tensor, everything is fine. But if _initialize_model was never called and a GPU tensor is passed, get_speech_timestamps will fail because the model is None.

Add an assertion or guard to ensure _initialize_model() has been called (and _device is set) before this method is invoked, to make the contract explicit.

Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
if self._vad_model is not None:
del self._vad_model
self._vad_model = None
torch.cuda.empty_cache()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 teardown calls torch.cuda.empty_cache() unconditionally

torch.cuda.empty_cache() is always called even when the model ran entirely on CPU (i.e., self._device == torch.device('cpu')). On CPU-only machines this is a no-op but adds unnecessary overhead on teardown. Consider guarding it:

Suggested change
torch.cuda.empty_cache()
def teardown(self) -> None:
"""Clean up resources."""
if self._vad_model is not None:
del self._vad_model
self._vad_model = None
if self._device is not None and self._device.type == "cuda":
torch.cuda.empty_cache()

Comment thread nemo_curator/stages/audio/configs/vad.py Outdated
Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
…load_audio_file, add ensure_waveform_2d in VAD processing
Comment on lines +265 to +277
if waveform is None or sample_rate is None:
audio_filepath = item.get('audio_filepath')
if audio_filepath and os.path.exists(audio_filepath):
try:
waveform, sample_rate = load_audio_file(audio_filepath)
item[self.waveform_key] = waveform
item[self.sample_rate_key] = sample_rate
except Exception as e:
logger.error(f"Failed to load audio file {audio_filepath}: {e}")
continue
else:
logger.error("Missing waveform/sample_rate and no valid audio_filepath provided")
continue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 User-provided waveform silently overwritten when sample_rate is missing

When waveform is not None but sample_rate is None, the condition on line 265 (waveform is None or sample_rate is None) is still True, so the code enters the block and calls load_audio_file(audio_filepath) unconditionally. This reloads the entire audio file and overwrites the user's waveform in item[self.waveform_key] with a freshly-decoded copy.

If the user provided a pre-processed waveform (e.g., already normalised, augmented, or synthesised — not matching the raw file on disk) and simply omitted sample_rate, that waveform is silently discarded and replaced by the raw file contents.

Notably, common.py already contains resolve_waveform_from_item, which handles this split case correctly: it reads only the sample rate from the file header (via soundfile.info) when a waveform is already present. The fix is to separate the two fallback branches:

if waveform is None:
    # No waveform at all — load from file
    audio_filepath = item.get('audio_filepath')
    if audio_filepath and os.path.exists(audio_filepath):
        try:
            waveform, sample_rate = load_audio_file(audio_filepath)
            item[self.waveform_key] = waveform
            item[self.sample_rate_key] = sample_rate
        except Exception as e:
            logger.error(f"Failed to load audio file {audio_filepath}: {e}")
            continue
    else:
        logger.error("Missing waveform/sample_rate and no valid audio_filepath provided")
        continue
elif sample_rate is None:
    # Waveform present but sample_rate missing — read only header
    audio_filepath = item.get('audio_filepath')
    if audio_filepath and os.path.exists(audio_filepath):
        try:
            import soundfile
            sample_rate = soundfile.info(audio_filepath).samplerate
            item[self.sample_rate_key] = sample_rate
        except Exception as e:
            logger.error(f"Waveform present but cannot read sample_rate from '{audio_filepath}': {e}")
            continue
    else:
        logger.error("Waveform present but 'sample_rate' missing and no audio_filepath available")
        continue

Comment thread nemo_curator/stages/audio/common.py
Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
Comment on lines +1 to +159
# Copyright (c) 2026, 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 unittest.mock import MagicMock, patch

import torch

from nemo_curator.stages.audio.segmentation.vad_segmentation import VADSegmentationStage
from nemo_curator.tasks import AudioBatch


class TestVADSegmentationStage:
"""Tests for VADSegmentationStage."""

@patch("nemo_curator.stages.audio.segmentation.vad_segmentation.get_speech_timestamps")
@patch("nemo_curator.stages.audio.segmentation.vad_segmentation.load_silero_vad")
def test_process_returns_segments(self, mock_load_vad, mock_get_ts) -> None:
mock_model = MagicMock()
mock_load_vad.return_value = mock_model

sr = 48000
mock_get_ts.return_value = [
{"start": 0, "end": sr * 3},
{"start": sr * 5, "end": sr * 8},
]

waveform = torch.randn(1, sr * 10)
batch = AudioBatch(
data=[{"waveform": waveform, "sample_rate": sr}],
task_id="test",
dataset_name="test",
)

stage = VADSegmentationStage(min_duration_sec=1.0, max_duration_sec=30.0)
stage.setup()
result = stage.process(batch)

assert isinstance(result, list)
assert len(result) == 2
for seg in result:
assert isinstance(seg, AudioBatch)
item = seg.data[0]
assert "waveform" in item
assert "start_ms" in item
assert "end_ms" in item
assert "segment_num" in item
assert "duration_sec" in item

@patch("nemo_curator.stages.audio.segmentation.vad_segmentation.get_speech_timestamps")
@patch("nemo_curator.stages.audio.segmentation.vad_segmentation.load_silero_vad")
def test_process_output_keys(self, mock_load_vad, mock_get_ts) -> None:
mock_load_vad.return_value = MagicMock()

sr = 48000
mock_get_ts.return_value = [{"start": 0, "end": sr * 5}]

waveform = torch.randn(1, sr * 10)
batch = AudioBatch(
data=[{"waveform": waveform, "sample_rate": sr}],
task_id="test",
dataset_name="test",
)

stage = VADSegmentationStage(min_duration_sec=1.0)
stage.setup()
result = stage.process(batch)

item = result[0].data[0]
assert item["start_ms"] == 0
assert item["segment_num"] == 0
assert item["duration_sec"] > 0
assert item["sample_rate"] == sr

@patch("nemo_curator.stages.audio.segmentation.vad_segmentation.get_speech_timestamps")
@patch("nemo_curator.stages.audio.segmentation.vad_segmentation.load_silero_vad")
def test_empty_speech_returns_empty(self, mock_load_vad, mock_get_ts) -> None:
mock_load_vad.return_value = MagicMock()
mock_get_ts.return_value = []

waveform = torch.randn(1, 48000 * 5)
batch = AudioBatch(
data=[{"waveform": waveform, "sample_rate": 48000}],
task_id="test",
dataset_name="test",
)

stage = VADSegmentationStage()
stage.setup()
result = stage.process(batch)

assert isinstance(result, list)
assert len(result) == 0

@patch("nemo_curator.stages.audio.segmentation.vad_segmentation.get_speech_timestamps")
@patch("nemo_curator.stages.audio.segmentation.vad_segmentation.load_silero_vad")
def test_segment_numbering(self, mock_load_vad, mock_get_ts) -> None:
mock_load_vad.return_value = MagicMock()

sr = 48000
mock_get_ts.return_value = [
{"start": 0, "end": sr * 2},
{"start": sr * 3, "end": sr * 5},
{"start": sr * 6, "end": sr * 8},
]

waveform = torch.randn(1, sr * 10)
batch = AudioBatch(
data=[{"waveform": waveform, "sample_rate": sr}],
task_id="test",
dataset_name="test",
)

stage = VADSegmentationStage(min_duration_sec=0.5)
stage.setup()
result = stage.process(batch)

assert len(result) == 3
for i, seg in enumerate(result):
assert seg.data[0]["segment_num"] == i

@patch("nemo_curator.stages.audio.segmentation.vad_segmentation.get_speech_timestamps")
@patch("nemo_curator.stages.audio.segmentation.vad_segmentation.load_silero_vad")
def test_missing_waveform_and_filepath_skipped(self, mock_load_vad, mock_get_ts) -> None:
mock_load_vad.return_value = MagicMock()

batch = AudioBatch(
data=[{"some_key": "value"}],
task_id="test",
dataset_name="test",
)

stage = VADSegmentationStage()
stage.setup()
result = stage.process(batch)

assert isinstance(result, list)
assert len(result) == 0

def test_pickling(self) -> None:
"""VADSegmentationStage should be picklable (for Ray workers)."""
import pickle

stage = VADSegmentationStage(min_duration_sec=2.0, threshold=0.6)
pickled = pickle.dumps(stage)
restored = pickle.loads(pickled)
assert restored.min_duration_sec == 2.0
assert restored.threshold == 0.6
assert restored._vad_model is None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing mode="batch" test coverage

The test suite only exercises the default mode="fanout" path. The mode="batch" path in process() has distinct control-flow branches — notably, it returns a single AudioBatch with all segments as items rather than a fan-out list — but none of these branches are tested. Key scenarios that are entirely uncovered include:

  • Normal operation: verify that process(batch) in mode="batch" returns a single AudioBatch whose data is a list of all segment dicts (not a fan-out list).
  • Empty segments: verify that an empty AudioBatch(data=[]) is returned (not []) when no speech is detected.
  • Model unavailable: verify the mode="batch" early-return path when _vad_model is None.

Without these tests, a regression in the batch mode code path would go undetected.

Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
Comment on lines +47 to +59
from silero_vad import load_silero_vad, get_speech_timestamps

# Silero VAD only supports 8kHz, 16kHz, and multiples of 16kHz (32k, 48k, etc.)
# Sample rates like 22050 Hz need to be resampled to 16kHz
SILERO_SUPPORTED_RATES = {8000, 16000, 32000, 48000, 64000, 96000}
SILERO_TARGET_RATE = 16000

from nemo_curator.backends.experimental.utils import RayStageSpecKeys
from nemo_curator.stages.audio.common import load_audio_file, ensure_waveform_2d
from nemo_curator.stages.base import ProcessingStage
from nemo_curator.stages.resources import Resources
from nemo_curator.tasks import AudioBatch
from nemo_curator.stages.audio.configs.vad import VADConfig

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Constants defined mid-import block

SILERO_SUPPORTED_RATES and SILERO_TARGET_RATE (lines 51–52) are defined between two groups of import statements — after the third-party silero_vad import and before the nemo_curator imports. PEP 8 recommends that module-level constants follow all imports, not be interleaved with them. This also confuses linters and import-ordering tools like isort.

Suggested change
from silero_vad import load_silero_vad, get_speech_timestamps
# Silero VAD only supports 8kHz, 16kHz, and multiples of 16kHz (32k, 48k, etc.)
# Sample rates like 22050 Hz need to be resampled to 16kHz
SILERO_SUPPORTED_RATES = {8000, 16000, 32000, 48000, 64000, 96000}
SILERO_TARGET_RATE = 16000
from nemo_curator.backends.experimental.utils import RayStageSpecKeys
from nemo_curator.stages.audio.common import load_audio_file, ensure_waveform_2d
from nemo_curator.stages.base import ProcessingStage
from nemo_curator.stages.resources import Resources
from nemo_curator.tasks import AudioBatch
from nemo_curator.stages.audio.configs.vad import VADConfig
from silero_vad import load_silero_vad, get_speech_timestamps
from nemo_curator.backends.experimental.utils import RayStageSpecKeys
from nemo_curator.stages.audio.common import load_audio_file, ensure_waveform_2d
from nemo_curator.stages.base import ProcessingStage
from nemo_curator.stages.resources import Resources
from nemo_curator.tasks import AudioBatch
from nemo_curator.stages.audio.configs.vad import VADConfig
# Silero VAD only supports 8kHz, 16kHz, and multiples of 16kHz (32k, 48k, etc.)
# Sample rates like 22050 Hz need to be resampled to 16kHz
SILERO_SUPPORTED_RATES = {8000, 16000, 32000, 48000, 64000, 96000}
SILERO_TARGET_RATE = 16000

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +28 to +58
def test_process_returns_segments(self, mock_load_vad, mock_get_ts) -> None:
mock_model = MagicMock()
mock_load_vad.return_value = mock_model

sr = 48000
mock_get_ts.return_value = [
{"start": 0, "end": sr * 3},
{"start": sr * 5, "end": sr * 8},
]

waveform = torch.randn(1, sr * 10)
batch = AudioBatch(
data=[{"waveform": waveform, "sample_rate": sr}],
task_id="test",
dataset_name="test",
)

stage = VADSegmentationStage(min_duration_sec=1.0, max_duration_sec=30.0)
stage.setup()
result = stage.process(batch)

assert isinstance(result, list)
assert len(result) == 2
for seg in result:
assert isinstance(seg, AudioBatch)
item = seg.data[0]
assert "waveform" in item
assert "start_ms" in item
assert "end_ms" in item
assert "segment_num" in item
assert "duration_sec" in item

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Mock timestamps are in sample units but no resampling is triggered

mock_get_ts.return_value uses raw sample-count values (e.g. {"start": 0, "end": sr * 3} where sr = 48000). Since 48 000 Hz is in SILERO_SUPPORTED_RATES, the code does not resample and correctly divides each timestamp by vad_sample_rate (= 48000) to convert to seconds. The resulting seconds are then multiplied back by the original sample_rate to obtain start_sample/end_sample.

However, the round-trip (sr_value / vad_sample_rate) * sample_rate only produces correct slice boundaries because the input sample rate and vad_sample_rate happen to be the same (no resampling). The test does not cover the resampling path (e.g. sr = 22050), where vad_sample_rate becomes SILERO_TARGET_RATE = 16000 and the timestamp-to-sample conversion uses a different ratio. Without a test for this case, a regression in the resampling timestamp conversion (such as computing ts['start'] / vad_sample_rate * sample_rate vs. ts['start'] / sample_rate) would go undetected.

Consider adding a test that uses a non-supported sample rate (e.g. 22050 Hz) to exercise the resampling path and verify that segment start_ms/end_ms and waveform slice boundaries are correct.

@sarahyurick

Copy link
Copy Markdown
Contributor

/ok to test 2de1a9d

@shubhamNvidia

Copy link
Copy Markdown
Contributor Author

/ok to test 4088c20

@shubhamNvidia

Copy link
Copy Markdown
Contributor Author

/ok to test 46eb1d8

@shubhamNvidia

Copy link
Copy Markdown
Contributor Author

/ok to test c26094d

@shubhamNvidia

Copy link
Copy Markdown
Contributor Author

/ok to test 6e650d8

@shubhamNvidia

Copy link
Copy Markdown
Contributor Author

/ok to test e240495

Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
Comment thread tests/stages/audio/segmentation/test_vad_segmentation.py Outdated
Comment thread tests/stages/audio/segmentation/test_vad_segmentation.py Outdated
Comment thread tests/stages/audio/segmentation/test_vad_segmentation.py Outdated
@shubhamNvidia

Copy link
Copy Markdown
Contributor Author

/ok to test ef09da8

Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
Comment thread tests/stages/audio/segmentation/test_vad_segmentation.py Outdated
Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
Comment thread nemo_curator/stages/audio/segmentation/vad_segmentation.py Outdated
Co-authored-by: Sarah Yurick <53962159+sarahyurick@users.noreply.github.com>
Signed-off-by: Sarah Yurick <53962159+sarahyurick@users.noreply.github.com>
Signed-off-by: Sarah Yurick <53962159+sarahyurick@users.noreply.github.com>
Comment thread tests/stages/audio/segmentation/test_vad_segmentation.py
Signed-off-by: Sarah Yurick <53962159+sarahyurick@users.noreply.github.com>
@sarahyurick

Copy link
Copy Markdown
Contributor

/ok to test 835cd3e

@sarahyurick

Copy link
Copy Markdown
Contributor

Hi @shubhamNvidia it looks like the audio CPU tests are blocked by needing a GPU dependency. You can mark GPU-only tests with @pytest.mark.gpu, or if you want it to still be a CPU test then you may have to update the pyproject.toml file. Thanks!

Signed-off-by: shbhawsar <shbhawsar@nvidia.com>
Signed-off-by: shbhawsar <shbhawsar@nvidia.com>
@shubhamNvidia

Copy link
Copy Markdown
Contributor Author

/ok to test baee33b


name: str = "VADSegmentation"
batch_size: int = 1
resources: Resources = field(default_factory=lambda: Resources(cpus=1.0, gpus=0.3))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VADSegmentationStage in nemo_curator/stages/audio/segmentation/vad_segmentation.py defaults to resources=Resources(cpus=1.0, gpus=0.3). On a CPU-only machine, _check_gpu_availability() raises a hard RuntimeError.

Silero VAD is lightweight (~1 MB model) and runs efficiently on CPU. Either:
(a) Default to gpus=0 and let users opt into GPU, or
(b) Change _check_gpu_availability from hard error to graceful CPU fallback:

if gpus > 0 and not torch.cuda.is_available():
    logger.warning("GPU requested but CUDA not available, falling back to CPU")
    


audio_result = self._resolve_audio(task.data)
if audio_result is None:
return []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In nested mode (nested=True), ray_stage_spec() returns {} (no fan-out), implying a 1:1 input→output contract. But when no speech is detected, process() returns [] regardless of the nested flag, dropping the task entirely.

This breaks the 1:1 contract that nested mode implies. Downstream stages (e.g., SegmentConcatenationStage in AudioDataFilterStage) never see the task.

For nested mode with no speech, return the task with empty segments instead:

if not segments:
    if self.nested:
        task.data["segments"] = []
        return task
    return []

@shubhamNvidia

Copy link
Copy Markdown
Contributor Author

/ok to test 599f658

- Default gpus=0.0 (CPU) for VADSegmentationStage since Silero VAD is
  lightweight; users opt into GPU via .with_(resources=Resources(gpus=X))
- In nested mode, return task with empty segments list instead of []
  when no speech is detected, preserving the 1:1 input-output contract
- Update test to match new nested no-speech behavior
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants