Add audio preprocessing stages (MonoConversion, SegmentConcatenation, TimestampMapper) - #1575
Conversation
Greptile SummaryThis PR adds three foundational audio preprocessing/postprocessing stages ( Two logic issues remain in
Confidence Score: 4/5Safe 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 Important Files Changed
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]
|
|
|
||
|
|
||
| @dataclass | ||
| class SegmentConcatenationConfig: |
There was a problem hiding this comment.
Discussed offline. We do not need a config class per stage.
|
|
||
|
|
||
| @dataclass | ||
| class MonoConversionConfig: |
There was a problem hiding this comment.
Discussed offline. We do not need a config class per stage.
| dataset_name=first_task.dataset_name, | ||
| _metadata=first_task._metadata, | ||
| _stage_perf=list(first_task._stage_perf), |
There was a problem hiding this comment.
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]: |
There was a problem hiding this comment.
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: |
|
|
||
| assert result == [] | ||
|
|
||
| def test_preserves_dataset_name(self) -> None: |
| 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 |
| 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 |
|
|
||
| assert len(result.data) == 0 | ||
|
|
||
| def test_preserves_task_metadata(self, tmp_path: Path) -> None: |
…atenation for canonical waveform format
| 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)) |
There was a problem hiding this comment.
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,)| 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) |
There was a problem hiding this comment.
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:
- After
process()returns, the originaltask.dataitems already carry the mutated fields (waveform,sample_rate, etc.) — the caller's data is silently modified. - 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:
| 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) |
| 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): |
There was a problem hiding this comment.
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:
| 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.
| def outputs(self) -> Tuple[List[str], List[str]]: | ||
| return [], ["waveform", "sample_rate", "num_segments", "total_duration_sec"] |
There was a problem hiding this comment.
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.
| 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"] |
eefedd8 to
46710d6
Compare
…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
46710d6 to
0d369c8
Compare
sarahyurick
left a comment
There was a problem hiding this comment.
Thanks, kicking off tests and the Ruff formatter as additional feedback.
|
/ok to test c5e326b |
- 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)
|
/ok to test 7ed5fc8 |
|
/ok to test 4130f6b |
|
/ok to test be48818 |
|
/ok to test 2ff712c |
|
/ok to test 4136b49 |
|
/ok to test ba466fa |
|
/ok to test 86c2569 |
| assert result == [] | ||
|
|
||
| def test_missing_segments_key_raises(self) -> None: | ||
| import pytest |
There was a problem hiding this comment.
Very nit: please move to top-level import.
|
/ok to test d7c7c68 |
|
/ok to test 4a43d49 |
Signed-off-by: shbhawsar <shbhawsar@nvidia.com>
|
/ok to test 3deae25 |
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.