Nemotron-3-Diarization-ONNX
An ONNX export of nvidia/Nemotron-3-Diarization
that runs on CPU with numpy + onnxruntime + soundfile only -- no PyTorch. Offline
(whole-recording) mode: up to 8 concurrently tracked speakers, ordered by first arrival,
10 ms frame resolution.
This is an independent conversion, not produced or endorsed by NVIDIA. See NOTICE and LICENSE.
Quick start
pip install -r requirements.txt
python diarize.py meeting.wav --out meeting.rttm
import soundfile as sf
from diarize import OnnxDiarizer, extract_speaker_dict, segments_to_rttm
audio, sr = sf.read("meeting.wav", dtype="float32")
diarizer = OnnxDiarizer(threads=8) # default graph: model.int8.onnx
probs = diarizer.diarize(audio, sample_rate=sr) # (1, num_frames, 8), one row per 10 ms
segments = extract_speaker_dict(probs) # [{"Start":.., "End":.., "Speaker":..}, ...]
open("meeting.rttm", "w").write(segments_to_rttm(segments, uri="meeting"))
diarize(audio, sample_rate=sr) raises ValueError if sr != 16000 (resample first -- every
frame-rate constant downstream is hard-coded to 16 kHz, so a wrong rate would otherwise
produce plausible-looking garbage, not an error) and downmixes multi-channel audio
(soundfile's (frames, channels) shape) to mono automatically.
Pass --model model.onnx (CLI) or model_filename="model.onnx" (OnnxDiarizer) for the
fp32 graph instead of the int8 default.
Files
| File | Size | Purpose |
|---|---|---|
preprocessor_core.onnx |
0.14 MB | waveform -> log-mel spectrogram (bounded-memory windowed calls, see diarize.py) |
model.onnx |
397.4 MB | fp32 chunk-step graph: one 30.4 s step of the encoder + head |
model.int8.onnx |
103.7 MB | int8 (dynamic, per-channel) chunk-step graph -- the default |
constants.npz |
0.01 MB | speaker-cache config and the silence_embeds weight diarize.py needs |
diarize.py |
-- | the offline loop, the speaker cache (numpy port), and the CLI |
requirements.txt |
-- | numpy, onnxruntime, soundfile, pinned minimums |
scripts/ |
-- | the export/quantize/verification pipeline that produced the three graphs above -- see scripts/README.md |
docs/RESULTS.md |
-- | every number in this README, with tolerances and full per-check detail |
Sizes are decimal MB (106 bytes) throughout this document. The three .onnx
files have had exporter debug metadata (stack traces containing local file paths from the
machine they were built on) stripped -- scripts/strip_metadata.py, verified to leave
onnxruntime's outputs bit-identical.
Accuracy
Benchmarked under NVIDIA's own model-card protocol so these numbers compare directly: all
16 AMI test-split meetings, Mix-Headset, forced-alignment ground truth
(nttcslab-sp/diar-forced-alignment,
CC BY 4.0), collar 0, overlap scored, 0.5 threshold.
| System | DER | Miss | FA | Confusion |
|---|---|---|---|---|
| PyTorch (reference) | 9.226% | 4.68% | 3.67% | 0.88% |
| This export, fp32 | 9.225% | 4.68% | 3.67% | 0.88% |
| This export, int8 | 9.248% | 4.80% | 3.58% | 0.87% |
The model card reports 9.25% DER under this protocol; the PyTorch reference reproduced here gets 9.226% -- this export's fp32 and int8 graphs stay within 0.02 and 0.03 points of that. Per-meeting, int8 vs. PyTorch ranges from -0.14 to +0.28 points DER across the 16 meetings (worst cases: IS1009b -0.14, IS1009c +0.28).
int8 accuracy criterion, and why it has two parts. The original bar for shipping int8 was DER(int8, fp32) ≤ 1.0% absolute per file (±0.25s collar, overlap scored) on every test file. That held on every file within the model's 8-speaker capacity, but failed on both test files with more real speakers than the model has output slots for: 13 speakers scored 3.70%, 14 speakers scored 11.97% (see "Limitations" below for the full per-file table). Investigating why found that past capacity, a single int8 rounding difference can flip which frame a genuine, exact score tie in the speaker cache's compression step keeps -- after that point fp32 and int8 take different but equally valid paths, not one more correct than the other (the same tie-break mechanism the "Parity with PyTorch" section below describes for fp32 ONNX vs. PyTorch itself). Comparing int8 to fp32 directly is the wrong test once that's true, so the criterion was revised, after seeing this result, to: for files within capacity, DER(int8, fp32) ≤ 1.0% still applies; for files with more speakers than the model's 8 slots, int8 must be no worse than fp32 against real ground truth, +0.5 points tolerance. See "Limitations" below for the measurements this produced.
Parity with PyTorch
Two kinds of comparison, on ES2004a (17.5 min) and TS3006d (49.5 min, a longer meeting used as a cache-compression stress test):
- Teacher-forced (the ONNX cache replays PyTorch's exact tie-break choices, isolating everything else in the port): max |Δ probability| 1.4e-05 / 1.3e-05, 100.000% frame agreement at the 0.5 threshold, on both files.
- Free-running (each side breaks its own ties): DER(this export vs. PyTorch) 0.000% / 0.035%, both well under the 0.5% tolerance this project holds itself to.
The small free-running gap exists because of one specific, understood mechanism: this
model's streaming speaker cache keeps only its speaker_cache_length most important frames
per compression step, chosen by a top-k score. When two frames score exactly equal, the
PyTorch CPU implementation and this export's tie-break can legitimately pick different
frames (verified: PyTorch's tie order is not simply "lowest index" or any other simple rule
we could find). From that point the two runs follow different but equally correct
trajectories -- not a numeric error, a genuine tie.
Limitations
8-speaker cap. The model tracks at most 8 concurrent speakers (an architectural limit, not specific to this export). Audio with more real speakers than that forces the cache to keep evicting/reassigning slots under contention, and diarization quality drops accordingly for the excess speakers -- this is true of the original PyTorch model too.
int8 vs. fp32 divergence past 8 speakers. Under that same contention, a single int8 rounding difference can flip a genuine cache-compression tie, so the fp32 and int8 graphs can diverge more than on lower-speaker-count audio (see "Accuracy" above for why that's a different question from accuracy). Measured on 5 VoxConverse clips spanning the boundary (collar ±0.25s, overlap scored):
File Speakers DER(int8, fp32) DER vs. ground truth: fp32 / int8 eoyaz 8 0.28% 3.83% / 3.89% hcyak 8 0.08% 3.16% / 2.86% clfcg 9 0.26% 26.32% / 26.18% aggyz 13 3.70% 18.35% / 16.66% qeejz 14 11.97% 33.56% / 31.41% At or near the 8-speaker cap, int8 and fp32 agree to well under 1%. Well past it, they can differ substantially in which specific frames they attribute to which speaker. This is stated neutrally: on the two over-capacity files measured here int8 was not found to be less accurate against real ground truth than fp32, but two data points is not enough to claim int8 is more accurate in general either -- treat "possibly different, not proven worse" as the honest summary, not "int8 is better." A 31-layer mixed-precision sensitivity sweep found no smaller-than-fp32 variant that removes the divergence on one over-capacity file without introducing a new one on a different file, so it isn't fixed in this release.
Offline mode only. This export targets the 30.4 s-chunk offline path (a whole recording, or a long segment of one, processed at once). The model's low-latency streaming mode (sub-second chunks) is architecturally the same step graph at different chunk sizes, but is not exported here.
RTF excludes model load. The speed numbers below are wall time / audio duration for the diarization call only, not
OnnxDiarizer.__init__'s model load, which the Speed table reports separately (measured with the OS file cache warm -- there is no way to measure a genuinely cold load without root, so "warm" is stated honestly rather than claiming "cold" or dropping the number).Memory scales with file length -- not flat, and this is expected. A whole file's mel spectrogram and frame-level logits are inherently arrays sized to the recording's duration, held in memory for the whole call; the preprocessing step (which used to scale much worse than that inherent minimum, from an unbounded STFT computation over the whole waveform at once) now processes bounded ~60 s windows instead (
OnnxDiarizer.log_mel). Measured slope (fit between the 17.5-minute and 49.5-minute files in the Speed table, both at 4 threads):Graph Slope Fixed baseline 2-hour-file estimate fp32 8.8 MB/min 844 MB ~1.9 GB int8 8.0 MB/min 428 MB ~1.4 GB One file in the wider test set used to validate this export, TS3006d, is in AMI's
trainsplit (which NVIDIA trained on) and is used only as a long-file/cache-compression stress test, not as a held-out accuracy figure the way the 16 AMI-test meetings above are.
Speed
CPU: i9-13900HX (24 cores / 32 threads), Linux, run alone with no other load. RTF excludes
model load; "Load" is OnnxDiarizer.__init__'s wall time with the OS file cache warm (see
"Limitations" for why this isn't labelled "cold").
| File | Graph | Threads | RTF | Peak RSS | Load (warm cache) |
|---|---|---|---|---|---|
| ES2004a (17.5 min) | fp32 | 1 | 0.049 | 1025 MB | 0.42 s |
| ES2004a | fp32 | 4 | 0.018 | 999 MB | 0.47 s |
| ES2004a | fp32 | 8 | 0.014 | 1020 MB | 0.57 s |
| ES2004a | int8 | 1 | 0.026 | 570 MB | 0.19 s |
| ES2004a | int8 | 4 | 0.011 | 568 MB | 0.25 s |
| ES2004a | int8 | 8 | 0.008 | 571 MB | 0.28 s |
| TS3006d (49.5 min) | fp32 | 4 | 0.022 | 1282 MB | 0.56 s |
| TS3006d | int8 | 4 | 0.012 | 824 MB | 0.26 s |
PyTorch reference: RTF 0.054 at 8 threads. int8 is 1.68-1.94x faster than fp32 across these four file/thread-count pairs and uses noticeably less memory; fp32 ONNX at a single thread (RTF 0.049) already beats the PyTorch reference's own 8-thread number (0.054), and int8 at a single thread (0.026) beats it by roughly 2x.
How it was made
- The reference PyTorch model's
forward()is one Python loop over 30.4 s chunks with a speaker cache mutated between iterations -- not exportable as one static graph, since its trip count depends on audio length and the cache's compression branches on Python ints. - Split at that loop boundary:
preprocessor_core.onnx(waveform -> log-mel) andmodel.onnx/model.int8.onnx(one chunk step: mel + speaker-cache context in, logits + new cache embeddings out) are static graphs; the outer chunking loop and the speaker cache (FIFO push/pop, score, boost, compress) are a plain numpy port indiarize.py. - Exported with
torch.onnx.export(dynamo=True), opset 18, dynamic axes on chunk length, context length and waveform length. attn_implementation="eager"and an explicitly materialized attention mask -- the reference's own mask-building helper skips masking entirely when it sees no padding at trace time, which would silently bake "no mask" into a static graph.- int8:
onnxruntime.quantization.quantize_dynamic, MatMul/Gemm nodes, per-channel;preprocessor_core.onnxstays fp32 (no meaningful MatMul/Gemm to quantize, at 0.14 MB). - Reference implementation:
transformersgit commit4b28d51d0d5f17ec20c23a187d0475a8e68810c8. - The exact scripts, and full design notes, source-code citations and every number in this
card:
scripts/in this package (scripts/README.md).
Licence and attribution
LICENSE: the OpenMDW License Agreement, version 1.1 -- the official plain-text
file from github.com/OpenMDW/OpenMDW
(see NOTICE for the exact commit and SHA-256). This applies to the whole package,
not only the converted graphs: the Model Materials carry it because the upstream model does,
and diarize.py, scripts/ and every other original file here carry it as this project's
own choice of licence, one licence for the package rather than two. Each shipped .py file
says so in its own header (SPDX-License-Identifier: LicenseRef-OpenMDW-1.1).
NOTICE: origin of the Model Materials this conversion is derived from, and how the
conversion was made.
This is an independent, community-produced conversion. It is not affiliated with, or endorsed by, NVIDIA Corporation, and makes no claim to NVIDIA's trademarks.
Model tree for joosthel/Nemotron-3-Diarization-ONNX
Base model
nvidia/Nemotron-3-Diarization