Skip to content

[speechlm2] Add streaming inference pipeline for NemotronVoiceChat - #16198

Open
erastorgueva-nv wants to merge 6 commits into
mainfrom
duplex-streaming-native
Open

[speechlm2] Add streaming inference pipeline for NemotronVoiceChat#16198
erastorgueva-nv wants to merge 6 commits into
mainfrom
duplex-streaming-native

Conversation

@erastorgueva-nv

Copy link
Copy Markdown
Collaborator

Important

The Update branch button must only be pressed in very rare occassions.
An outdated branch is never blocking the merge of a PR.
Please reach out to the automation team before pressing that button.

What does this PR do ?

Add a streaming (chunk-by-chunk) inference pipeline for NemotronVoiceChat on the native PyTorch backend, following the same architecture as the NeMo ASR Inference Pipelines. Successor to #15571.

Collection: speechlm2

Changelog

  • Add StreamingS2SPipeline with run() for files/manifests and generate_step() for live audio
  • Add NemotronVoicechatInferenceWrapper with one frame loop: perception → LLM → TTS → codec decode
  • Add DuplexLLM / DuplexTTS contracts; native implementations in backend/pytorch/. Engine type is chosen at construction, not in the per-frame body
  • Add S2SPipelineBuilder (plain factory, same shape as asr/inference) and Hydra config s2s_streaming.yaml
  • Precision/determinism via inference_precision_from_cfg; callers shut down the pipeline in finally
  • Perception uses the ASR streaming-encoder CUDA graphs (encoder.set_streaming_cuda_graphs; subsequent chunks pass keep_all_outputs=False)
  • Slot-based S2SContextManager for decode-state lifetime, S2SStreamingOutput for accumulation
  • s2s_streaming_infer.py for files, directories, or manifests
  • Docs: streaming_inference.rst (architecture, config, trailing-silence vs batch padding)
  • Tests under tests/collections/speechlm2/nemo_inference_pipelines/: CPU units, tiny-model no-crash + offline-vs-streaming parity, public-11B native no-crash

To choose a voice, pass speaker_name matching a latent registered in the checkpoint (public 11B: Aria). speaker_reference is rejected — encoding a new wav goes through the anti-cloning projection and sounds wrong.

The public HF checkpoint (nvidia/NVIDIA-NemotronLabs-VoiceChat-11B) is supported. It has a function head; we decode those tokens (and feed them back into the next frame) but we do not actually call the functions. Executing tool calls would add a lot of complexity (API-call timing, etc.), and we might not release future native function-call models, so it's not worth implementing here.

batch_size=1

(cc @pzelasko)

Keeping this at 1.

The last review (#15571) noted that the inner forwards look capable of bs>1. Lock-step batching (pad every file to the longest and step them together) wouldn't add much on top of offline inference — if you want fast batched eval, use the offline path. It would also take a lot of extra code for not much gain, and it would be confusing to expose batch_size>1 without independent stream start/end: in a real-time deployment a new stream could only join the batch once the others had also finished, which is a pretty weird API.

Supporting several streams of different lengths at once is complex (per-stream caches for perception, LLM KV, EarTTS, codec, plus stream start/end). So we will not support this currently, making this pipeline just for local inference/initial PoC.

streaming.batch_size is asserted at construction and in generate_step.

vLLM-Omni follow-up

This PR is native only. The combined native+vLLM tree was designed first, so the frame loop and DuplexLLM / DuplexTTS contracts already match the combined form. VllmLLM / VllmEarTTS are stubs: selecting vllm_omni raises NotImplementedError at wrapper construction.

You can find a draft of vllm code (stock vllm==0.26.0 / vllm-omni==0.26.0, no fork; LLM and TTS independently switchable) on a parent commit on this branch: 1a2bd8d

Modifications to more general code — FYI @kevinhu-nv @Edresson

  • EarTTSModel: vectorized RVQ depth-sum embedding, optional per-subword embedding cache (use_tts_subword_cache), MaskGIT unmasking loop uses Python ints (compile-friendly)
  • DuplexEARTTS: skip get_codec_silence_frame() when the codec has random weights (slow encode of silence that the checkpoint overwrites anyway)
  • PyTorchEarTTS: optional torch.compile on the TTS backbone
  • DuplexSTTModel: function head (autoregressive feedback even when tools are not executed), shared LogitBoosts for agent/ASR channels, build_input_embedding so offline and streaming match addition order, cache_position / configurable cache_key for Nemotron KV cache
  • NemotronVoiceChat.from_pretrained: HF-format checkpoints with llm_artifacts/, meta-device init, skip_prefixes for submodules the caller will replace, return_logits for parity tests
  • text_utils: shared strip_timestamps, byte-level BPE decoding, BOS/EOS kept as literal strings
  • pretrained.py / hf_hub.py: meta-device LLM construct, skip pretrained ASR/LLM downloads when loading a VoiceChat HF dir, tokenizer export on save_pretrained

Usage

python examples/speechlm2/nemo_inference_pipelines/s2s_streaming_infer.py \
    audio_file=/path/to/audio.wav \
    s2s.model_path=nvidia/NVIDIA-NemotronLabs-VoiceChat-11B \
    s2s.speaker_name=Aria \
    s2s.llm_engine_type=native \
    s2s.tts_engine_type=native \
    streaming.chunk_size_in_secs=0.08 \
    streaming.buffer_size_in_secs=1.68
from nemo.collections.speechlm2.inference import S2SPipelineBuilder
from nemo.collections.speechlm2.inference.model_wrappers.engine_selection import (
    inference_precision_from_cfg,
)

with inference_precision_from_cfg(cfg.s2s):
    pipeline = S2SPipelineBuilder.build_pipeline(cfg)
    try:
        outputs = pipeline.run(audio_filepaths, options=options)
    finally:
        pipeline.shutdown()

GitHub Actions CI

The Jenkins CI system has been replaced by GitHub Actions self-hosted runners.

Trusted PRs run automatically through copy-pr-bot. For an untrusted PR, a maintainer can trigger CI by commenting
/ok to test <head-sha>; repeat this after a new push if the PR remains untrusted.

Before your PR is "Ready for review"

Pre checks:

  • Make sure you read and followed Contributor guidelines
  • Did you write any new necessary tests?
  • Did you add or update any necessary documentation?
  • Does the PR affect components that are optional to install? (Ex: Numba, Pynini, Apex etc)
    • No new optional runtime deps in this PR (vllm_omni is stubbed).
    • Reviewer: Does the PR have correct import guards for all optional libraries?

PR Type:

  • New Feature
  • Bugfix
  • Documentation

If you haven't finished some of the above items you can still open "Draft" PR.

Who can review?

Anyone in the community is free to review the PR once the checks have passed.
Contributor guidelines contains specific people who can review PRs to various areas.

Additional Information

…LM-Omni backends

StreamingS2SPipeline and the VoiceChat wrapper share one frame loop, with
LLM and TTS engines independently selectable.

Signed-off-by: Elena Rastorgueva <erastorgueva@nvidia.com>
Keep DuplexLLM/DuplexTTS and the Vllm* classes so the native frame loop
already matches the combined form. Selecting vllm_omni raises
NotImplementedError; the runtime is the parent commit on
duplex-vllm-omni-on-main.

Signed-off-by: Elena Rastorgueva <erastorgueva@nvidia.com>
Signed-off-by: Elena Rastorgueva <erastorgueva@nvidia.com>
Replace the VoiceChat-specific encoder capture with encoder.set_streaming_cuda_graphs so subsequent cache-aware steps share the ASR helper.

Signed-off-by: Elena Rastorgueva <erastorgueva@nvidia.com>
Remove redundant streaming overrides so native and converted EarTTS use the sampling values carried by the model checkpoint.

Signed-off-by: Elena Rastorgueva <erastorgueva@nvidia.com>
Keep distinct config, engine, and tiny-model parity checks while avoiding repeated cases and a second real-checkpoint test.

Signed-off-by: Elena Rastorgueva <erastorgueva@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented Sep 2, 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.

cas_cfg = cfg["model"]["speech_generation"]["model"]["tts_config"]["cas_config"]
if cas_cfg.pop("pretrained_tokenizer_name", None) is not None:
logging.info("NemotronLabs release shim: removed legacy CAS pretrained_tokenizer_name")
except (KeyError, TypeError):
@pzelasko

pzelasko commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

@codex review this from the angle of test and regression coverage: there is a public Nemotron VoiceChat checkpoint on HF that can be used for functional tests

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-02T00:49:47.087549Z e77e1ac Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e77e1ac127

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +195 to +196
@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires GPU")
def test_pipeline_no_crash_hf_11b(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate the 11B test behind the download opt-in

On any machine with CUDA, this test runs even when pytest was invoked without --with_downloads; the autouse gate in tests/conftest.py only skips tests carrying pytest.mark.with_downloads. Consequently, an ordinary SpeechLM2 GPU test run can unexpectedly download and load the public 11B checkpoint, consuming substantial time, disk, and GPU memory. Mark this checkpoint test with with_downloads (and preferably integration) so it remains an explicitly selected functional test.

Useful? React with 👍 / 👎.

Comment on lines +222 to +226
result = _run(pipeline, voicechat_audio_path)
output = result[0]
assert output.token_asr_text is None
assert output.raw_asr_text is None
assert output.token_function is not None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Assert a stable result from the public checkpoint

The only real-checkpoint test verifies that output fields are allocated, so regressions that misload weights or corrupt decoding can still pass while producing empty/padding-only tokens, silence, NaNs, or unrelated speech. Since this uses a fixed public checkpoint and fixed audio, configure deterministic decoding and assert a stable token/text invariant plus finite, non-silent audio; otherwise the new HF compatibility and native inference paths have no meaningful functional regression oracle.

AGENTS.md reference: AGENTS.md:L78-L78

Useful? React with 👍 / 👎.

Comment on lines +520 to +524
# Decode phase. Prefill-only frames (empty audio) are not passed to
# inference; outputs are stitched back 1:1 with the original *frames*.
non_empty_frames = [f for f in frames if f.samples.numel() > 0]
empty_terminal_frames = [f for f in frames if f.samples.numel() == 0 and f.is_last]
for frame in empty_terminal_frames:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cover prefill-only frames in the live streaming API

All added pipeline tests enter through run(), whose first frame already contains audio, so none exercise the documented live-client flow where generate_step() receives an empty initialization frame or an empty terminal frame. Add a direct test that performs empty prefill, multiple audio chunks, termination, and stream-ID reuse while checking incremental outputs and state cleanup; otherwise regressions in this separate branch can break microphone/server integrations despite the file-based suite passing.

AGENTS.md reference: AGENTS.md:L78-L78

Useful? React with 👍 / 👎.

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.

3 participants