Skip to content

Add David AI redelivered MFA pipeline from draco#2186

Open
timofeeva16tanya wants to merge 2 commits into
NVIDIA-NeMo:mainfrom
timofeeva16tanya:david_ai_preparation
Open

Add David AI redelivered MFA pipeline from draco#2186
timofeeva16tanya wants to merge 2 commits into
NVIDIA-NeMo:mainfrom
timofeeva16tanya:david_ai_preparation

Conversation

@timofeeva16tanya

Copy link
Copy Markdown

Sync current pipeline (RAM-by-session stages, glued-OOV heuristic detector, lexicon build, cluster submission scripts, ffmpeg timeout hardening) from the draco working copy.

Description

Usage

# Add snippet demonstrating usage

Checklist

  • I am familiar with the Contributing Guide.
  • New or Existing tests cover these changes.
  • The documentation is up to date with these changes.

Sync current pipeline (RAM-by-session stages, glued-OOV heuristic detector,
lexicon build, cluster submission scripts, ffmpeg timeout hardening) from
the draco working copy.
@timofeeva16tanya timofeeva16tanya requested a review from a team as a code owner July 8, 2026 13:41
@timofeeva16tanya timofeeva16tanya requested review from sarahyurick and removed request for a team July 8, 2026 13:41
@copy-pr-bot

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

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a new David AI redelivered MFA alignment tutorial pipeline, porting the current working copy from a private cluster branch into the public repository. It introduces the full RAM-by-session processing flow — text normalization, 16 kHz Opus encoding, Montreal Forced Aligner invocation, Lhotse cutset construction, and per-session RTTM/mixed-audio generation — along with cluster submission scripts, a lexicon build tool, and supporting documentation.

  • david_ai_common.py: clear_stage_done_from has a NameError on every call (slices stage_names[stage:] where stage is undefined; the index variable is named start); recording_textgrid_paths returns the non-existent ordinary path alongside the existing fb_path when only the fallback exists.
  • stage2_mfa_align_textgrids.py: mfa align is called via subprocess.run without a timeout= argument; all ffmpeg calls in the same codebase are guarded with FFMPEG_TIMEOUT_S = 600 for exactly this reason, but MFA is left unprotected.
  • stage_ram_session_pipeline.py: todo_sessions (the skip-aware filtered list) is computed and logged but the task list iterates over the full sessions list, submitting already-finished sessions to the process pool on every restart.

Confidence Score: 2/5

The clear_stage_done_from helper crashes unconditionally on any call due to a name typo, and mfa align can deadlock a worker process indefinitely with no timeout escape.

Two bugs in david_ai_common.py are unconditional failures: clear_stage_done_from raises NameError on every invocation, and recording_textgrid_paths hands callers a non-existent path. Separately, mfa align has no subprocess timeout while the codebase already uses FFMPEG_TIMEOUT_S to prevent the exact deadlock pattern MFA is also susceptible to in a worker pool.

david_ai_common.py (two broken helpers used across the pipeline) and pipeline_ram/stage2_mfa_align_textgrids.py (unguarded mfa align subprocess).

Important Files Changed

Filename Overview
tutorials/audio/david_ai_redelivered_mfa/david_ai_common.py Shared helpers: includes clear_stage_done_from with a NameError (slices on undefined stage instead of start), and recording_textgrid_paths which returns a stale non-existent path when only the fallback exists.
tutorials/audio/david_ai_redelivered_mfa/pipeline_ram/stage_ram_session_pipeline.py RAM session orchestrator: todo_sessions filter is computed but task list iterates sessions (all sessions), re-submitting already-done work; otherwise the sharding and resume logic is sound.
tutorials/audio/david_ai_redelivered_mfa/pipeline_ram/stage2_mfa_align_textgrids.py MFA alignment stage: mfa align subprocess call has no timeout, risking indefinite deadlock in worker pool runs; all ffmpeg calls have an equivalent FFMPEG_TIMEOUT_S guard already.
tutorials/audio/david_ai_redelivered_mfa/pipeline_ram/david_ai_ram_session.py Per-session RAM worker: lazy MFA root initialization, force/skip logic, and audio encode/mix flow are correct; uses per-PID worker directories to avoid model corruption across parallel workers.
tutorials/audio/david_ai_redelivered_mfa/pipeline_ram/stage4_build_final_outputs.py Lhotse + RTTM output builder from alignment cache; logic is straightforward with proper error handling and skip-if-done guard.
tutorials/audio/david_ai_redelivered_mfa/lexicon/stage0_build_lexicon.py Dictionary build + G2P: mfa g2p subprocess calls also lack a timeout (same risk as mfa align), but the lexicon stage typically runs once and is less frequently exercised in the pool.
tutorials/audio/david_ai_redelivered_mfa/pipeline_ram/david_ai_ram_lhotse.py Lhotse cut construction from in-memory alignment results; clean and straightforward.
tutorials/audio/david_ai_redelivered_mfa/tests/conftest.py Minimal pytest sys.path setup for the tutorial package; no issues.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Source audio + transcript] --> B[stage0: Normalize text in RAM]
    B --> C[Lexicon build\npreprocess_build_lexicon.py]
    C --> D[16 kHz Opus encode per speaker\naudio_16k/]
    D --> E{MFA align needed?}
    E -- yes --> F[stage2: mfa align per segment\nstage2_mfa_align_textgrids.py]
    E -- no --> G[Load existing TextGrids]
    F --> H[Session + recording TextGrids\ntextgrids/]
    G --> H
    H --> I[Build Lhotse cuts\ndavid_ai_ram_lhotse.py]
    H --> J[Build session RTTM\nbuild_session_rttm_lines_from_words]
    I --> K[Per-session cuts\nlhotse/sessions/]
    J --> L[Prepare per-speaker pause noise audio]
    L --> M[Mix speakers to session Opus\naudio_mixed/]
    M --> N[Mark session done\n.done/sessions/]
    N --> O{All sessions done?}
    O -- yes --> P[stage_ram_merge_lhotse:\nMerge global Lhotse manifests]
    O -- no --> Q[Resubmit shard]
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[Source audio + transcript] --> B[stage0: Normalize text in RAM]
    B --> C[Lexicon build\npreprocess_build_lexicon.py]
    C --> D[16 kHz Opus encode per speaker\naudio_16k/]
    D --> E{MFA align needed?}
    E -- yes --> F[stage2: mfa align per segment\nstage2_mfa_align_textgrids.py]
    E -- no --> G[Load existing TextGrids]
    F --> H[Session + recording TextGrids\ntextgrids/]
    G --> H
    H --> I[Build Lhotse cuts\ndavid_ai_ram_lhotse.py]
    H --> J[Build session RTTM\nbuild_session_rttm_lines_from_words]
    I --> K[Per-session cuts\nlhotse/sessions/]
    J --> L[Prepare per-speaker pause noise audio]
    L --> M[Mix speakers to session Opus\naudio_mixed/]
    M --> N[Mark session done\n.done/sessions/]
    N --> O{All sessions done?}
    O -- yes --> P[stage_ram_merge_lhotse:\nMerge global Lhotse manifests]
    O -- no --> Q[Resubmit shard]
Loading

Reviews (2): Last reviewed commit: "scripts" | Re-trigger Greptile

start = stage_names.index(stage_name)
except ValueError:
return
for name in stage_names[stage:]:

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 NameError on every call: the local variable is start but the slice uses the undefined name stage. Any caller of clear_stage_done_from will crash with NameError: name 'stage' is not defined at runtime.

Suggested change
for name in stage_names[stage:]:
for name in stage_names[start:]:

Comment on lines +294 to +295
for session_dir in sessions
]

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 The task list iterates over sessions (all sessions) instead of todo_sessions (only the ones that need processing). The todo_sessions filter was correctly computed and logged, but is never used here. Every already-finished session is submitted to the process pool, where process_session_ram parses manifests and re-checks outputs before returning skipped=True — a significant waste at scale (e.g. a 200-node run re-processes every completed session on restart).

Suggested change
for session_dir in sessions
]
for session_dir in todo_sessions
]

Comment on lines +687 to +694
def recording_textgrid_paths(textgrid_dir: Path, recording_id: str) -> list[Path]:
ordinary = recording_textgrid_path(textgrid_dir, recording_id, variant="ordinary")
if ordinary.is_file():
return [ordinary]
fb_path = recording_textgrid_path(textgrid_dir, recording_id, variant="fb")
if fb_path.is_file():
return [ordinary, fb_path]
return [ordinary]

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 When ordinary does not exist as a file but fb_path does, the function returns [ordinary, fb_path] — a list that includes the non-existent ordinary path. build_recording_rttm_lines iterates that list and calls parse_textgrid_words(ordinary), which raises an exception that is silently swallowed; only the fb path is parsed. The intent appears to be returning just the fb path (or both when both exist), but the current code returns a stale, missing path.

Suggested change
def recording_textgrid_paths(textgrid_dir: Path, recording_id: str) -> list[Path]:
ordinary = recording_textgrid_path(textgrid_dir, recording_id, variant="ordinary")
if ordinary.is_file():
return [ordinary]
fb_path = recording_textgrid_path(textgrid_dir, recording_id, variant="fb")
if fb_path.is_file():
return [ordinary, fb_path]
return [ordinary]
def recording_textgrid_paths(textgrid_dir: Path, recording_id: str) -> list[Path]:
ordinary = recording_textgrid_path(textgrid_dir, recording_id, variant="ordinary")
fb_path = recording_textgrid_path(textgrid_dir, recording_id, variant="fb")
if ordinary.is_file() and fb_path.is_file():
return [ordinary, fb_path]
if ordinary.is_file():
return [ordinary]
if fb_path.is_file():
return [fb_path]
return [ordinary]

from pathlib import Path
from typing import TypeVar

MFA_ROOT_DIR_DEFAULT = "/home/ttimofeeva/MFA_models"

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 The default MFA root path is a developer-specific home directory. Any user running this without setting MFA_ROOT_DIR will get a FileNotFoundError when resolving models, with a confusing path pointing to ttimofeeva's home. A generic fallback like ~/MFA_models is more appropriate.

Suggested change
MFA_ROOT_DIR_DEFAULT = "/home/ttimofeeva/MFA_models"
MFA_ROOT_DIR_DEFAULT = "~/MFA_models"

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 +321 to +326
try:
result = subprocess.run(align_cmd, capture_output=True, text=True, env=mfa_env)
except OSError as exc:
logger.error("%s: mfa align failed to start: %s", recording_id, exc)
mfa_failed_globally = True
detail = str(exc)

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 Missing timeout on mfa align subprocess

subprocess.run(align_cmd, …) has no timeout= argument. The codebase already documents this exact failure mode: FFMPEG_TIMEOUT_S = 600 exists precisely because "a wedged ffmpeg (e.g. an internal futex deadlock seen when many run in a worker pool) blocks its caller forever and hangs the whole shard." MFA runs as another pooled subprocess with the same risk — if mfa align hangs (e.g. SQLite or pynini lock contention), the entire process pool worker blocks indefinitely with no recovery path. The outer try/except Exception in align_recording would catch a subprocess.TimeoutExpired and return RecordingAlignResult(ok=False), so adding a timeout here is safe and consistent.

Comment on lines +1321 to +1325
try:
start = stage_names.index(stage_name)
except ValueError:
return
for name in stage_names[stage:]:

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.

P0 NameError in clear_stage_done_from: stage vs start

The loop slices on stage, but the variable assigned by list.index() is named start. Any caller will crash with NameError: name 'stage' is not defined at runtime.

Suggested change
try:
start = stage_names.index(stage_name)
except ValueError:
return
for name in stage_names[stage:]:
try:
start = stage_names.index(stage_name)
except ValueError:
return
for name in stage_names[start:]:

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