Skip to content

feat(audio-inference): agent-ready ASR/VAD/diarization (residency + fan-out provenance fix)#2176

Open
shubhamNvidia wants to merge 2 commits into
NVIDIA-NeMo:mainfrom
shubhamNvidia:agent_pr/inference
Open

feat(audio-inference): agent-ready ASR/VAD/diarization (residency + fan-out provenance fix)#2176
shubhamNvidia wants to merge 2 commits into
NVIDIA-NeMo:mainfrom
shubhamNvidia:agent_pr/inference

Conversation

@shubhamNvidia

Copy link
Copy Markdown
Contributor

Summary

Makes the ASR/VAD/diarization stages agent-ready, routes their audio loading through the residency resolver, and fixes fan-out provenance.

Common changes

  • Each stage loads its input path via resolve_audio_path (accepts a file, or an in-memory waveform materialized to a temp WAV) and cleans up temp files in a finally block. input_residency="file" by default → same behavior as before. Each adds describe(); batched stages (e.g. ASR) are BATCH_ONLY.

Fan-out provenance fix (pyannote, sortformer, whisperx_vad)

  • These emit one child record per detected segment/speaker. Each child now resolves original_file through a fallback chain — configured original_file_keyaudio_filepath_key → canonical audio_filepath → legacy resampled_audio_filepath"unknown" — instead of collapsing to "unknown" when audio arrived under the canonical key. Children also get a distinct segment_num and per-segment timing (start/end, start_ms/end_ms, duration).
  • WhisperX specifically drops its raw internal segments key from children (a list of tuples) so it doesn't collide with the semantic segments role and break dict-shaped consumers.
  • sortformer additionally avoids pinning a materialized temp path onto its children.

Notes for reviewers

  • Worth a look: the _segment_child_data / _fanout_segments helpers in pyannote.py, sortformer.py, whisperx_vad.py (provenance fallback + temp handling) — these are near-identical across the three.

Test plan

  • pytest tests/stages/audio/inference -q

…idency resolver

Shared base imported by the audio stage modules to make them agent-ready:
- _agent_ready.py: AgentReady mixin, StageContract/IOSpec/Gates/Role
- _residency.py: input residency resolver (file/waveform/auto)
- common.py: agent-ready helpers (ensure_mono/ensure_waveform_2d)

Excludes the agent orchestration layer (registry/catalog/conformance/planning/agent).
@shubhamNvidia shubhamNvidia requested a review from a team as a code owner July 7, 2026 15:50
@shubhamNvidia shubhamNvidia requested review from weijiac0619 and removed request for a team July 7, 2026 15:50
@copy-pr-bot

copy-pr-bot Bot commented Jul 7, 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 Jul 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes the ASR, VAD, and speaker diarization stages agent-ready by routing audio loading through a new resolve_audio_path residency layer (accepting both file paths and in-memory waveforms materialized to temp WAVs), implementing AgentReady.describe() on every stage, and fixing fan-out provenance so segment children always carry a meaningful original_file instead of collapsing to "unknown".

  • New infrastructure: _agent_ready.py adds the full agent-contract type hierarchy (StageContract, IOSpec, Gates, etc.); _residency.py adds resolve_audio_path / resolve_audio / cleanup_temp_files with try/finally temp-WAV lifecycle management across all three inference families.
  • Fan-out provenance: pyannote, sortformer, and whisperx VAD each gain _segment_child_data / _fanout_segments helpers that resolve original_file through a configured-key → canonical audio_filepath → legacy resampled_audio_filepath fallback chain, and attach segment_num, start/end, start_ms/end_ms, and duration to every child.
  • Pyannote additions: write_rttm, vad_onset, vad_offset, and all segment key names are now configurable constructor fields; the fanout flag switches between passthrough and 1:N mode.

Confidence Score: 3/5

Safe to merge for file-based pipelines, but waveform-input paths through PyAnnote with the default write_rttm=True will silently accumulate RTTM files in the OS temp directory on every invocation.

The pyannote RTTM write derives its output path from file_path (which is the materialized temp WAV when waveform input is used), but only the WAV is registered in temp_paths; the .rttm file is never cleaned up — a real resource leak on every waveform-input invocation in the default configuration.

pyannote.py deserves a close second look around the write_rttm + temp-path interaction; sortformer.py and asr_nemo.py have smaller inconsistencies worth addressing before agentic waveform workloads go to production.

Important Files Changed

Filename Overview
nemo_curator/stages/audio/_agent_ready.py New file introducing agent-contract data classes (StageContract, IOSpec, Gates, ParamSpec, etc.) and the AgentReady mixin; well-structured, frozen dataclasses, clean JSON serialization helpers.
nemo_curator/stages/audio/_residency.py New file with resolve_audio / resolve_audio_path / cleanup_temp_files; correctly materializes in-memory waveforms to temp WAVs and registers them for cleanup, but _as_soundfile_array's channels < samples heuristic could produce wrong shape for very short audio.
nemo_curator/stages/audio/common.py Added AgentReady mixin and describe() to GetAudioDurationStage, PreserveByValueStage, ManifestReaderStage, ManifestReader, and ManifestWriterStage; changes are additive and correct.
nemo_curator/stages/audio/inference/asr/asr_nemo.py ASR stage now routes through resolve_audio_path with try/finally cleanup; pre-validation accepts waveform-only tasks even when residency=file (default), leading to a confusing error at resolution time.
nemo_curator/stages/audio/inference/speaker_diarization/pyannote.py Major refactor adding residency support, configurable VAD thresholds, and fan-out; RTTM is written to the materialized temp WAV path but never registered for cleanup — file leak when waveform input is used with write_rttm=True (default).
nemo_curator/stages/audio/inference/speaker_diarization/sortformer.py Added residency support and fan-out with careful temp-path pinning; original_file_key may be silently absent on fan-out children when file_path_is_temp and all provenance fields are missing, unlike pyannote/whisperx which fall back to unknown.
nemo_curator/stages/audio/inference/vad/whisperx_vad.py Added residency support and fan-out; correctly excludes whisperx's raw internal segments key from child data, returns [] on min_length skip when fanout=True — clean implementation.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[AudioTask arrives] --> B{residency setting}
    B -- file or auto --> C{audio_filepath_key in task.data?}
    B -- waveform --> D{waveform + sample_rate in task.data?}
    C -- yes, file exists --> E[use real file path]
    C -- no --> F{residency == file?}
    F -- yes --> G[return expanded path unverified]
    F -- no --> H{waveform in task.data?}
    H -- yes --> I[write temp WAV, append to temp_paths]
    H -- no --> J[return None, raise ValueError]
    D -- yes --> I
    D -- no --> J
    I --> K[pass path to ASR / VAD / diarizer]
    E --> K
    K --> L[inference runs]
    L --> M[finally: cleanup_temp_files removes temp WAVs]
    M --> N[return tasks or fan-out children]
    subgraph Fan-out provenance
        N --> O{fanout == True?}
        O -- yes --> P[_segment_child_data per segment]
        P --> Q[emit AudioTask per segment with segment_num start end]
        O -- no --> R[emit single AudioTask with segments list]
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[AudioTask arrives] --> B{residency setting}
    B -- file or auto --> C{audio_filepath_key in task.data?}
    B -- waveform --> D{waveform + sample_rate in task.data?}
    C -- yes, file exists --> E[use real file path]
    C -- no --> F{residency == file?}
    F -- yes --> G[return expanded path unverified]
    F -- no --> H{waveform in task.data?}
    H -- yes --> I[write temp WAV, append to temp_paths]
    H -- no --> J[return None, raise ValueError]
    D -- yes --> I
    D -- no --> J
    I --> K[pass path to ASR / VAD / diarizer]
    E --> K
    K --> L[inference runs]
    L --> M[finally: cleanup_temp_files removes temp WAVs]
    M --> N[return tasks or fan-out children]
    subgraph Fan-out provenance
        N --> O{fanout == True?}
        O -- yes --> P[_segment_child_data per segment]
        P --> Q[emit AudioTask per segment with segment_num start end]
        O -- no --> R[emit single AudioTask with segments list]
    end
Loading

Comments Outside Diff (1)

  1. nemo_curator/stages/audio/inference/speaker_diarization/sortformer.py, line 1120-1127 (link)

    P2 original_file silently absent when all provenance is missing and path is temp

    When file_path_is_temp=True and all of original_file_key, filepath_key, and audio_filepath are absent from item, original_file resolves to None, and the if original_file is not None: guard means original_file_key is simply not set on the child. This is inconsistent with pyannote and whisperx VAD, which both fall back to "unknown". Downstream consumers that unconditionally read this key will raise a KeyError or silently miss provenance data.

    Consider replacing (None if file_path_is_temp else file_path) with ("unknown" if file_path_is_temp else file_path) and removing the if original_file is not None guard so the key is always present, matching the behaviour in the other two fan-out stages.

Reviews (1): Last reviewed commit: "feat(audio-inference): make inference st..." | Re-trigger Greptile

Comment on lines +386 to +391
if self.write_rttm:
logger.info(f"Writing {len(diarization._tracks)} turns to RTTM file")
rttm_filepath = os.path.splitext(file_path)[0] + ".rttm"
rttm_fs, rttm_path = url_to_fs(rttm_filepath)
with rttm_fs.open(rttm_path, "w") as rttm_file:
diarization.write_rttm(rttm_file)

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 RTTM file leaked when input is a waveform

When write_rttm=True (the default) and audio arrives as an in-memory waveform, resolve_audio_path materializes a temp WAV (e.g. /tmp/tmpXXXXXX.wav) and registers it in temp_paths. The RTTM is then written to /tmp/tmpXXXXXX.rttm — derived from file_path via os.path.splitext. The finally block in process() calls cleanup_temp_files(temp_paths), which only contains the .wav entry; the .rttm file is never removed. In long-running agentic pipelines that push waveforms through this stage, this will silently accumulate RTTM files in the temp directory until the disk is full.

The fix is to register the RTTM path alongside the WAV when file_path came from a temp materialization. One approach is to pass temp_paths (or a boolean flag) into _diarize_file and append rttm_path to it, or to guard RTTM writing with file_path not in temp_paths.

Comment on lines 132 to 137
for task in tasks:
if not self.validate_input(task):
has_file = self.filepath_key in task.data
has_waveform = self.waveform_key in task.data and self.sample_rate_key in task.data
if not (has_file or has_waveform):
msg = f"Task {task.task_id} missing required columns for {type(self).__name__}: {self.inputs()}"
raise ValueError(msg)

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 Validation passes for waveform-only tasks even when residency="file" silently ignores them

The pre-resolution validation loop accepts a task when has_waveform=True, regardless of input_residency. But when input_residency="file" (the default), resolve_audio_path ignores the waveform entirely and returns None if filepath_key is absent from task.data. The task then hits the if path is None: guard with the error "missing audio input", leaving the caller no indication that the waveform was present but not used.

Either tighten the first-pass check to account for residency (e.g. has_file or (has_waveform and self.input_residency != "file")), or amend the error message to hint that the stage is running in residency="file" mode and requires a path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant