feat: Nemotron-3-Diarization support + speaker-attributed ASR - #71
Closed
localai-org-maint-bot wants to merge 17 commits into
Closed
localai-org-maint-bot wants to merge 17 commits into
localai-org-maint-bot wants to merge 17 commits into
Conversation
Assisted-by: Codex:GPT-5 [Codex]
Convert Nemotron-3-Diarization (.nemo) to GGUF with arch="diarization". The converter loads state_dict + config directly from the .nemo tar, bypassing SortformerEncLabelModel.restore_from() (which fails on NeMo versions that don't support self_attention_model='rope'). ModelLoader reads diarization-specific KV pairs (parakeet.diar.*, parakeet.encoder.self_attention_model, rope_base, rotary_fraction) and accepts models with no vocabulary (arch="diarization"). Model::load rejects diarization arch so the C-API falls through to DiarizationModel::load. test_model_loader skips vocab checks for diarization models.
DiarizationEncoder: 31-layer pre-LN RoPE Transformer (d_model=512, 8 heads, head_dim=64, ff_dim=2048). Uses ggml_flash_attn_ext for attention (CPU). FeatureStacking 8x subsampling in pre_encode. Key ggml workarounds: - flash_attn_ext returns [hd,H,T,1] not [hd,T,H,1]: no permute, just cont + reshape_2d - 3D ggml_cont after ggml_permute is broken (creates view not copy): use 2D transpose + cont instead DiarizationHead: encoder_proj (512->192), subpixel Conv1D 8x upsampling, sigmoid speaker head (192->8). Subpixel reshape uses reshape_3d -> reshape_2d -> 2D transpose+cont to avoid the 3D cont bug. Output is [n_spk, T_out] row-major via cont(transpose(sig)). Parity vs PyTorch reference: probs max_diff=0.0097 (F16 noise).
DiarizationModel: loads diarization GGUF, runs encoder + head, applies hysteresis thresholding (onset=0.5, offset=0.5) to produce SpeakerSegments with speaker labels and time ranges. SAS merge (sas_merge.hpp/.cpp): merge_asr_diarization assigns each ASR word to the speaker with the largest temporal overlap. Dominant speaker wins ties (first by start time, then by speaker index). group_speaker_words groups consecutive same-speaker words into SpeakerUtterances, splitting on speaker change or >0.5s gap. Words with no overlapping segment get speaker=-1. Parity: 584 segments (ours) vs 581 (reference) — 0.5% difference.
Bump ABI to 7. Add diarization C-API (Phase 1): - parakeet_capi_diarize_path / _pcm: run diarization on audio - parakeet_capi_diarize_json: JSON output with segments array - parakeet_capi_free_diar_results: free result array Add speaker-attributed ASR (SAS) C-API (Phase 3): - parakeet_sas_result struct (speaker, text, start, end, conf) - parakeet_capi_transcribe_and_diarize: run ASR + diarization, merge into speaker-attributed utterances (struct variant) - parakeet_capi_transcribe_and_diarize_json: same, JSON output with speakers/utterances/words arrays - parakeet_capi_free_sas_results: free result array SAS functions take separate ASR and diarization contexts so the caller can mix and match model types and sizes.
Build: - CMakeLists.txt: add diarization sources to parakeet library - examples/cli/CMakeLists.txt: add diarize CLI target - tests/CMakeLists.txt: register all diarization/SAS tests CLI: - examples/cli/diarize.cpp: minimal CLI (path -> segments JSON) Tests: - test_diarization.cpp: basic diarization smoke test - test_diarization_parity.cpp: full parity test vs PyTorch reference - test_diar_head_bisect.cpp: head stage-by-stage bisection - test_diar_layer0.cpp / _bisect.cpp: encoder layer 0 bisection - test_cont_check.cpp: verifies 2D cont works (PASS) - test_subpixel_check.cpp: verifies subpixel reshape (PASS) - test_3d_permute.cpp: documents 3D cont bug (expected FAIL) - test_sas_merge.cpp: 10 unit tests for SAS merge logic (PASS) - test_combined_offline.cpp: end-to-end SAS test (ASR + diarization) docs/diarization-plan.md: 3-phase plan with dependency graph.
mudler
force-pushed
the
feat/diarization-sas
branch
from
September 26, 2026 12:26
880b8a3 to
e497e08
Compare
Split the DiarizationEncoder into pre_encode() and transformer_forward() so the streaming path can run the transformer over [spkcache | chunk] without re-doing the mel front-end. Add forward_range() to DiarizationHead to compute probs for a sub-range of encoder frames (the chunk portion only). StreamingDiarization implements the AOSC mechanism from NeMo's Sortformer: chunked processing with a speaker cache, FIFO overflow, and per-speaker top-K compression of the spkcache. When fifo_len=0 (Nemotron-3 default) every chunk immediately overflows into the spkcache, which is then compressed back to spkcache_len frames. C-API ABI v8 adds: - parakeet_capi_diarize_stream_begin / _feed / _free (streaming diarization) - parakeet_capi_sas_stream_begin / _feed / _free (streaming SAS) Streaming SAS (Phase 3.4) buffers incoming PCM, feeds complete chunks to both ASR and diarization, and merges the results with the same SAS merge logic as the offline path. ModelLoader reads streaming config from GGUF KVs (parakeet.diar.chunk_len, spkcache_len, fifo_len, etc.). When absent, Nemotron-3-Diarization defaults are used so the streaming C-API works without converter changes. test_streaming_diarization verifies the streaming path produces segments in chronological order within the audio duration, and detects speakers. On the 2-minute test audio: 115 streaming segments vs 583 offline (the gap is expected — streaming uses chunk-local context, not full audio).
6 tasks
Write parakeet.diar.chunk_len, spkcache_len, fifo_len, and the AOSC compression tuning parameters as GGUF KV pairs. Values are read from the NeMo config 'streaming' section, with Nemotron-3-Diarization defaults (chunk_len=264, spkcache_len=264, fifo_len=0) when absent. The model loader already falls back to these defaults when the KVs are missing, so this is not a breaking change — but writing them explicitly ensures non-default models are handled correctly.
mudler-agent
added a commit
to mudler/vllm.cpp
that referenced
this pull request
Sep 27, 2026
…3324) ## Summary Adds speaker diarization and speaker-attributed ASR (SAS) to vllm.cpp by linking [parakeet.cpp](https://github.com/mudler/parakeet.cpp) as a FetchContent dependency. The diarization stack (Nemotron-3-Diarization Sortformer encoder, speaker head, AOSC streaming, and SAS merge layer) lives in parakeet.cpp; vllm.cpp wraps it through a clean C++ seam. ## What's new ### C++ seam (`include/vllm/multimodal/diarization.h`) - **`Diarizer`** class: loads a diarization GGUF, exposes `Diarize()` and `DiarizeWavFile()` returning speaker segments - **`SpeakerAttributedASR`** struct + `TranscribeAndDiarize()` helpers: run both ASR and diarization on the same audio and merge results ### C ABI (`include/vllm.h`, ABI v30) - `vllm_diarization_load`: load a diarization GGUF as a separate engine handle - `vllm_diarize_path` / `vllm_diarize_pcm`: diarize a WAV file or raw PCM - `vllm_transcribe_and_diarize` / `_pcm`: combined ASR + diarization (speaker-attributed utterances) - `vllm_diarization_free` / `vllm_sas_result_free`: cleanup ### Server endpoints (registered only when a diarizer/SAS is attached) - **`POST /v1/audio/diarizations`**: returns speaker segments as JSON - **`POST /v1/audio/sas`**: returns speaker-attributed utterances as JSON ### CMake - `parakeet.cpp` is fetched from GitHub (or pointed at a local tree via `VLLM_CPP_PARAKEET_CPP_DIR`) - Gated by `VLLM_CPP_WITH_DIARIZATION` (ON by default). When disabled, every diarization function returns an error and the endpoints are not registered — the library is byte-identical to before. ## Dependencies - [parakeet.cpp](https://github.com/mudler/parakeet.cpp) — the diarization engine, GGUF model loader, and SAS merge layer - [Nemotron-3-Diarization-GGUF](https://huggingface.co/mudler/Nemotron-3-Diarization-GGUF) — the quantized model checkpoint ## Parakeet.cpp PR The parakeet.cpp diarization implementation is in [PR #71](mudler/parakeet.cpp#71) (Phases 1–3.4: offline diarization, streaming diarization, SAS merge, streaming SAS). ## Test plan - [ ] Build with `VLLM_CPP_WITH_DIARIZATION=ON` — verify FetchContent populates parakeet.cpp and the library links - [ ] Build with `VLLM_CPP_WITH_DIARIZATION=OFF` — verify the library is byte-identical to `main` - [ ] Load a diarization GGUF via `vllm_diarization_load` — verify segments are returned - [ ] Run `vllm_transcribe_and_diarize` with a Parakeet ASR checkpoint + diarization GGUF — verify speaker-attributed utterances - [ ] `POST /v1/audio/diarizations` — verify JSON response with speaker segments - [ ] `POST /v1/audio/sas` — verify JSON response with speaker-attributed utterances
The offline diarization output disagreed with NeMo on most frames and almost never detected a second speaker. Three causes: - The subpixel upsample used the wrong interleave. NeMo reshapes the conv output as [T, up, hidden], so output frame t*up+u reads conv channels u*tf..u*tf+tf at frame t. The old code read channel h*up+u and wrote frame u*T+t, which scrambled time across the whole clip. The conv now runs as im2col + mul_mat in F32, and its time-major output reshapes straight into the upsampled sequence. - NeMo peak-normalizes the waveform in offline mode (x / (max(x) + 1e-3)). Without it every log-mel bin was shifted. - The mel was not trimmed to floor(S / hop), and the upsampled output kept the FeatureStacking pad frames. The pipeline up to the probabilities is now DiarizationModel:: speaker_probs, which returns the same tensor as NeMo's offline forward(). On a 2-speaker clip the F32 max prob diff vs NeMo drops from 0.96 to 0.004 and the segments match exactly. Assisted-by: Claude:claude-opus-5-5 [Claude Code]
scripts/gen_diar_baseline.py runs NeMo on a clip and stores the audio, the offline and streaming speaker probabilities and the diarize() segments in a baseline GGUF. test_diarization_accuracy checks the C++ offline pipeline against it: probability max/mean diff, segment count, speakers and boundaries (20 ms), and frame-level agreement (>= 99.5%). tests/fixtures/two_speakers.wav is 23.6 s of LibriSpeech (CC BY 4.0): speaker 1272 (1272-128104-0000, -0001, from the dev-clean set) and speaker 2086 (the existing speech.wav) alternating A-B-A-B with 0.5 s gaps. NeMo finds 5 segments across the 2 speakers. Assisted-by: Claude:claude-opus-5-5 [Claude Code]
test_diar_layer0, test_diar_layer0_bisect, test_diar_head_bisect and test_diarization_parity were one-off debugging dumps with no assertions. test_3d_permute, test_cont_check and test_subpixel_check documented "ggml bugs" that are not bugs: ggml_permute takes the destination of each source axis (the inverse of torch.permute), and flash_attn_ext is documented to return [head_dim, n_head, T]. The subpixel check also asserted the wrong upsample layout. test_diarization_accuracy and test_streaming_diarization replace them with checks against NeMo. test_diarization is now labelled "model". Assisted-by: Claude:claude-opus-5-5 [Claude Code]
Streaming diarization did not follow NeMo's Sortformer streaming: - chunk_len, spkcache_len, fifo_len and spkcache_update_period are encoder frames in NeMo (264 = 21.12 s), not mel frames, and the speaker cache was 8x too small. - The cache stored channels-first embeddings but was concatenated as if time-major, so [cache | chunk] was scrambled after one chunk. - The cache must hold pre-norm embeddings: NeMo applies embed_norm inside the encoder, after the bypassed pre-encoder. - NeMo runs the head over the whole [cache | fifo | chunk] sequence and feeds the downsampled predictions to the cache update. The head ran on the chunk alone. - The converter wrote no streaming keys for .nemo input, and read them from a "streaming" section that does not exist. spkcache_sil_frames_per_spk is 1 for Nemotron-3, not 3. StreamingDiarization now mirrors streaming_update and _compress_spkcache (sync mode). Top-k ties go to the earlier frame so the result is deterministic. Segments carry across chunk boundaries. On the fixture and on 31 s and 68 s clips (the last compresses the cache twice) the streaming segments match NeMo streaming exactly. The encoder builds one shared graph for the offline and streaming entry points, works time-major end to end, reads RoPE base, rotary fraction and pre_block_norm from the GGUF, and throws on graph failure instead of asserting. The converter drops the dead ASR-path diarization code and accepts a HF id. C-API (ABI 7, nothing released used 8): - SAS JSON uses the shared JSON writers: text is escaped and long utterances are no longer cut at 512 bytes. - transcribe_and_diarize returns a status. Zero utterances is success, not NULL. free_sas_results takes the count and frees every .text. - Streaming diarization takes 16 kHz PCM and computes the mel with StreamingMel (bit-identical to the whole-clip mel), instead of asking the caller for mel chunks. - Streaming SAS transcribes the uncommitted audio when a diarization chunk completes and commits only words that end 1 s before the cut, so words at chunk edges are not split. Assisted-by: Claude:claude-opus-5-5 [Claude Code]
ASR word and diarization segment boundaries can disagree by a frame or two, and a word that overlaps no segment got speaker -1. It now takes the nearest segment's speaker when that segment is within 0.5 s. Words farther from any segment are still unassigned. Assisted-by: Claude:claude-opus-5-5 [Claude Code]
docs/superpowers/ holds local planning notes and stays out of the tree. The file is kept locally. Assisted-by: Claude:claude-opus-5-5 [Claude Code]
NeMo's diarize() for Nemotron-3-Diarization runs cache-aware streaming inference (streaming_mode: true in the model config). diarize_path and diarize_pcm always ran the offline path, which attends over the whole clip: on a 12.3 min, 3 speaker recording it agreed with NeMo on 22% of speech frames and put almost everything on one speaker. The streaming path agrees on 100% and runs in 6.8 s instead of 11.8 s, since each chunk attends over at most 528 steps. The converter writes parakeet.diar.streaming_mode; GGUFs without it default to streaming. run_offline, run_streaming and segments_from_probs are public so tests can check both modes. test_diarization_accuracy now also checks the default diarize_pcm against NeMo's diarize(). PARAKEET_TEST_DIAR_VERBOSE prints the streaming probability diff per 10 s window. Assisted-by: Claude:claude-opus-5-5 [Claude Code]
Feeds the audio through parakeet_capi_diarize_stream_* in 1 s pieces, the live path, and prints the same JSON as the offline mode. Assisted-by: Claude:claude-opus-5-5 [Claude Code]
docs/diarization.md covers the model, conversion, offline and streaming inference, parity with NeMo, speaker-attributed ASR, speed and tests. AGENTS.md lists the new sources, tests, fixture, baseline script and C-API symbols. The implementation plan was a working note and now lives with the other plans outside the tree. Assisted-by: Claude:claude-opus-5-5 [Claude Code]
mudler
added a commit
that referenced
this pull request
Sep 28, 2026
…ASR (#71) Adds speaker diarization with nvidia/Nemotron-3-Diarization (Sortformer, up to 8 speakers) and speaker-attributed ASR that combines it with any Parakeet ASR model. Offline and live streaming paths are both exposed through the C-API (ABI 7, additive). diarize_* follows the checkpoint's streaming_mode like NeMo's diarize(): cache-aware streaming in 21.12 s chunks with a speaker cache. Against NeMo main the segments match exactly on a 23.6 s and a 68.5 s two-speaker clip, offline and streaming, and a 12.3 min three-speaker clip agrees on 100% of speech frames. F16 matches too; Q8_0 keeps the same segments. The 12.3 min clip runs in 6.8 s on CPU. tests/fixtures/two_speakers.wav (LibriSpeech, CC BY 4.0) and scripts/gen_diar_baseline.py back test_diarization_accuracy and test_streaming_diarization. See docs/diarization.md. Assisted-by: Claude:claude-opus-5-5 [Claude Code]
1 similar comment
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Add offline speaker diarization and speaker-attributed ASR (SAS) to
parakeet.cpp, porting the Nemotron-3-Diarization (Sortformer) model to ggml.
This is Phase 1 (standalone diarization) and Phase 3.1-3.2 (SAS merge +
combined C-API) from the plan in
docs/diarization-plan.md.Architecture
Nemotron-3-Diarization is a 31-layer pre-LN RoPE Transformer
(d_model=512, 8 heads, head_dim=64, ff_dim=2048) with FeatureStacking
8x subsampling and a subpixel Conv1D 8x upsampling speaker head.
The model takes mel spectrograms as input and outputs per-frame
speaker activity probabilities for up to 8 speakers. Hysteresis
thresholding (onset=0.5, offset=0.5) produces speaker segments.
The implementation is a separate
DiarizationModelclass (not the ASRModel), with its own GGUF arch string"diarization". The C-APItakes separate ASR and diarization contexts so callers can mix and
match model sizes.
Parity vs PyTorch reference
Speaker-attributed ASR (SAS)
parakeet_capi_transcribe_and_diarize()runs ASR + diarization on thesame audio and merges the results: each ASR word is assigned to the
speaker with the largest temporal overlap, then consecutive
same-speaker words are grouped into utterances.
ggml bugs discovered and worked around
ggml_flash_attn_extoutput layout: Returns[hd, H, T, 1],not
[hd, T, H, 1]. Workaround: no permute, justcont+reshape_2d.ggml_contafterggml_permuteis broken: It creates aview instead of copying. Workaround: use 2D
transpose+cont.[OC, IC, k]in GGUF, read as[k, IC, OC](reversed). No permute needed - conv output matcheswithin F16 noise.
test_3d_permute.cppdocuments bug #2 and is expected to FAIL.test_cont_check.cppandtest_subpixel_check.cppverify theworkarounds.
C-API changes (ABI v7)
New functions:
parakeet_capi_diarize_path/parakeet_capi_diarize_pcmparakeet_capi_diarize_jsonparakeet_capi_free_diar_resultsparakeet_capi_transcribe_and_diarize(struct variant)parakeet_capi_transcribe_and_diarize_json(JSON variant)parakeet_capi_free_sas_resultsNew struct:
parakeet_sas_result(speaker, text, start, end, conf).Commits
feat(converter): add diarization model support- converter,ModelLoader, Model::load rejection
feat(diarization): add encoder and speaker head- 31-layerRoPE transformer, subpixel Conv1D upsampling head
feat(diarization): add DiarizationModel and SAS merge layer-model class, hysteresis thresholding, SAS merge logic
feat(capi): add diarization and SAS C-API (ABI v7)- C-APIfunctions for diarization and speaker-attributed ASR
feat(diarization): add CLI, tests, build integration, and plan docHow to verify
Not in this PR
The full 3-phase plan is in
docs/diarization-plan.md.