Skip to content

Commit c2fde93

Browse files
Adding Nemotron 3 Diarization ONNX Model Support (#748)
## Summary Add ONNX graph support for NVIDIA Nemotron 3 Diarization, including both an offline full-sequence model and a stateful streaming model for per-frame speaker-activity probabilities. ## Changes - Add `Nemotron3DiarizationConfig` to map the Hugging Face audio, head, and streaming settings, and register `nemotron3_diarization` with the existing offline `diarization` task. - Implement the Sortformer-style audio path: feature stacking/subsampling, bidirectional RoPE transformer, projection, sub-pixel upsampling, and sigmoid speaker-classification head. - Add the `diarization-streaming` task. Each graph invocation processes one mel-feature chunk with configurable look-ahead and returns that chunk’s speaker probabilities plus updated fixed-capacity Arrival-Order Speaker Cache (AOSC), FIFO buffers, occupancy counts, and compression state. The cache update includes score-based top-k compression. - Export the model and task from their public modules and add a tiny model configuration for graph-build coverage. ## Validation - Add streaming graph tests for package creation, input/output names, task lookup, and three consecutive ONNX Runtime calls that feed each step’s cache state into the next and check probability ranges and cache capacities. - The existing parameterized speech graph-build suite includes the new tiny model configuration for offline graph construction. - The three-step execution test uses random weights; this PR does not add a numerical comparison against Hugging Face with real model weights. ## Scope / limitations - The graph consumes mel-spectrogram features, not raw audio. Streaming callers provide the chunk window, look-ahead length, and cache state on each invocation. - The streaming export assumes a fully valid input window; it does not expose Hugging Face’s padding `attention_mask` for a potentially padded final chunk. - Outputs are speaker-activity probabilities, not post-processed speaker turns or timestamps. --------- Signed-off-by: themason2011 <masoncorey@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 88fd6a1 commit c2fde93

34 files changed

Lines changed: 3093 additions & 217 deletions

‎scripts/generate_golden.py‎

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,30 @@
5353
_MISSING_ATTRIBUTE = object()
5454

5555

56+
def _best_effort_package_commit(package_name: str) -> str | None:
57+
"""Best-effort immutable VCS commit for a pip-installed package.
58+
59+
When a dependency is installed from a git URL (``pip install
60+
git+https://...@<ref>``) -- the only way to get pre-release/unreleased
61+
HuggingFace model code such as Nemotron3Diarization's -- pip records the
62+
resolved commit SHA in the package's ``direct_url.json`` metadata. This
63+
lets golden-data provenance capture an immutable source identity instead
64+
of just a mutable, potentially ambiguous ``X.Y.Z.devN`` version string.
65+
Returns ``None`` (rather than raising) for a normal PyPI install, or if
66+
the metadata is missing/malformed, since this is diagnostic-only.
67+
"""
68+
try:
69+
import json
70+
from importlib.metadata import distribution
71+
72+
direct_url = json.loads(
73+
distribution(package_name).read_text("direct_url.json") or "{}"
74+
)
75+
return direct_url.get("vcs_info", {}).get("commit_id")
76+
except Exception:
77+
return None
78+
79+
5680
@contextlib.contextmanager
5781
def _temporary_processor_max_pixels(processor: object, max_pixels: int | None):
5882
"""Temporarily override processor pixel limits and restore their exact state."""
@@ -1907,6 +1931,207 @@ def _hook(_module, _args, kwargs, output):
19071931
save_drafter_inputs(drafter_inputs_path_for_case(case), arrays)
19081932

19091933

1934+
def _generate_diarization_offline(case: TestCase, json_path: Path, device: str) -> None:
1935+
"""Generate golden data for offline speaker diarization.
1936+
1937+
Diarization models emit continuous per-frame per-speaker sigmoid
1938+
probabilities, not a vocab logit vector, so the reference is stored via
1939+
``save_diarization_golden()`` (full-precision ``probs`` array in a
1940+
companion ``.npz``) and compared with ``compare_diarization_golden()``
1941+
instead of the argmax-gated ``save_golden_ref()``/``compare_golden()``
1942+
used elsewhere in this file.
1943+
1944+
Two reference implementations are dispatched by ``case.model_type``:
1945+
1946+
* ``sortformer`` (NeMo Sortformer): built from a ``.nemo`` archive via
1947+
``nemo_toolkit`` (not a mobius runtime dependency) --
1948+
``frontend_encoder`` (mel -> embeddings) then ``forward_infer``
1949+
(embeddings -> per-frame speaker sigmoids). ``mel`` is generated
1950+
already channel-first ``[batch, feat, frames]`` -- NeMo's own input
1951+
contract -- matching the mobius ``DiarizationTask`` graph directly.
1952+
* otherwise (e.g. ``nemotron3_diarization``): a HuggingFace
1953+
``AutoModelForAudioFrameClassification`` checkpoint. HuggingFace's
1954+
``input_features`` are channel-last ``[batch, frames, feat]``; they
1955+
are transposed to channel-first before saving so the golden matches
1956+
the mobius task's input contract with no runtime transpose needed.
1957+
"""
1958+
import numpy as np
1959+
import torch
1960+
1961+
from mobius._testing.golden import save_diarization_golden
1962+
1963+
seed = int(case.generation_params.get("seed", 0))
1964+
torch.manual_seed(seed)
1965+
1966+
if case.model_type == "sortformer":
1967+
import nemo # type: ignore[import-not-found]
1968+
from huggingface_hub import hf_hub_download
1969+
from nemo.collections.asr.models import ( # type: ignore[import-not-found]
1970+
SortformerEncLabelModel,
1971+
)
1972+
1973+
filename = case.generation_params.get(
1974+
"nemo_filename", "diar_streaming_sortformer_4spk-v2.1.nemo"
1975+
)
1976+
nemo_path = hf_hub_download(
1977+
repo_id=case.model_id, filename=filename, revision=case.revision
1978+
)
1979+
model = SortformerEncLabelModel.restore_from(nemo_path, map_location=device)
1980+
model.eval()
1981+
model.streaming_mode = False # offline: full-context attention.
1982+
1983+
num_frames = int(case.generation_params.get("num_frames", 400))
1984+
feat_dim = int(model.cfg.encoder.feat_in)
1985+
mel = torch.randn(1, feat_dim, num_frames, device=device)
1986+
mel_len = torch.tensor([num_frames], dtype=torch.long, device=device)
1987+
with torch.no_grad():
1988+
emb_seq, emb_len = model.frontend_encoder(
1989+
processed_signal=mel, processed_signal_length=mel_len
1990+
)
1991+
preds = model.forward_infer(emb_seq, emb_len)
1992+
1993+
arrays = {
1994+
"mel": mel.cpu().numpy().astype(np.float32),
1995+
"emb_seq": emb_seq.cpu().numpy().astype(np.float32),
1996+
"emb_len": emb_len.cpu().numpy().astype(np.int64),
1997+
"probs": preds.cpu().numpy().astype(np.float32),
1998+
}
1999+
provenance = {
2000+
"model_id": case.model_id,
2001+
"revision": case.revision,
2002+
"nemo_version": nemo.__version__,
2003+
"nemo_commit": _best_effort_package_commit("nemo_toolkit"),
2004+
"seed": seed,
2005+
"feat_dim": feat_dim,
2006+
"num_frames": num_frames,
2007+
"num_speakers": int(preds.shape[-1]),
2008+
}
2009+
else:
2010+
import transformers
2011+
from transformers import AutoModelForAudioFrameClassification
2012+
2013+
model = AutoModelForAudioFrameClassification.from_pretrained(
2014+
case.model_id, revision=case.revision, dtype=torch.float32
2015+
)
2016+
model = model.to(device)
2017+
model.eval()
2018+
2019+
mel_dim = model.config.audio_config.num_mel_bins
2020+
# Below chunk_length * subsampling_factor so HuggingFace's own
2021+
# offline forward does not internally re-chunk.
2022+
num_frames = int(case.generation_params.get("num_frames", 2000))
2023+
mel = torch.randn(1, num_frames, mel_dim, device=device)
2024+
with torch.no_grad():
2025+
out = model(input_features=mel)
2026+
2027+
arrays = {
2028+
"mel": np.transpose(mel.cpu().numpy(), (0, 2, 1)).astype(np.float32),
2029+
"probs": out.logits.sigmoid().cpu().numpy().astype(np.float32),
2030+
}
2031+
provenance = {
2032+
"model_id": case.model_id,
2033+
"revision": case.revision,
2034+
"transformers_version": transformers.__version__,
2035+
"transformers_commit": _best_effort_package_commit("transformers"),
2036+
"torch_version": torch.__version__,
2037+
"seed": seed,
2038+
"num_speakers": int(model.config.head_config.num_speakers),
2039+
"mel_dim": mel_dim,
2040+
}
2041+
2042+
save_diarization_golden(json_path, arrays=arrays, provenance=provenance)
2043+
2044+
2045+
def _generate_diarization_streaming(case: TestCase, json_path: Path, device: str) -> None:
2046+
"""Generate golden data for a multi-chunk streaming diarization session.
2047+
2048+
Runs the real HuggingFace ``Nemotron3DiarizationForAudioFrameClassification``
2049+
checkpoint (the ground-truth reference implementation) chunk by chunk,
2050+
threading the HuggingFace ``Nemotron3DiarizationSpeakerCache`` (Arrival-
2051+
Order Speaker Cache + FIFO queue) across chunks. Each chunk is a full
2052+
``chunk_length + chunk_right_context`` window (matching the mobius
2053+
``DiarizationStreamingTask`` ONNX graph's fixed input shape).
2054+
2055+
NOTE: the HF cache's ``.embeds``/``.probs``/``.fifo`` buffers are mutable
2056+
and updated in place via ``index_copy_`` across chunks -- ``.numpy()``
2057+
returns a *view*, so every per-chunk snapshot below is explicitly copied.
2058+
Without this, all per-chunk arrays would silently alias the final
2059+
chunk's values once ``np.savez`` serializes them (a real bug caught while
2060+
first authoring this golden -- see git history for
2061+
``scripts/generate_nemotron3_diarization_golden.py``).
2062+
"""
2063+
import numpy as np
2064+
import torch
2065+
import transformers
2066+
from transformers import AutoModelForAudioFrameClassification
2067+
2068+
from mobius._testing.golden import save_diarization_golden
2069+
2070+
seed = int(case.generation_params.get("seed", 0))
2071+
num_chunks = int(case.generation_params.get("num_chunks", 3))
2072+
2073+
model = AutoModelForAudioFrameClassification.from_pretrained(
2074+
case.model_id, revision=case.revision, dtype=torch.float32
2075+
)
2076+
model = model.to(device)
2077+
model.eval()
2078+
2079+
mel_dim = model.config.audio_config.num_mel_bins
2080+
chunk_length = model.config.chunk_length
2081+
chunk_right_context = model.config.chunk_right_context
2082+
subsampling = model.config.audio_config.subsampling_factor
2083+
raw_window = (chunk_length + chunk_right_context) * subsampling
2084+
2085+
arrays: dict[str, np.ndarray] = {"num_chunks": np.array(num_chunks, dtype=np.int64)}
2086+
cache = None
2087+
for i in range(num_chunks):
2088+
torch.manual_seed(seed + 100 + i)
2089+
mel = torch.randn(1, raw_window, mel_dim, device=device)
2090+
is_last = i == num_chunks - 1
2091+
lookahead = 0 if is_last else chunk_right_context
2092+
with torch.no_grad():
2093+
out = model(
2094+
input_features=mel, speaker_cache=cache, num_lookahead_frames=lookahead
2095+
)
2096+
cache = out.speaker_cache
2097+
# HF's input_features are channel-last; transpose to the mobius
2098+
# DiarizationStreamingTask's channel-first contract.
2099+
arrays[f"chunk{i}_mel"] = (
2100+
np.transpose(mel.cpu().numpy(), (0, 2, 1)).astype(np.float32).copy()
2101+
)
2102+
arrays[f"chunk{i}_lookahead"] = np.array(lookahead, dtype=np.int64)
2103+
arrays[f"chunk{i}_probs"] = out.logits.sigmoid().cpu().numpy().copy()
2104+
arrays[f"chunk{i}_cache_embeds"] = cache.embeds.cpu().numpy().copy()
2105+
arrays[f"chunk{i}_cache_probs"] = cache.probs.cpu().numpy().copy()
2106+
arrays[f"chunk{i}_fifo"] = cache.fifo.cpu().numpy().copy()
2107+
arrays[f"chunk{i}_num_cache_frames"] = np.array(cache.num_cache_frames, dtype=np.int64)
2108+
arrays[f"chunk{i}_num_fifo_frames"] = np.array(cache.num_fifo_frames, dtype=np.int64)
2109+
arrays[f"chunk{i}_is_compressed"] = np.array(cache.is_compressed, dtype=np.bool_)
2110+
2111+
provenance = {
2112+
"model_id": case.model_id,
2113+
"revision": case.revision,
2114+
"transformers_version": transformers.__version__,
2115+
"transformers_commit": _best_effort_package_commit("transformers"),
2116+
"torch_version": torch.__version__,
2117+
"seed": seed,
2118+
"num_speakers": int(model.config.head_config.num_speakers),
2119+
"mel_dim": mel_dim,
2120+
"chunk_length": int(chunk_length),
2121+
"chunk_right_context": int(chunk_right_context),
2122+
"subsampling_factor": int(subsampling),
2123+
"streaming_fifo_length": int(model.config.streaming_config.fifo_length),
2124+
"streaming_speaker_cache_length": int(
2125+
model.config.streaming_config.speaker_cache_length
2126+
),
2127+
"streaming_speaker_cache_update_period": int(
2128+
model.config.streaming_config.speaker_cache_update_period
2129+
),
2130+
"num_stream_chunks": num_chunks,
2131+
}
2132+
save_diarization_golden(json_path, arrays=arrays, provenance=provenance)
2133+
2134+
19102135
# ---- Dispatcher ----
19112136

19122137
# Map task_type strings to generator functions.
@@ -1930,6 +2155,8 @@ def _hook(_module, _args, kwargs, output):
19302155
"phi4mm-multimodal": _generate_phi4mm_multimodal,
19312156
"gemma4-assistant": _generate_gemma4_assistant,
19322157
"dflash-draft": _generate_dflash_draft,
2158+
"diarization": _generate_diarization_offline,
2159+
"diarization-streaming": _generate_diarization_streaming,
19332160
}
19342161

19352162

‎scripts/generate_sortformer_golden.py‎

Lines changed: 0 additions & 106 deletions
This file was deleted.

‎src/mobius/_configs/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
MoonshineStreamingConfig,
6363
MuseGlimmerConfig,
6464
NanoChatConfig,
65+
Nemotron3DiarizationConfig,
6566
NemotronHConfig,
6667
NemotronParseConfig,
6768
ParakeetCTCConfig,
@@ -167,6 +168,7 @@
167168
"MoonshineStreamingConfig",
168169
"MuseGlimmerConfig",
169170
"NanoChatConfig",
171+
"Nemotron3DiarizationConfig",
170172
"NemotronParseConfig",
171173
"NemotronHConfig",
172174
"ParakeetCTCConfig",

0 commit comments

Comments
 (0)