Skip to content

Add audio preprocessing stages (MonoConversion, SegmentConcatenation, TimestampMapper) - #1575

Merged
sarahyurick merged 25 commits into
NVIDIA-NeMo:mainfrom
shubhamNvidia:pr/audio-foundation
Apr 2, 2026
Merged

Add audio preprocessing stages (MonoConversion, SegmentConcatenation, TimestampMapper)#1575
sarahyurick merged 25 commits into
NVIDIA-NeMo:mainfrom
shubhamNvidia:pr/audio-foundation

Conversation

@shubhamNvidia

Copy link
Copy Markdown
Contributor

Adds foundational audio preprocessing stages for the ADV pipeline:
MonoConversionStage: Converts multi-channel audio to mono and verifies sample rate, with strict/non-strict sample rate enforcement.
SegmentConcatenationStage: Concatenates multiple audio segments with configurable silence gaps between them.
Config dataclasses (MonoConversionConfig, SegmentConcatenationConfig) with from_dict/to_dict support.
Unit tests with mocked I/O covering config, stage properties, process logic, and edge cases.

@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 three foundational audio preprocessing/postprocessing stages (MonoConversionStage, SegmentConcatenationStage, TimestampMapperStage) along with shared utilities in common.py and unit tests. The overall architecture is clean and the previous review concerns around channel-count mismatches, sample-rate mismatches, and test mock scoping have all been addressed.

Two logic issues remain in TimestampMapperStage:

  • item.get(\"end_ms\", 0) silently drops any task where end_ms is absent and start_ms is 0, because 0 <= 0 triggers the "invalid range" early-return.
  • _build_output_item_no_mapping can return duration_ms=0 (or negative) without logging a warning when all three duration fallbacks fail.

Confidence Score: 4/5

Safe to merge after addressing the two TimestampMapperStage logic issues; the preprocessing stages themselves are solid.

Two P1 logic bugs in TimestampMapperStage: tasks with start_ms=0 and no end_ms key are silently dropped, and zero/negative durations can be emitted without a warning. These affect correctness on real data where end_ms may be absent or where no duration fallback succeeds. The preprocessing stages (MonoConversion, SegmentConcatenation) are well-implemented and all prior review concerns have been resolved.

nemo_curator/stages/audio/postprocessing/timestamp_mapper.py — two logic issues in process() and _build_output_item_no_mapping.

Important Files Changed

Filename Overview
nemo_curator/stages/audio/common.py Adds load_audio_file, ensure_waveform_2d, ensure_mono, resolve_waveform_from_item, and resolve_model_path utilities; resolve_waveform_from_item and resolve_model_path are not referenced by any stage in this PR (dead code), and resolve_waveform_from_item mutates its item argument in-place as documented.
nemo_curator/stages/audio/preprocessing/mono_conversion.py New stage; converts multi-channel audio to mono and enforces sample-rate policy. Clean, well-tested implementation with proper error handling.
nemo_curator/stages/audio/preprocessing/concatenation.py New stage; concatenates nested VAD segments with configurable silence gaps and writes segment mappings to _metadata. Correctly guards channel-count mismatches and warns on sample-rate mismatches.
nemo_curator/stages/audio/postprocessing/timestamp_mapper.py New stage; maps concatenated-waveform positions back to original-file timestamps. Two logic issues: tasks with start_ms=0 and no end_ms key are silently dropped due to end_ms defaulting to 0, and _build_output_item_no_mapping can return duration_ms=0 (or negative) without logging a warning.
tests/stages/audio/preprocessing/test_mono_conversion.py Good test coverage; mocks are correctly scoped to the module under test (MOCK_EXISTS = "...mono_conversion.os.path.exists").
tests/stages/audio/preprocessing/test_concatenation.py Covers basic concatenation, silence insertion, empty/missing inputs, and error paths. No mocking issues.
tests/stages/audio/postprocessing/test_timestamp_mapper.py Thorough coverage of mapping paths, cross-boundary rejection, passthrough filtering, and edge cases.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[AudioTask\naudio_filepath] --> B[MonoConversionStage\nload file → mono waveform]
    B -->|strict_sample_rate check| C{sample rate OK?}
    C -->|no| D[drop task]
    C -->|yes| E[AudioTask\nwaveform + sample_rate + is_mono]

    E --> F[VADSegmentationStage\nnested=True]
    F --> G[AudioTask\ndata.segments list]

    G --> H[SegmentConcatenationStage\nsort → cat with silence]
    H -->|no valid parts| I[drop task]
    H -->|ok| J[AudioTask\ncombined waveform\n_metadata.segment_mappings]

    J --> K[ASR / Quality\n/ Speaker stages]
    K --> L[AudioTask\nstart_ms, end_ms per segment]

    L --> M[TimestampMapperStage\ntranslate concat coords\nto original file coords]
    M -->|spans 2+ mappings| N[drop task]
    M -->|in silence gap| N
    M -->|ok| O[AudioTask\noriginal_file\noriginal_start/end_ms\nduration_sec]
Loading

Comments Outside Diff (2)

  1. nemo_curator/stages/audio/postprocessing/timestamp_mapper.py, line 110-116 (link)

    Missing end_ms silently drops valid tasks

    item.get("end_ms", 0) defaults to 0 when the key is absent. A task whose start_ms is also 0 (or absent) will hit concat_end <= concat_start (0 <= 0) and be discarded with just a warning, even though start_ms=0 is a perfectly valid segment start. Any task coming in without an end_ms key — including the first segment of a file that genuinely starts at offset 0 — will be silently dropped.

    Use a sentinel default (e.g. None) so that the "missing key" case can be distinguished from the "key is explicitly 0" case:

    concat_start = item.get("start_ms", 0)
    concat_end = item.get("end_ms")
    if concat_end is None:
        logger.warning(
            f"[TimestampMapper] Skipping task with missing 'end_ms'"
        )
        return []
    if concat_end <= concat_start:
        logger.warning(
            f"[TimestampMapper] Skipping task with invalid range: start_ms={concat_start}, end_ms={concat_end}"
        )
        return []
  2. nemo_curator/stages/audio/postprocessing/timestamp_mapper.py, line 159-180 (link)

    Silent zero/negative duration_ms in no-mapping fallback path

    When all three fallbacks fail — end_ms - start_ms <= 0, no duration/duration_sec, and no waveform — the function still returns a dict with duration_ms=0 (or even a negative value if start_ms > end_ms) and duration_sec=0.0, without logging any warning. Downstream consumers that divide by duration or filter on duration_sec > 0 will silently receive corrupt output.

    At minimum, add a warning and guard when no fallback succeeds:

    if duration_ms <= 0:
        logger.warning(
            f"[TimestampMapper] Could not determine a positive duration for task; "
            f"start_ms={start_ms}, end_ms={end_ms}. Emitting duration_ms=0."
        )

Reviews (29): Last reviewed commit: "Merge branch 'main' into pr/audio-founda..." | Re-trigger Greptile

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


@dataclass
class SegmentConcatenationConfig:

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.



@dataclass
class MonoConversionConfig:

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 on lines +141 to +143
dataset_name=first_task.dataset_name,
_metadata=first_task._metadata,
_stage_perf=list(first_task._stage_perf),

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 should not use only the first task for this.

logger.error(f"Error concatenating segments: {e}")
return []

def process(self, task: AudioBatch) -> Optional[AudioBatch]:

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 should not need to implement process if we are implementing process_batch.

expected = 1.0 + 2.0 + 1.0
assert abs(combined_duration_sec - expected) < 0.1

def test_process_single_delegates_to_batch(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.


assert result == []

def test_preserves_dataset_name(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 +28 to +44
def test_defaults(self) -> None:
cfg = MonoConversionConfig()
assert cfg.output_sample_rate == 48000
assert cfg.audio_filepath_key == "audio_filepath"
assert cfg.strict_sample_rate is True

def test_from_dict(self) -> None:
cfg = MonoConversionConfig.from_dict(
{"output_sample_rate": 16000, "strict_sample_rate": False}
)
assert cfg.output_sample_rate == 16000
assert cfg.strict_sample_rate is False
assert cfg.audio_filepath_key == "audio_filepath"

def test_from_dict_ignores_unknown_keys(self) -> None:
cfg = MonoConversionConfig.from_dict({"unknown_key": 42})
assert cfg.output_sample_rate == 48000

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 +63 to +73
def test_stage_properties(self) -> None:
stage = MonoConversionStage()
assert stage.name == "MonoConversion"
assert stage.inputs() == (["data"], [])
assert stage.outputs() == ([], ["waveform", "sample_rate", "is_mono", "duration", "num_samples"])

def test_config_overrides_params(self) -> None:
cfg = MonoConversionConfig(output_sample_rate=16000, strict_sample_rate=False)
stage = MonoConversionStage(config=cfg)
assert stage.output_sample_rate == 16000
assert stage.strict_sample_rate is False

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 len(result.data) == 0

def test_preserves_task_metadata(self, tmp_path: Path) -> 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 thread nemo_curator/stages/audio/configs/mono_conversion.py Outdated
Comment thread nemo_curator/stages/audio/preprocessing/concatenation.py Outdated
Comment on lines +143 to +165
w = waveform.squeeze() if waveform.dim() > 1 else waveform
num_samples = w.shape[-1] if w.dim() > 0 else 0
segment_duration_ms = int(1000 * num_samples / sample_rate)

orig_start = item.get('start_ms', 0)
orig_end = item.get('end_ms', 0)
if orig_end <= orig_start:
orig_end = orig_start + segment_duration_ms

mapping = SegmentMapping(
original_file=item.get('original_file', item.get('audio_filepath', 'unknown')),
original_start_ms=orig_start,
original_end_ms=orig_end,
concat_start_ms=current_pos_ms,
concat_end_ms=current_pos_ms + segment_duration_ms,
segment_index=idx,
)
mappings.append(mapping.to_dict())

parts.append(w.unsqueeze(0) if w.dim() == 1 else w)
current_pos_ms += segment_duration_ms

parts.append(torch.zeros(1, silence_samples, dtype=w.dtype, device=w.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.

P1 Multi-channel waveform causes RuntimeError in torch.cat

waveform.squeeze() (no dimension argument) squeezes all singleton dimensions. For a mono (1, N) tensor it correctly collapses to (N,), but for a multi-channel (C, N) tensor where C > 1, squeeze() is a no-op and w keeps shape (C, N).

Because w.dim() == 2 in that case, line 162 appends w as (C, N). The silence tensor on line 165, however, is always hardcoded to (1, silence_samples). When torch.cat(parts[:-1], dim=-1) is later called, the shapes (C, N) and (1, silence_samples) are incompatible along dim 0 (when C ≠ 1), raising a RuntimeError at runtime.

Even though SegmentConcatenationStage is intended to run after MonoConversionStage, there is no guard enforcing this, so receiving multi-channel data is an entirely reachable error path.

A minimal fix is to validate — or explicitly enforce mono — before appending:

# Ensure waveform is 1D (squeeze the channel dim only if it's 1)
if waveform.dim() == 2 and waveform.shape[0] != 1:
    logger.warning(
        f"[SegmentConcat] Segment {idx} has {waveform.shape[0]} channels; "
        "expected mono. Averaging channels."
    )
    waveform = waveform.mean(dim=0, keepdim=True)
w = waveform.squeeze(0)  # now safely (N,)

Comment on lines +144 to +150
item['waveform'] = mono_waveform
item['sample_rate'] = sr
item['is_mono'] = True
item['duration'] = mono_waveform.shape[1] / sr
item['num_samples'] = mono_waveform.shape[1]

results.append(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.

P2 In-place mutation of input item dict

The stage writes directly into the original item dict from task.data and then appends that same object to results. This means:

  1. After process() returns, the original task.data items already carry the mutated fields (waveform, sample_rate, etc.) — the caller's data is silently modified.
  2. If the same batch is ever replayed or inspected, the item will appear as already-processed even though no copy was made.

Prefer creating a copy of the item to keep the output immutable with respect to the input:

Suggested change
item['waveform'] = mono_waveform
item['sample_rate'] = sr
item['is_mono'] = True
item['duration'] = mono_waveform.shape[1] / sr
item['num_samples'] = mono_waveform.shape[1]
results.append(item)
result_item = {
**item,
'waveform': mono_waveform,
'sample_rate': sr,
'is_mono': True,
'duration': mono_waveform.shape[1] / sr,
'num_samples': mono_waveform.shape[1],
}
results.append(result_item)

Comment thread nemo_curator/stages/audio/configs/mono_conversion.py Outdated
stereo = np.random.randn(48000, 2).astype(np.float32)

with patch("nemo_curator.stages.audio.preprocessing.mono_conversion.sf.read", return_value=(stereo, 48000)):
with patch("os.path.exists", return_value=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.

P2 Global os.path.exists mock leaks across all modules

patch("os.path.exists", return_value=True) replaces the os.path.exists function in the os module itself, meaning every call to os.path.exists from any imported module during the test will also return True. This creates a test-isolation risk — e.g., framework code that legitimately checks for a file's absence would be silently bypassed.

The correct approach is to scope the patch to the module under test:

Suggested change
with patch("os.path.exists", return_value=True):
with patch("nemo_curator.stages.audio.preprocessing.mono_conversion.os.path.exists", return_value=True):

The same global-patch issue applies at lines 55, 71, 85, and 110 — all five patch("os.path.exists", ...) calls should be updated to use the fully qualified module path.

Comment thread nemo_curator/stages/audio/preprocessing/concatenation.py Outdated
Comment on lines +97 to +98
def outputs(self) -> Tuple[List[str], List[str]]:
return [], ["waveform", "sample_rate", "num_segments", "total_duration_sec"]

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 outputs() declaration is missing "original_file"

The returned declaration ["waveform", "sample_rate", "num_segments", "total_duration_sec"] does not include "original_file", which is unconditionally present in the output dict built at line 198. If the pipeline framework uses outputs() for contract validation or schema registration, downstream stages expecting "original_file" would not discover it through the declared contract.

Suggested change
def outputs(self) -> Tuple[List[str], List[str]]:
return [], ["waveform", "sample_rate", "num_segments", "total_duration_sec"]
def outputs(self) -> Tuple[List[str], List[str]]:
return [], ["waveform", "sample_rate", "num_segments", "total_duration_sec", "original_file"]

…ers in preprocessing

- common.py: add load_audio_file, ensure_waveform_2d, ensure_mono,
  resolve_waveform_from_item, resolve_model_path utility functions
- postprocessing/timestamp_mapper.py: new TimestampMapperStage with
  cross-boundary rejection and passthrough_keys filtering
- configs/timestamp_mapper.py: new TimestampMapperConfig
- mono_conversion.py: replace inline sf.read with load_audio_file
- concatenation.py: replace inline tensor normalization with
  ensure_waveform_2d from common
- Update __init__.py and configs/__init__.py exports
- Add unit tests for TimestampMapperStage

@sarahyurick sarahyurick left a comment

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.

Thanks, kicking off tests and the Ruff formatter as additional feedback.

@sarahyurick

Copy link
Copy Markdown
Contributor

/ok to test c5e326b

shubhamNvidia and others added 6 commits March 25, 2026 19:36
- Q000: Replace single quotes with double quotes across all audio stage files
- ANN401: Add noqa suppression for intentional Any type in ensure_waveform_2d
- BLE001: Replace blind Exception catches with specific exceptions
  (OSError, RuntimeError, soundfile.SoundFileError)
@sarahyurick

Copy link
Copy Markdown
Contributor

/ok to test 7ed5fc8

@sarahyurick

Copy link
Copy Markdown
Contributor

@sarahyurick

Copy link
Copy Markdown
Contributor

/ok to test 4130f6b

@sarahyurick

Copy link
Copy Markdown
Contributor

/ok to test be48818

@sarahyurick

Copy link
Copy Markdown
Contributor

/ok to test 2ff712c

@shubhamNvidia

Copy link
Copy Markdown
Contributor Author

/ok to test 4136b49

@shubhamNvidia

Copy link
Copy Markdown
Contributor Author

/ok to test ba466fa

@shubhamNvidia

Copy link
Copy Markdown
Contributor Author

/ok to test 86c2569

assert result == []

def test_missing_segments_key_raises(self) -> None:
import pytest

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.

Very nit: please move to top-level import.

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.

sure

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.

Done

@shubhamNvidia

Copy link
Copy Markdown
Contributor Author

/ok to test d7c7c68

@sarahyurick sarahyurick left a comment

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.

LGTM, thanks!

@sarahyurick

Copy link
Copy Markdown
Contributor

/ok to test 4a43d49

@sarahyurick

Copy link
Copy Markdown
Contributor

/ok to test 3deae25

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.

3 participants