Skip to content

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

Open
shubhamNvidia wants to merge 3 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 3 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 ASR, VAD, and diarization stages agent-ready by adding AgentReady mixin with describe() contracts, routing audio loading through the new _residency.py resolver, and fixing fan-out provenance so children carry a proper original_file fallback chain instead of always collapsing to "unknown".

  • New modules: _agent_ready.py defines the StageContract/IOSpec/Gates descriptor vocabulary; _residency.py implements resolve_audio_path with temp-WAV materialization and cleanup_temp_files.
  • Residency routing: all four stages (InferenceAsrNemoStage, PyAnnoteDiarizationStage, InferenceSortformerStage, WhisperXVADStage) now resolve their input through resolve_audio_path with input_residency defaulting to "file" (preserving existing behaviour) and clean up temp paths in finally blocks.
  • Fan-out provenance: _segment_child_data / _fanout_segments helpers added to pyannote, sortformer, and whisperx_vad emit one AudioTask per detected segment with accurate start/end/duration fields and a robust original_file fallback chain.

Confidence Score: 5/5

Safe to merge; all changes are additive, default residency preserves existing behaviour, and the temp-cleanup logic is correct for the normal happy path.

The residency routing and fan-out provenance fix are well-structured. finally: cleanup_temp_files(temp_paths) covers the normal hot path for all four stages, and the input_residency="file" default means existing callers are unaffected. The one new gap — an orphaned temp file if sf.write raises before the path is registered — only triggers under abnormal conditions (disk full, malformed waveform) and leaves an empty file rather than causing data loss.

nemo_curator/stages/audio/_residency.py — the resolve_audio_path temp-registration ordering deserves a second look; the other changed files are clean.

Important Files Changed

Filename Overview
nemo_curator/stages/audio/_residency.py New residency resolver; resolve_audio_path has a narrow temp-file orphan if sf.write raises before register_temp.append(tmp); otherwise logic is sound.
nemo_curator/stages/audio/_agent_ready.py New agent-discovery vocabulary (StageContract, IOSpec, Gates, ParamSpec); well-structured, JSON-safe serialisation, no logic issues found.
nemo_curator/stages/audio/inference/asr/asr_nemo.py process_batch now resolves paths via residency and cleans up temps in finally; pre-pass validation accepts waveform tasks even when residency="file" will discard them (flagged in prior review thread).
nemo_curator/stages/audio/inference/speaker_diarization/pyannote.py Residency routing, RTTM guard, fan-out helpers added; RTTM sibling file is untracked in temp_paths when input is a waveform (flagged in prior review thread).
nemo_curator/stages/audio/inference/speaker_diarization/sortformer.py Residency routing and fan-out helpers added; original_file key absent from child when all provenance keys missing and path is temp (flagged in prior outside-diff comment).
nemo_curator/stages/audio/inference/vad/whisperx_vad.py Residency routing, fan-out helpers, and "segments" key exclusion added cleanly; provenance fallback chain is consistent with pyannote.
nemo_curator/stages/audio/common.py AgentReady mixin and describe() contracts added to four existing stages; changes are additive and non-breaking.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[AudioTask with file or waveform] --> B{resolve_audio_path}
    B -->|residency=file, path exists| C[Return local path]
    B -->|residency=file, path absent| D[Return None → ValueError]
    B -->|auto/waveform + waveform present| E[sf.write → temp WAV]
    E --> F[Register in temp_paths]
    F --> G[Return temp path]
    B -->|protocol path e.g. s3://| H[Return protocol path as-is]

    C --> I{Stage processing}
    G --> I
    H --> I

    I -->|ASR batch| J[NeMo transcribe]
    I -->|Pyannote| K[sf.read → diarize → RTTM?]
    I -->|Sortformer| L[SortformerEncLabelModel.diarize]
    I -->|WhisperX VAD| M[WhisperXVADModel.get_vad_segments]

    J --> N[task.data pred_text set]
    K -->|fanout=False| O[task.data segments set]
    K -->|fanout=True| P[_fanout_segments → list of AudioTask]
    L -->|fanout=False| Q[task.data diar_segments set]
    L -->|fanout=True| R[_fanout_segments → list of AudioTask]
    M -->|fanout=False| S[task.data vad_segments set]
    M -->|fanout=True| T[_fanout_segments → list of AudioTask]

    N --> U[finally: cleanup_temp_files]
    O --> U
    P --> U
    Q --> U
    R --> U
    S --> U
    T --> U
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 with file or waveform] --> B{resolve_audio_path}
    B -->|residency=file, path exists| C[Return local path]
    B -->|residency=file, path absent| D[Return None → ValueError]
    B -->|auto/waveform + waveform present| E[sf.write → temp WAV]
    E --> F[Register in temp_paths]
    F --> G[Return temp path]
    B -->|protocol path e.g. s3://| H[Return protocol path as-is]

    C --> I{Stage processing}
    G --> I
    H --> I

    I -->|ASR batch| J[NeMo transcribe]
    I -->|Pyannote| K[sf.read → diarize → RTTM?]
    I -->|Sortformer| L[SortformerEncLabelModel.diarize]
    I -->|WhisperX VAD| M[WhisperXVADModel.get_vad_segments]

    J --> N[task.data pred_text set]
    K -->|fanout=False| O[task.data segments set]
    K -->|fanout=True| P[_fanout_segments → list of AudioTask]
    L -->|fanout=False| Q[task.data diar_segments set]
    L -->|fanout=True| R[_fanout_segments → list of AudioTask]
    M -->|fanout=False| S[task.data vad_segments set]
    M -->|fanout=True| T[_fanout_segments → list of AudioTask]

    N --> U[finally: cleanup_temp_files]
    O --> U
    P --> U
    Q --> U
    R --> U
    S --> U
    T --> U
Loading

Reviews (2): Last reviewed commit: "fix(audio): residency-derived accepts/re..." | 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.

…x/sortformer/pyannote) + match _residency.py
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