Skip to content

feat(audio-tagging): agent-ready tagging stages (resample temp-path fix + configurable keys)#2178

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

feat(audio-tagging): agent-ready tagging stages (resample temp-path fix + configurable keys)#2178
shubhamNvidia wants to merge 2 commits into
NVIDIA-NeMo:mainfrom
shubhamNvidia:agent_pr/tagging

Conversation

@shubhamNvidia

Copy link
Copy Markdown
Contributor

Summary

Makes the tagging stages agent-ready and fixes temp-path safety in resampling.

tagging/resample_audio.py — temp-path fix

  • When materializing/producing audio, temp files now use unique mkstemp paths registered via register_temp and removed by cleanup_temp_files, so parallel workers / repeated calls no longer collide on a shared temp path. Residency-aware input; optional in-memory output (write_to_disk), with the original path preserved when not updating.

Others (contract + configurable keys, logic unchanged)

  • nemo_asr_align.py, merge_alignment_diarization.py, prepare_module_segments.py, split.py, text/itn.py, text/chinese_conversion.py — add describe() / StageContract and replace hardcoded data-key strings with configurable fields.

Notes for reviewers

  • Backward compatible defaults. NeMoASRAlignerStage.process() delegates to process_batch([task]) (works per item, so intentionally no BATCH_ONLY).
  • Worth a look: the temp-file lifecycle in resample_audio.py (creation + cleanup on both success and error).

Test plan

  • pytest tests/stages/audio/tagging -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:53
@shubhamNvidia shubhamNvidia requested review from oyilmaz-nvidia and removed request for a team July 7, 2026 15:53
@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 audio tagging stages agent-ready by introducing a StageContract/AgentReady discovery layer and replacing hardcoded data-key strings with configurable *_key fields across nine stages. It also fixes a shared-path collision in ResampleAudioStage by materializing waveform inputs via tempfile.mkstemp with a register_temp/cleanup_temp_files lifecycle.

  • _agent_ready.py / _residency.py (new files): Clean infrastructure for agent contract discovery and residency-aware audio resolution; the register_temp pattern for input WAV materialization is correct.
  • resample_audio.py: The most complex change — adds write_to_disk=False and keep_waveform_in_task modes. An output temp file created with mkstemp when write_to_disk=False is not registered in temp_paths, so it leaks if load_audio_file or get_audio_duration raises after ffmpeg succeeds.
  • All other tagging stages: Purely mechanical — hardcoded string literals replaced by self.*_key fields and describe() methods added; backward-compatible defaults throughout.

Confidence Score: 3/5

Safe to merge for most paths, but the write_to_disk=False code path leaks a temp file on post-conversion exceptions before tests exercise it.

The bulk of the changes are clean refactors (key-string → field, @staticmethod → method, describe() additions) with no logic mutations. The residency and agent-contract infrastructure is well-structured. The one concrete defect is in the new write_to_disk=False branch of ResampleAudioStage: the output mkstemp file is outside the temp_paths cleanup list, so a failure in load_audio_file or get_audio_duration after ffmpeg succeeds permanently leaks a temp file on the worker. This path is new code, not guarded by a finally block, and the test plan's scope is not yet confirmed to cover it.

nemo_curator/stages/audio/tagging/resample_audio.py — specifically the write_to_disk=False branch and the output temp file lifecycle between lines 160 and 215.

Important Files Changed

Filename Overview
nemo_curator/stages/audio/tagging/resample_audio.py Adds waveform-input and in-memory output modes with temp-file lifecycle management; output temp file created with mkstemp when write_to_disk=False is not registered for cleanup and leaks if load_audio_file or get_audio_duration raises after ffmpeg succeeds.
nemo_curator/stages/audio/_residency.py New file providing resolve_audio_path and cleanup_temp_files helpers; correctly uses mkstemp + register_temp pattern for input temp WAV materialization.
nemo_curator/stages/audio/_agent_ready.py New file introducing StageContract, AgentReady mixin, and supporting dataclasses for agent-facing stage discovery; well-structured with clear serialization helpers.
nemo_curator/stages/audio/tagging/inference/nemo_asr_align.py Adds configurable key fields and describe(); NeMoASRAlignerStage re-declares three fields already present in BaseASRProcessorStage, which is redundant and risks silent drift.
nemo_curator/stages/audio/common.py Adds AgentReady mixin and describe() to existing stages; ManifestReaderStage.describe() hardcodes "audio_filepath" instead of a configurable key field, unlike other stages in this PR.
nemo_curator/stages/audio/tagging/split.py Replaces hardcoded key strings with configurable fields and converts _build_split_metadata from @staticmethod to instance method; logic unchanged.
nemo_curator/stages/audio/tagging/merge_alignment_diarization.py Adds alignment_key, segments_key configurable fields and describe(); straightforward key refactor with no logic changes.
nemo_curator/stages/audio/tagging/prepare_module_segments.py Adds segments_key, duration_key, metrics_key configurable fields, an outputs() implementation that was previously missing, and describe(); logic unchanged.
nemo_curator/stages/audio/tagging/text/chinese_conversion.py Adds segments_key and output_suffix configurable fields and describe(); the output_suffix default preserves the previous hardcoded '_simplified' behavior.
nemo_curator/stages/audio/tagging/text/itn.py Adds segments_key and output_suffix configurable fields and describe(); default output_suffix '_ITN' preserves previous hardcoded behavior.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant ResampleAudioStage
    participant resolve_audio_path
    participant ffmpeg
    participant load_audio_file
    participant cleanup_temp_files

    Caller->>ResampleAudioStage: process(task)
    ResampleAudioStage->>resolve_audio_path: "residency=file/waveform/auto"
    alt waveform in task
        resolve_audio_path->>resolve_audio_path: mkstemp → write WAV
        resolve_audio_path-->>ResampleAudioStage: tmp_input_path (registered in temp_paths)
    else file path
        resolve_audio_path-->>ResampleAudioStage: local_path
    end

    alt "write_to_disk=True"
        ResampleAudioStage->>ResampleAudioStage: "output = resampled_audio_dir/id.fmt"
    else "write_to_disk=False"
        ResampleAudioStage->>ResampleAudioStage: mkstemp → output_audio_path (NOT in temp_paths)
    end

    ResampleAudioStage->>ffmpeg: convert input → output
    alt ffmpeg fails
        ResampleAudioStage->>cleanup_temp_files: temp_paths (input WAV)
        ResampleAudioStage-->>Caller: raise RuntimeError
    end

    ResampleAudioStage->>cleanup_temp_files: temp_paths (input WAV)

    opt keep_waveform_in_task
        ResampleAudioStage->>load_audio_file: output_audio_path
        Note over ResampleAudioStage: exception here leaks output temp
        load_audio_file-->>ResampleAudioStage: waveform, sample_rate
    end

    ResampleAudioStage->>ResampleAudioStage: get_audio_duration(output_audio_path)

    opt not write_to_disk
        ResampleAudioStage->>ResampleAudioStage: os.remove(output_audio_path)
    end

    ResampleAudioStage-->>Caller: task with updated data_entry
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 Caller
    participant ResampleAudioStage
    participant resolve_audio_path
    participant ffmpeg
    participant load_audio_file
    participant cleanup_temp_files

    Caller->>ResampleAudioStage: process(task)
    ResampleAudioStage->>resolve_audio_path: "residency=file/waveform/auto"
    alt waveform in task
        resolve_audio_path->>resolve_audio_path: mkstemp → write WAV
        resolve_audio_path-->>ResampleAudioStage: tmp_input_path (registered in temp_paths)
    else file path
        resolve_audio_path-->>ResampleAudioStage: local_path
    end

    alt "write_to_disk=True"
        ResampleAudioStage->>ResampleAudioStage: "output = resampled_audio_dir/id.fmt"
    else "write_to_disk=False"
        ResampleAudioStage->>ResampleAudioStage: mkstemp → output_audio_path (NOT in temp_paths)
    end

    ResampleAudioStage->>ffmpeg: convert input → output
    alt ffmpeg fails
        ResampleAudioStage->>cleanup_temp_files: temp_paths (input WAV)
        ResampleAudioStage-->>Caller: raise RuntimeError
    end

    ResampleAudioStage->>cleanup_temp_files: temp_paths (input WAV)

    opt keep_waveform_in_task
        ResampleAudioStage->>load_audio_file: output_audio_path
        Note over ResampleAudioStage: exception here leaks output temp
        load_audio_file-->>ResampleAudioStage: waveform, sample_rate
    end

    ResampleAudioStage->>ResampleAudioStage: get_audio_duration(output_audio_path)

    opt not write_to_disk
        ResampleAudioStage->>ResampleAudioStage: os.remove(output_audio_path)
    end

    ResampleAudioStage-->>Caller: task with updated data_entry
Loading

Comments Outside Diff (2)

  1. nemo_curator/stages/audio/tagging/inference/nemo_asr_align.py, line 188-191 (link)

    P2 Subclass re-declares base-class dataclass fields

    NeMoASRAlignerStage re-declares split_filepaths_key, split_metadata_key, and alignment_key with the same defaults that already exist on BaseASRProcessorStage. In Python dataclass inheritance, re-declaring a field in a subclass moves it to the end of the field order in __init__, and creates a separate slot that shadows the base definition. While functionally harmless here (defaults are identical), it doubles the surface for drift and could silently break if the base default ever changes without updating the subclass.

    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!

  2. nemo_curator/stages/audio/common.py, line 566-572 (link)

    P2 ManifestReaderStage.describe() hardcodes "audio_filepath"

    Unlike every other stage in this PR (which replaces hardcoded key strings with self.*_key fields), ManifestReaderStage.describe() still writes a literal "audio_filepath" in its IOSpec. If ManifestReaderStage has a configurable key field for the filepath it emits, the contract should reference it; if not, this is worth noting so that it can be added alongside the other stages for consistency.

    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-tagging): make tagging stages..." | Re-trigger Greptile

Comment on lines 160 to +215
@@ -135,14 +183,36 @@ def process(self, task: AudioTask) -> AudioTask:
try:
subprocess.run(cmd, check=True, capture_output=True, text=True) # noqa: S603
except subprocess.CalledProcessError as e:
cleanup_temp_files(temp_paths)
msg = f"Error converting {input_audio_path}: {e}"
raise RuntimeError(msg) from e

# Update metadata — preserve original URL for cloud paths
data_entry[self.audio_filepath_key] = original_audio_filepath
data_entry[self.resampled_audio_filepath_key] = output_audio_path
# Input temp WAV (materialized from a waveform) is no longer needed after conversion.
cleanup_temp_files(temp_paths)

# Update metadata — preserve original URL for cloud paths.
if original_audio_filepath is not None:
data_entry[self.audio_filepath_key] = original_audio_filepath
if self.write_to_disk:
data_entry[self.resampled_audio_filepath_key] = output_audio_path
if self.update_audio_filepath:
produce_audio_filepath(
data_entry,
output_audio_path,
key=self.audio_filepath_key,
original_key=self.original_audio_filepath_key,
)
if self.keep_waveform_in_task:
waveform, sample_rate = load_audio_file(output_audio_path, mono=False)
data_entry[self.waveform_key] = waveform
data_entry[self.sample_rate_key] = sample_rate
duration = get_audio_duration(output_audio_path)
data_entry[self.duration_key] = duration
if not self.write_to_disk:
try:
os.remove(output_audio_path)
except OSError:
pass

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 Output temp file leaks on post-ffmpeg exception

When write_to_disk=False a temp file is created with mkstemp at line 160, but it is never added to temp_paths (which only tracks the input materialized WAV). If load_audio_file() (line 206) or get_audio_duration() (line 209) raises — e.g., a corrupt conversion result or an I/O error — execution never reaches the os.remove(output_audio_path) block at line 213, so the temp file is permanently leaked. The fix is to wrap the waveform-load + duration-get + remove block in a try/finally, or to register output_audio_path in a second temp list just like the input.

audio_item_id_key: str = "audio_item_id"
original_audio_filepath_key: str = "original_audio_filepath"

input_residency: str = "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.

P2 input_residency is declared as str but resolve_audio_path expects InputResidency = Literal["file", "waveform", "auto"]. The workaround # type: ignore[arg-type] at the call site masks the mismatch; an agent or user passing an invalid value (e.g. "disk") would get a silent misroute inside resolve_audio_path rather than an early error.

Suggested change
input_residency: str = "file"
input_residency: InputResidency = "file"

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