Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
2c80669
Add SIGMOSFilterStage and ensure_cudnn_loaded utility
shubhamNvidia Mar 5, 2026
5faad7a
Add SIGMOS model files
shubhamNvidia Mar 5, 2026
e64f75b
Add unit tests for SIGMOSFilterStage
shubhamNvidia Mar 5, 2026
f3f8928
Simplified config, removed temp file creation, uses in-memory predict…
shubhamNvidia Mar 11, 2026
01dc37e
Guard GPU fallback, delegate GPU detection to stage, replace print wi…
shubhamNvidia Mar 12, 2026
ffd5888
Fix copyrights, restore upstream gpu_utils and added ensure cudnn fun…
shubhamNvidia Mar 20, 2026
be96268
Add common utilities, replace duplicate _resolve_model_path in sigmos.py
shubhamNvidia Mar 22, 2026
fda7068
Add AudioDataFilterStage composite pipeline with VAD, quality filteri…
shubhamNvidia Mar 22, 2026
ab83471
change resource allocation pattern
shubhamNvidia Mar 22, 2026
24834bd
change resource allocation pattern
shubhamNvidia Mar 22, 2026
cfc3cdc
Fix CompositeStage example, make TimestampMapper conditional on segme…
shubhamNvidia Mar 23, 2026
dfc6ae9
Remove SIGMOSConfig, modernize types, untrack ADV test
shubhamNvidia Mar 24, 2026
042f36b
Replace AudioDataFilterConfig with YAML config, resolve PR review com…
shubhamNvidia Mar 24, 2026
7dacb2e
Merge upstream/main into pr/audio-sigmos
shubhamNvidia Mar 25, 2026
802fa41
Merge remote-tracking branch 'upstream/main' into pr/advance-pipeline
shubhamNvidia Mar 25, 2026
b2c48cf
Merge branch 'main' into pr/audio-sigmos
sarahyurick Mar 25, 2026
64e09ed
Merge branch 'main' into pr/advance-pipeline
sarahyurick Mar 25, 2026
e910433
Fix ruff lint violations in audio sigmos filter stage
shubhamNvidia Mar 25, 2026
65533a6
Fix ruff lint violations in advance pipeline stage
shubhamNvidia Mar 25, 2026
9e3c559
Migrate to AudioTask single-dict pattern
shubhamNvidia Mar 26, 2026
9acf672
Migrate to AudioTask single-dict pattern
shubhamNvidia Mar 26, 2026
3840328
Merge upstream/main, resolve conflicts keeping utility functions
shubhamNvidia Mar 26, 2026
250c67f
Add nested segments handling to SIGMOSFilterStage
shubhamNvidia Mar 30, 2026
fd740de
Merge branch 'main' into pr/advance-pipeline
shubhamNvidia Mar 30, 2026
6912606
Merge branch 'main' into pr/audio-sigmos
sarahyurick Mar 31, 2026
b4035d5
Merge branch 'main' into pr/advance-pipeline
sarahyurick Mar 31, 2026
8eb54e7
Add AudioDataFilterStage export to audio stages __init__.py
shubhamNvidia Apr 1, 2026
65730b0
fix ruff linting errors in sigmos files
shubhamNvidia Mar 31, 2026
37d2a80
Merge upstream/main into pr/advance-pipeline
shubhamNvidia Apr 1, 2026
de20c46
Merge upstream/main into pr/audio-sigmos
shubhamNvidia Apr 1, 2026
672dfe0
fix: lazy import predict_audio_mos to avoid onnxruntime import at col…
shubhamNvidia Apr 1, 2026
a1f3c82
fix: guard sigmos import to avoid ImportError during test collection
shubhamNvidia Apr 1, 2026
3747312
Merge branch 'main' into pr/audio-sigmos
shubhamNvidia Apr 2, 2026
42c2e29
Merge branch 'main' into pr/advance-pipeline
shubhamNvidia Apr 2, 2026
e089988
Merge remote-tracking branch 'upstream/main' into pr/audio-sigmos
shubhamNvidia Apr 2, 2026
2380e51
Merge remote-tracking branch 'upstream/main' into pr/advance-pipeline
shubhamNvidia Apr 2, 2026
b3228af
Merge branch 'main' into pr/audio-sigmos
shubhamNvidia Apr 2, 2026
02d3580
fix: move _deep_merge to top-level import
shubhamNvidia Apr 2, 2026
25a5956
Merge branch 'main' into pr/advance-pipeline
shubhamNvidia Apr 2, 2026
32215b4
Merge upstream/main, resolve conflicts keeping both changes
shubhamNvidia Apr 3, 2026
6beb62b
Merge upstream/main, resolve __init__.py keeping both changes
shubhamNvidia Apr 3, 2026
a0022ea
Merge upstream/main and address reviewer feedback for SIGMOS
shubhamNvidia Apr 5, 2026
75809cc
Remove bundled ONNX model, auto-download from Microsoft GitHub
shubhamNvidia Apr 5, 2026
d0936c4
Address review feedback: guard teardown, add deps, remove dead code
shubhamNvidia Apr 5, 2026
f297f01
Merge branch 'main' into pr/audio-sigmos
sarahyurick Apr 6, 2026
7e6be59
Merge upstream/main into pr/advance-pipeline, resolve __init__.py con…
shubhamNvidia Apr 6, 2026
ec072cb
Merge origin/pr/audio-sigmos into pr/advance-pipeline, resolve __init…
shubhamNvidia Apr 6, 2026
e18eb77
Address review feedback: rename dirs, add validations, fix bugs, add …
shubhamNvidia Apr 6, 2026
47372e3
Fix ruff lint: sort imports, remove unused imports, escape regex, ext…
shubhamNvidia Apr 6, 2026
abeca1e
Merge branch 'main' into pr/advance-pipeline
shubhamNvidia Apr 6, 2026
f1696f2
Address test review: remove redundant tests, move pickle import to to…
shubhamNvidia Apr 6, 2026
1ea1455
Merge upstream/main into pr/advance-pipeline, resolve conflicts
shubhamNvidia Apr 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions nemo_curator/stages/audio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@

This module provides stages for processing and curating audio data,
including ASR inference, quality assessment, ALM data preparation,
VAD segmentation, bandwidth classification filtering,
audio preprocessing (mono conversion, segment concatenation, timestamp mapping),
audio quality filtering (SIGMOS, UTMOS), and speaker diarization/separation.
audio quality filtering (SIGMOS, UTMOS, bandwidth classification filtering),
VAD segmentation, speaker diarization/separation,
and advanced audio processing pipelines.
"""

from nemo_curator.stages.audio.advanced_pipelines import AudioDataFilterStage
from nemo_curator.stages.audio.alm import ALMDataBuilderStage, ALMDataOverlapStage
from nemo_curator.stages.audio.common import (
GetAudioDurationStage,
Expand All @@ -47,6 +49,7 @@
__all__ = [
"ALMDataBuilderStage",
"ALMDataOverlapStage",
"AudioDataFilterStage",
"BandFilterStage",
"GetAudioDurationStage",
"MonoConversionStage",
Expand Down
50 changes: 50 additions & 0 deletions nemo_curator/stages/audio/advanced_pipelines/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.

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 Module name advance_pipelines appears to be a typo

The directory (and therefore the public import path) is advance_pipelines, but the conventional English adjective form would be advanced_pipelines. Since this becomes a public API path (nemo_curator.stages.audio.advance_pipelines), fixing the typo now (before this is shipped) would avoid a breaking rename later. All four new files and any future additions to this package would need to move to nemo_curator/stages/audio/advanced_pipelines/.

#
# 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.

"""
Advanced Audio Processing Pipelines.

This module provides composite pipeline stages that combine multiple
audio processing steps into single, easy-to-use stages.

Available Pipelines:
- audio_data_filter: Audio Data Filter pipeline with VAD,
quality filtering (UTMOS, SIGMOS, Band), speaker separation,
and timestamp tracking.

Example::

from nemo_curator.stages.audio.advanced_pipelines import (
AudioDataFilterStage,
)

# Using default config (all stages enabled)
pipeline.add_stage(AudioDataFilterStage())

# Using custom YAML config
pipeline.add_stage(AudioDataFilterStage(config_path="my_config.yaml"))

# Using dict overrides
pipeline.add_stage(AudioDataFilterStage(
config={"utmos": {"mos_threshold": 4.0}},
))
Comment on lines +39 to +41

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 Dict literal passed to dataclass config parameter causes AttributeError

AudioDataFilterStage.config is typed as AudioDataFilterConfig (a dataclass). Passing a raw dict such as {"utmos": {"mos_threshold": 4.0}} does not raise a TypeError at construction, but when decompose() is called it accesses cfg.utmos_mos_threshold, cfg.sample_rate, cfg.get_enabled_filters(), etc. — all attribute accesses that will immediately raise AttributeError on a plain dict.

Any user copying this "Using dict overrides" example verbatim will get a runtime crash. Either:

  1. Remove the dict-override example and replace it with a correct AudioDataFilterConfig instantiation, or
  2. Add a __post_init__ conversion that accepts a dict | AudioDataFilterConfig and coerces dicts to the dataclass.
Suggested change
pipeline.add_stage(AudioDataFilterStage(
config={"utmos": {"mos_threshold": 4.0}},
))
# Using programmatic config overrides
pipeline.add_stage(AudioDataFilterStage(
config=AudioDataFilterConfig(utmos_mos_threshold=4.0),
))

"""

from nemo_curator.stages.audio.advanced_pipelines.audio_data_filter import (
AudioDataFilterStage,
)

__all__ = [
"AudioDataFilterStage",
]
Comment thread
shubhamNvidia marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 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.

"""
Audio Data Filter pipeline.

Composite pipeline stage for audio curation with VAD,
quality filtering, speaker separation, and timestamp tracking.

Example::

from nemo_curator.stages.audio.advanced_pipelines import (
AudioDataFilterStage,
)

# Using default config
pipeline.add_stage(AudioDataFilterStage())

# Using custom YAML config
pipeline.add_stage(AudioDataFilterStage(config_path="my_config.yaml"))

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 config_path parameter does not exist on AudioDataFilterStage

The docstring advertises AudioDataFilterStage(config_path="my_config.yaml") as a valid constructor call, but AudioDataFilterStage is a @dataclass with only two fields: config: AudioDataFilterConfig and name: str. There is no config_path parameter. Any user who copies this example will receive:

TypeError: AudioDataFilterStage.__init__() got an unexpected keyword argument 'config_path'

The same broken example also appears in advance_pipelines/__init__.py line 36.

Either:

  1. Add a config_path: Optional[str] = None field to the dataclass and wire it through __post_init__ to call load_config(config_path) and build the AudioDataFilterConfig, or
  2. Update the docstring to show the correct constructor pattern using the config kwarg.

"""

from .audio_data_filter import AudioDataFilterStage

__all__ = [
"AudioDataFilterStage",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
Comment thread
shubhamNvidia marked this conversation as resolved.
#
# 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.

"""
Audio Data Filter Stage -- CompositeStage that decomposes into independent
pipeline stages for extracting clean single-speaker segments.

Pipeline (when all filters + speaker separation enabled)::

1. MonoConversion (1:1)
2. VAD batch mode (1:1, items = N segments)
3. BandFilter (1:1, filter items)
Comment thread
shubhamNvidia marked this conversation as resolved.
4. UTMOS (1:1, filter items)
5. SIGMOS (1:1, filter items)
6. SegmentConcatenation (1:1, M items -> 1 item + timestamp mappings)
7. SpeakerSeparation (1:N fan-out)
8-11. Per-speaker: VAD + Band + UTMOS + SIGMOS
12. TimestampMapper (1:1, resolve to original file positions)

Usage::

# Using default config
pipeline.add_stage(AudioDataFilterStage())

# Using custom YAML config
pipeline.add_stage(AudioDataFilterStage(config_path="/path/to/config.yaml"))
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from pathlib import Path

from loguru import logger

from nemo_curator.stages.audio.filtering import BandFilterStage, SIGMOSFilterStage, UTMOSFilterStage
from nemo_curator.stages.audio.postprocessing import TimestampMapperStage
from nemo_curator.stages.audio.preprocessing import MonoConversionStage, SegmentConcatenationStage
from nemo_curator.stages.audio.segmentation import SpeakerSeparationStage, VADSegmentationStage
from nemo_curator.stages.base import CompositeStage, ProcessingStage
from nemo_curator.stages.resources import Resources

from .config import _deep_merge, get_enabled_stages, load_config


class AudioDataFilterStage(CompositeStage):
"""Complete audio data filtering and curation pipeline (CompositeStage).

Decomposes into independent stages that the executor can schedule with
cross-file parallelism. Each stage owns its own default resource
allocation. Use ``.with_()`` to override individual stage resources.

Args:
config_path: Path to a YAML config file. When *None* the
built-in ``default_config.yaml`` is used.
config: Pre-loaded config dict (alternative to *config_path*).
When both are given, *config* values override the YAML file.
name: Name for this composite stage instance.
"""

def __init__(
self,
config_path: str | Path | None = None,
config: dict[str, Any] | None = None,
name: str = "AudioDataFilter",
) -> None:
super().__init__()
self.name = name
self._cfg = load_config(config_path)
if config:
self._cfg = _deep_merge(self._cfg, config)
Comment on lines +83 to +84

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 _validate bypassed for dict-override path

When a caller passes config={"vad": {"min_duration_sec": 100, "max_duration_sec": 1}} (with no config_path), the flow is:

  1. load_config(None) returns defaults — _validate is not called (the early return at config.py:82 exits before _validate).
  2. _deep_merge merges the invalid overrides on top.
  3. No validation occurs on the result.

The inverted VAD bounds will then propagate silently into VADSegmentationStage and surface as a cryptic downstream failure rather than a clear ValueError at construction time.

Call _validate on the merged result after the dict override:

if config:
    from .config import _deep_merge, _validate
    self._cfg = _deep_merge(self._cfg, config)
    _validate(self._cfg)

Comment on lines +83 to +84

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 _validate not called after dict-override merge

When a caller supplies the config dict override, _deep_merge is applied on top of the YAML defaults but _validate is never invoked on the result. A caller can pass {"vad": {"min_duration_sec": 100, "max_duration_sec": 1}} and the inverted VAD bounds will propagate silently into VADSegmentationStage, producing a cryptic downstream error instead of a clear ValueError at construction time.

_validate should be called on the merged result:

if config:
    from .config import _deep_merge, _validate

    self._cfg = _deep_merge(self._cfg, config)
    _validate(self._cfg)


def decompose(self) -> list[ProcessingStage]:
cfg = self._cfg
stages: list[ProcessingStage] = []

mc = cfg.get("mono_conversion", {})
stages.append(
MonoConversionStage(

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.

MonoConversionStage is always added unconditionally — there's no enable flag for it, unlike every other stage (VAD, Band, UTMOS, SIGMOS, SpeakerSep all have enable toggles in the YAML config).

If audio is already mono at the correct sample rate, this is wasted processing. For consistency, add enable: true to the mono_conversion section in default_config.yaml and guard the append in decompose().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

MonoConversion is intentionally unconditional because it also serves as the audio loader — it reads the file, populates waveform, sample_rate, duration, and num_samples into the task data. Disabling it would force each downstream stage to independently load from audio_filepath, causing redundant file I/O and risking failures on multi-channel files. The strict_sample_rate check acts as a guard against corrupted or mismatched files in large-scale datasets that would otherwise produce silent errors downstream. The operation is lightweight (CPU-only, no GPU), so keeping it always-on prevents failures at negligible overhead.

output_sample_rate=mc.get("output_sample_rate", 48000),
strict_sample_rate=mc.get("strict_sample_rate", True),
name="MonoConversion",
resources=Resources(cpus=mc.get("cpus", 1.0)),
)
)

vad = cfg.get("vad", {})
band = cfg.get("band_filter", {})
utmos = cfg.get("utmos", {})
sigmos = cfg.get("sigmos", {})
speaker = cfg.get("speaker_separation", {})
concat = cfg.get("concatenation", {})
ts = cfg.get("timestamp_mapper", {})

enable_vad = vad.get("enable", True)
enable_band = band.get("enable", True)
enable_utmos = utmos.get("enable", True)
enable_sigmos = sigmos.get("enable", True)
enable_speaker = speaker.get("enable", True)

self._append_filter_stages(
stages,
vad,
band,
utmos,
sigmos,
enable_vad,
enable_band,
enable_utmos,
enable_sigmos,
suffix="",
)

if enable_speaker:
if enable_vad:
stages.append(
SegmentConcatenationStage(
silence_duration_sec=concat.get("silence_duration_sec", 0.5),
name="SegmentConcat",
resources=Resources(cpus=concat.get("cpus", 1.0)),
)
)

stages.append(
SpeakerSeparationStage(
exclude_overlaps=speaker.get("exclude_overlaps", True),
min_duration=speaker.get("min_duration", 0.8),
gap_threshold=speaker.get("gap_threshold", 0.1),
buffer_time=speaker.get("buffer_time", 0.5),
name="SpeakerSeparation",
resources=Resources(
cpus=speaker.get("cpus", 1.0),
gpus=speaker.get("gpus", 1.0),
),
)
)

self._append_filter_stages(
stages,
vad,
band,
utmos,

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.

When speaker separation is enabled (the default), the full filter chain (VAD → BandFilter → UTMOS → SIGMOS) runs TWICE:

  1. First pass: filters on the raw audio segments
  2. After SpeakerSeparation: same filters again on per-speaker segments

UTMOS and SIGMOS are GPU-inference stages. Running them twice doubles the GPU compute cost. This is especially wasteful because audio quality metrics (MOS scores, noise, bandwidth) don't fundamentally change between pre-speaker and post-speaker segments — the waveform content is the same, just attributed to different speakers.

With defaults, AudioDataFilterStage() decomposes into 12 stages including redundant GPU inference.

Consider making the second filter pass optional via a config flag (post_speaker_refilter: true/false), or document why double filtering is architecturally necessary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The double filter pass is intentional — after speaker separation, the per-speaker waveforms are different from the original mixed audio. VAD on isolated single-speaker audio produces different segment boundaries, and quality scores (UTMOS, SIGMOS) on separated speech are more representative of actual per-speaker signal quality. This was validated through extensive analysis on our test sets.

sigmos,
enable_vad,
enable_band,
enable_utmos,
enable_sigmos,
suffix="_Speaker",
)

if enable_vad or enable_speaker:
stages.append(
TimestampMapperStage(
passthrough_keys=ts.get("passthrough_keys"),
name="TimestampMapper",
resources=Resources(cpus=ts.get("cpus", 1.0)),
)
)

enabled = get_enabled_stages(cfg)
logger.info(
f"AudioDataFilterStage decomposed into {len(stages)} stages "
f"(enabled: {enabled}, speaker_sep: {enable_speaker})"
)
return stages

@staticmethod
def _append_filter_stages( # noqa: PLR0913
stages: list[ProcessingStage],
vad: dict,
band: dict,
utmos: dict,
sigmos: dict,
enable_vad: bool,
enable_band: bool,
enable_utmos: bool,
enable_sigmos: bool,
*,
suffix: str,
) -> None:
"""Append VAD + quality filter stages to *stages* list."""
if enable_vad:
# Pre-speaker pass (suffix==""): nested=True so VAD stores segments
# inside the task for SegmentConcatenation to merge.
# Post-speaker pass (suffix=="_Speaker"): nested=False so VAD fans
# out into separate tasks for independent downstream processing.
stages.append(
VADSegmentationStage(
min_duration_sec=vad.get("min_duration_sec", 2.0),
max_duration_sec=vad.get("max_duration_sec", 60.0),
threshold=vad.get("threshold", 0.5),
min_interval_ms=vad.get("min_interval_ms", 500),
speech_pad_ms=vad.get("speech_pad_ms", 300),
nested=(suffix == ""),
Comment thread
shubhamNvidia marked this conversation as resolved.
name=f"VAD{suffix}",
resources=Resources(
cpus=vad.get("cpus", 1.0),
gpus=vad.get("gpus", 0.3),
),
)
)

if enable_band:
stages.append(
BandFilterStage(
band_value=band.get("band_value", "full_band"),
name=f"BandFilter{suffix}",
resources=Resources(
cpus=band.get("cpus", 1.0),
gpus=band.get("gpus", 0.0),
),
)
)

if enable_utmos:
stages.append(
UTMOSFilterStage(
mos_threshold=utmos.get("mos_threshold", 3.5),
name=f"UTMOS{suffix}",
resources=Resources(
cpus=utmos.get("cpus", 1.0),
gpus=utmos.get("gpus", 0.5),
),
)
)

if enable_sigmos:
stages.append(
SIGMOSFilterStage(
noise_threshold=sigmos.get("noise_threshold", 4.0),
ovrl_threshold=sigmos.get("ovrl_threshold", 3.5),
sig_threshold=sigmos.get("sig_threshold"),
col_threshold=sigmos.get("col_threshold"),
disc_threshold=sigmos.get("disc_threshold"),
loud_threshold=sigmos.get("loud_threshold"),
reverb_threshold=sigmos.get("reverb_threshold"),
name=f"SIGMOS{suffix}",
resources=Resources(
cpus=sigmos.get("cpus", 1.0),
gpus=sigmos.get("gpus", 0.5),
),
)
)
Loading
Loading