Skip to content

feat(audio-segmentation): agent-ready speaker-separation + VAD (provenance fix)#2177

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

feat(audio-segmentation): agent-ready speaker-separation + VAD (provenance fix)#2177
shubhamNvidia wants to merge 2 commits into
NVIDIA-NeMo:mainfrom
shubhamNvidia:agent_pr/segmentation

Conversation

@shubhamNvidia

Copy link
Copy Markdown
Contributor

Summary

Makes the segmentation stages agent-ready and fixes speaker-separation provenance.

segmentation/speaker_separation.py (1:N fan-out) — provenance fix

  • Splits one multi-speaker input into one record per speaker. Previously each child inherited the parent audio_filepath (the full mixed file) and carried no source provenance. Now each child drops the parent file path and snapshots original_file (from original_file / audio_filepath) before emission, and carries a distinct speaker_id plus per-speaker waveform / diar_segments.

segmentation/vad_segmentation.py

  • Residency-aware VAD (accepts waveform or file), with fan-out (one task per segment) or nested (all segments under one key) modes, configurable keys, and describe().

Notes for reviewers

  • Backward compatible defaults; residency default "auto" prefers in-memory waveform.
  • Worth a look: _build_speaker_tasks in speaker_separation.py (which parent keys are dropped vs inherited).

Test plan

  • pytest tests/stages/audio/segmentation -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:52
@shubhamNvidia shubhamNvidia requested review from suiyoubi and removed request for a team July 7, 2026 15:52
@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 SpeakerSeparationStage and VADSegmentationStage agent-ready by adding describe() contracts and residency-aware audio resolution, while also fixing the provenance bug where child tasks incorrectly inherited the parent's multi-speaker audio_filepath.

  • Introduces two new modules: _agent_ready.py (StageContract/IOSpec/Gates protocol) and _residency.py (waveform/file/auto resolution), and adds AgentReady mixin + describe() to existing stages in common.py.
  • Refactors SpeakerSeparationStage to fan out per-speaker child tasks that drop the parent file path and carry original_file for provenance, with configurable data keys and input_residency.
  • Adds nested mode, configurable keys, keep_segment_waveform_in_task, and describe() to VADSegmentationStage, replacing the inline audio-loading logic with the shared resolve_audio helper.

Confidence Score: 3/5

The core provenance fix is solid, but two contract defects in the new agent-ready layer need to be resolved before this should be relied upon by automated orchestrators.

The VADSegmentationStage.describe() always reports iteration_key as the segments_key regardless of mode; in fan-out mode no child task carries that key, so any agent consuming the contract will see an incorrect iteration handle. Separately, SpeakerSeparationStage hardcodes "original_file" everywhere while VADSegmentationStage exposes it as a configurable original_file_key — chaining the two stages with a non-default key silently breaks provenance. Both issues are in the new agent-ready contract layer that is the central goal of this PR.

vad_segmentation.py (iteration_key logic in describe()) and speaker_separation.py (hardcoded "original_file" key throughout)

Important Files Changed

Filename Overview
nemo_curator/stages/audio/_agent_ready.py New file introducing StageContract, IOSpec, Gates, and the AgentReady mixin — well-structured discovery protocol with clean JSON serialization helpers.
nemo_curator/stages/audio/_residency.py New file providing residency-aware audio resolution (waveform/file/auto) and temp-file materialization; logic is correct but resolve_audio_path may return a non-existent local path in "file" mode by design (documented).
nemo_curator/stages/audio/common.py Adds AgentReady mixin and describe() contracts to existing stages; changes are additive and correct.
nemo_curator/stages/audio/segmentation/speaker_separation.py Provenance fix and agent-ready refactor; "original_file" key is hardcoded throughout while VAD exposes it as a configurable field — this breaks chained pipelines using non-default key names. Also missing error log on audio resolution failure.
nemo_curator/stages/audio/segmentation/vad_segmentation.py Residency-aware refactor and describe() addition; iteration_key is always segments_key regardless of mode — in fan-out mode child tasks carry no "segments" key, making the contract wrong for agent consumers.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Agent
    participant Stage as SpeakerSeparation / VAD
    participant Residency as _residency.resolve_audio
    participant Disk as Audio File / Waveform

    Agent->>Stage: describe() → StageContract
    Note over Stage: cardinality, iteration_key,<br/>reads_one_of, gates

    Agent->>Stage: process(task)
    Stage->>Residency: "resolve_audio(item, residency="auto")"
    alt waveform + sample_rate in item
        Residency-->>Stage: (waveform_2d, sr)
    else file path exists on disk
        Residency->>Disk: load_audio_file(path)
        Disk-->>Residency: (waveform, sr)
        Residency-->>Stage: (waveform_2d, sr)
    else no audio found
        Residency-->>Stage: None → skip task
    end

    alt SpeakerSeparationStage (1:N fan-out)
        Stage->>Stage: diarize → per-speaker waveforms
        loop for each speaker
            Stage-->>Agent: "AudioTask(waveform=spk_waveform, original_file=…, speaker_id=…)"
        end
    else VADSegmentationStage — fan-out mode
        Stage->>Stage: Silero VAD → segments
        loop for each segment
            Stage-->>Agent: "AudioTask(waveform=seg_waveform, segment_num=…, start_ms=…)"
        end
    else VADSegmentationStage — nested mode
        Stage->>Stage: Silero VAD → segments
        Stage-->>Agent: "AudioTask(segments=[{…}, {…}])"
    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"}}}%%
sequenceDiagram
    participant Agent
    participant Stage as SpeakerSeparation / VAD
    participant Residency as _residency.resolve_audio
    participant Disk as Audio File / Waveform

    Agent->>Stage: describe() → StageContract
    Note over Stage: cardinality, iteration_key,<br/>reads_one_of, gates

    Agent->>Stage: process(task)
    Stage->>Residency: "resolve_audio(item, residency="auto")"
    alt waveform + sample_rate in item
        Residency-->>Stage: (waveform_2d, sr)
    else file path exists on disk
        Residency->>Disk: load_audio_file(path)
        Disk-->>Residency: (waveform, sr)
        Residency-->>Stage: (waveform_2d, sr)
    else no audio found
        Residency-->>Stage: None → skip task
    end

    alt SpeakerSeparationStage (1:N fan-out)
        Stage->>Stage: diarize → per-speaker waveforms
        loop for each speaker
            Stage-->>Agent: "AudioTask(waveform=spk_waveform, original_file=…, speaker_id=…)"
        end
    else VADSegmentationStage — fan-out mode
        Stage->>Stage: Silero VAD → segments
        loop for each segment
            Stage-->>Agent: "AudioTask(waveform=seg_waveform, segment_num=…, start_ms=…)"
        end
    else VADSegmentationStage — nested mode
        Stage->>Stage: Silero VAD → segments
        Stage-->>Agent: "AudioTask(segments=[{…}, {…}])"
    end
Loading

Comments Outside Diff (1)

  1. nemo_curator/stages/audio/segmentation/vad_segmentation.py, line 230-237 (link)

    P2 Unnecessary waveform clone() when keep_segment_waveform_in_task=False

    segment_waveform is always sliced and .clone()'d before the if not self.keep_segment_waveform_in_task guard sets it to None. When this flag is False, the allocation and copy are immediately discarded. Consider deferring the clone until after the guard check to avoid wasted memory, especially for long audio with many segments.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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

writes=IOSpec(data_keys=writes, produces=produces),
cardinality="1:1 nested-list" if self.nested else "1:N fan-out",
cardinality_options=["fan_out", "nested"],
iteration_key=self.segments_key,

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 Wrong iteration_key in fan-out mode

iteration_key=self.segments_key is always emitted from describe(), but in fan-out mode (nested=False) no child task ever carries a "segments" key — each child has segment_num, start_ms, etc. An agent that uses this contract to identify which key distinguishes children will look for a non-existent key. The correct value for fan-out is self.segment_num_key; for nested it remains self.segments_key.

Comment on lines +160 to +170
return StageContract(
reads_one_of=[
IOSpec(data_keys=[self.waveform_key, self.sample_rate_key], accepts=["waveform"]),
IOSpec(data_keys=[self.audio_filepath_key], accepts=["file"]),
],
writes=IOSpec(data_keys=writes, produces=produces),
cardinality="1:1 nested-list" if self.nested else "1:N fan-out",
cardinality_options=["fan_out", "nested"],
iteration_key=self.segments_key,
gates=Gates(requires_gpu=self.resources.gpus > 0),
)

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 In fan-out mode the child tasks don't carry a "segments" key, so iteration_key should point at segment_num_key instead. Using segments_key here makes the contract inaccurate for agents.

Suggested change
return StageContract(
reads_one_of=[
IOSpec(data_keys=[self.waveform_key, self.sample_rate_key], accepts=["waveform"]),
IOSpec(data_keys=[self.audio_filepath_key], accepts=["file"]),
],
writes=IOSpec(data_keys=writes, produces=produces),
cardinality="1:1 nested-list" if self.nested else "1:N fan-out",
cardinality_options=["fan_out", "nested"],
iteration_key=self.segments_key,
gates=Gates(requires_gpu=self.resources.gpus > 0),
)
return StageContract(
reads_one_of=[
IOSpec(data_keys=[self.waveform_key, self.sample_rate_key], accepts=["waveform"]),
IOSpec(data_keys=[self.audio_filepath_key], accepts=["file"]),
],
writes=IOSpec(data_keys=writes, produces=produces),
cardinality="1:1 nested-list" if self.nested else "1:N fan-out",
cardinality_options=["fan_out", "nested"],
iteration_key=self.segments_key if self.nested else self.segment_num_key,
gates=Gates(requires_gpu=self.resources.gpus > 0),
)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines 148 to +262
@@ -178,21 +234,39 @@ def _build_speaker_tasks(
logger.debug(f"Skipping {speaker_id}: duration {result.duration:.2f}s < {self.min_duration}s")
continue
spk_waveform, spk_sr = _pydub_to_waveform_sr(result.audio)
# Drop the parent's file path(s) too: they point at the FULL
# multi-speaker file, so a file-preferring downstream stage would
# process the whole file per speaker instead of this speaker's
# extracted waveform. With the path gone, downstream resolves the
# per-speaker waveform (input_residency="auto") instead.
drop_keys = {
*self._INHERITED_DROP_KEYS,
self.waveform_key,
self.duration_key,
self.audio_filepath_key,
"audio_filepath",
}
speaker_data = {
**{k: v for k, v in item.items() if k not in self._INHERITED_DROP_KEYS},
"waveform": spk_waveform,
"sample_rate": spk_sr,
"speaker_id": speaker_id,
"num_speakers": num_speakers,
"duration": result.duration,
"diar_segments": result.diar_segments,
**{k: v for k, v in item.items() if k not in drop_keys},
self.waveform_key: spk_waveform,
self.sample_rate_key: spk_sr,
self.speaker_id_key: speaker_id,
self.num_speakers_key: num_speakers,
self.duration_key: result.duration,
self.diar_segments_key: result.diar_segments,
# Source identity must survive the audio_filepath drop above —
# TimestampMapper (and any provenance consumer) reads original_file.
"original_file": item.get("original_file")
or item.get(self.audio_filepath_key)
or item.get("audio_filepath")
or "unknown",

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 "original_file" key is hardcoded in SpeakerSeparationStage

VADSegmentationStage exposes original_file_key as a configurable dataclass field (default "original_file"), but SpeakerSeparationStage hardcodes the string "original_file" in both the describe() contract (data_keys=[..., "original_file"]) and the runtime data ("original_file": item.get("original_file") or ...). When these two stages are chained and the user sets a non-default original_file_key in VAD, the speaker-separation stage reads and writes a different key, silently breaking provenance for all downstream consumers.

Comment on lines +289 to 297
audio_result = resolve_audio(
item,
residency=self.input_residency, # type: ignore[arg-type]
audio_filepath_key=self.audio_filepath_key,
waveform_key=self.waveform_key,
sample_rate_key=self.sample_rate_key,
)
if audio_result is None:
return []

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 Silent drop when audio resolution fails

The old resolve_waveform_from_item logged an error before returning None. The new resolve_audio call returns None without any log, and the callsite just returns [] with no diagnostic. A task that silently disappears from the pipeline (missing waveform and non-existent file path) will be very hard to debug. Adding a log at the if audio_result is None guard (mirroring _resolve_audio in VADSegmentationStage) would preserve the previous observability contract.

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