Add speaker-tagged transcription to Nemotron ASR - #681
Conversation
603d607 to
91b641f
Compare
|
@LysanderdeJong Could you split the PR into smaller stacked PRs?
|
91b641f to
7854912
Compare
|
Split into three stacked PRs:
Each PR's last commit is its own change. The final tree is identical to the previous #681. |
f3d1f44 to
e9bf7ad
Compare
|
@LysanderdeJong When On the same audio, masked mode returned only the final result (text shortened here): Is this expected behavior? If so, please document that masked mode returns only the final transcript through the server API. If not, my concern is that adding streaming support for this feature to the existing endpoints could break the boundaries and contracts of ASR and diarization. I prefer reusing the existing Please also rebase. |
Nemotron ASR reads a Nemotron 3 Diarization speaker_probabilities file and labels its transcript by speaker, in NeMo's masked multitalker mode (one cache-aware stream per active speaker, the default) or by word attribution.
Each finished speaker segment now also goes out as a "speaker_k: words" partial text line, so the server forwards masked mode as append-only transcript.text.delta events whose concatenation is the final transcript. The streamed transcript lists segments in the order they finish; speaker turns and SegLST stay complete and sorted by start time.
e9bf7ad to
4af4841
Compare
|
Not intended, thanks for testing it through the server. Masked streaming now also publishes each finished segment as partial text lines ( One documented limit: a speaker's stream is paused while they're silent, so a segment's last token (often punctuation) can arrive only when they speak again. The delta has already gone out without it. SegLST keeps it. That affects about 2% of words on a 1 h 48 min recording. Rebased onto main; the fix is the new commit 4af4841. |
|
@LysanderdeJong Thanks. PR merged! |
Summary
nemotron_asr, driven by anemotron_3_diarspeaker-activity file: NeMo's masked multitalker ASR (default; one cache-aware stream per active speaker, so overlapping speech is transcribed per speaker) and word attribution (one transcript, cheaper)Third of three stacked PRs split from the original #681; #685 (spec-backed
nemotron_asroptions) and #686 (diarizerspeaker_probabilities.safetensorsandasr_laNprofiles) are merged.There is no pipeline or composite family: the diarizer and the ASR run as two commands, documented in
docs/asr.md.With NeMo's own diarizer output as input, masked mode reproduces NeMo's SegLST segment for segment on CPU F32: 100% on two full AMI meetings (14 and 17.5 min) and on every tested lookahead.
Changes and rationale
1. Attribution mode
One ASR pass; each word takes the speaker with the highest mean activity over a window around it (NeMo Python
get_word_dict_content_offline: 80 ms frames[first - 1, last + 1), clamped at 0.01, lowest index wins ties). Words are formed from tokens (a leading space starts a word). Offline runs it after decoding; streaming attributes each word once the next word starts.Evidence: on the AMI 20 s clip all 42 words get the same speaker as NeMo's function on the same diarizer file.
2. Masked mode
A port of
SpeakerTaggedASR.perform_parallel_streaming_stt_spkwithmasked_asr=true(the Hugging Face guide's "Option 2"):pad_and_drop_preencodedfor this diarizer: each step feeds[9 pre-encode cache frames | 8·(lookahead + 1) new frames], runs the causal subsampling statelessly, and drops the first 2 encoder frames. This is a new raw-cache variant of the stream graph; the existing streaming path is untouched and byte-identical.NemotronDecoderStreamState. A speaker's stream starts at its first active chunk from zero caches and is frozen while inactive.lookahead + 1frames of the diarizer stream, repeated 8×, left-padded with zeros (so the cache frames are always masked) or cut from the right on the final chunk; masked frames become 0.0 and frames that were 0.0 become −16.6355, exactly asmask_features.update_sessionwise_seglsts_for_parallel: per-speaker segments that break on a pause overspeaker_segment_gap_sec(1.0, NeMosent_break_sec), with word continuations and leading punctuation kept on the previous segment. The hypothesis text follows NeMo exactly (space before punctuation removed, language tags stripped, SentencePiece's⁇for<unk>).3. Lookahead resolution
Masked mode reads the lookahead from the file:
chunk_len - 1whenchunk_right_context == 0and the value is supported. An explicitlookahead_tokenswins with a warning; a file without a matching geometry (for examplevery_high) uses 13 with a warning that the masks are not NeMo-exact.4. Outputs and options
speaker_turnswithtext(--turns-out), labelsspeaker_kas in the diarizerseglstartifact in NeMo's schema (--out-dirwritesseglst.json)speaker_k: wordslines, in start time order offline and in finish order when streamingspeaker_segment_max_sec(10 s) arrive as several events, the final result keeps full segmentstranscript.text.deltaevents are append-only and concatenate totranscript.text.done; ordinary streaming and the server endpoints are unchangedspeaker_probabilities,speaker_mode,speaker_mask,speaker_segment_gap_sec,speaker_segment_max_secValidation
Environment: Windows 11, MSVC (VS 2026), CUDA 13.3, 2× RTX 4070 Ti SUPER. References: NVIDIA-NeMo/Speech
cf724ac, CPU FP32, the guide's Option 2 (max_num_of_spks=8 masked_asr=true parallel_speaker_strategy=true cache_gating=true binary_diar_preds=true att_context_size=[56,13] fifo_len=264 spkcache_update_period=222 target_lang=auto,strip_lang_tags=true), checkpointsnvidia/nemotron-3.5-asr-streaming-0.6bandnvidia/Nemotron-3-Diarization. NeMo runs are reproducible byte for byte.Single-speaker baseline (unchanged paths)
Lookahead 13, CPU F32: offline tokens (id and 80 ms frame) equal NeMo on AMI 20 s (79/79) and a 120 s file (527/527). The existing streaming path matches too, except that it never computes the last centered feature frame, so NeMo's final
.at that frame is missing (pre-existing, unchanged).Masked mode vs NeMo, NeMo's diarizer output as input
To test the ASR side alone, NeMo's in-pipeline diarizer stream was written to our file format. Identical = same speaker, start, end and text.
The CUDA differences are single tokens (
uh, a repeated word) from float noise; running the speakers one at a time instead of batched gives the same differences.End to end on AMI (our diarizer,
asr_la13; attribution usesvery_high)Four AMI test meetings (ES2004a, IS1009a, TS3003a, EN2002a Mix-Headset; 92 min, 14,837 reference words from the AMI manual annotations), cpWER with meeteval, lowercase,
[^a-z0-9' ]removed, CUDA F32:masked(default)masked,speaker_mask=audioattributionMasked mode is within 5 errors of NeMo over 14,837 words. During development two alternatives were measured and dropped: filling masked frames with the log floor instead of NeMo's 0.0 (33.18% total) and NeMo-Speech.cpp's 160 ms onset window for attribution (35.43%, within noise of NeMo Python's span window, which is kept because it matches NeMo word for word). Attribution loses mostly deletions: one transcript cannot hold two speakers talking at once.
Performance
Masked mode, 120 s meeting, CUDA F32, k speakers active all the time. "One at a time" is the unbatched variant measured during development:
Peak VRAM includes 870 MiB used by other processes; going from 1 to 8 active speakers adds about 250 MiB. At 8 speakers the RNNT decoder (2.2 s) dominates. On real AMI audio masked mode runs at about 75x real time (17.5 min in 14 s).
Other checks
nemotron_asr_speaker_tagging_test(ported NeMo segment, gating and mask cases, lookahead resolution, file validation) andnemotron_3_diar_streaming_testpasstools/check_loader_catalog_sync.pypassesReproduce:
Known limitations
floor(samples / 160)rows. The final chunk's mask can differ in the last 1.12 s.--mode streaming. Masked mode is chunked in both modes.speaker_mask=audiorecomputes features per speaker and chunk and is about 15× slower thanmel.