[Model] Add native full-duplex Nemotron VoiceChat serving (#6089)

Signed-off-by: Sysy <1370724210@qq.com>
Signed-off-by: sy03 <137072421@qq.com>
Co-authored-by: sy03 <137072421@qq.com>
This commit is contained in:
Sy03andsy03 authored and GitHub committed 2026-08-26 13:19:20 -07:00
1 parent c2723979c0
commit 432b2c6ca3
40 files changed
+3560 -154

No files matched your search

+1 -1
View File
@@ -192,7 +192,7 @@ steps:
grade: Blocking
commands:
- |
timeout 20m bash -c '
timeout 45m bash -c '
pytest -s -v tests/e2e/online_serving/test_cosyvoice3_tts_expansion.py -m "slow" --run-level "core_model"
'
+70 -7
View File
@@ -1,7 +1,7 @@
# NemotronLabs VoiceChat 11B
> Offline speech-to-speech: a 16 kHz user utterance in, the agent's spoken reply
> (text + 22.05 kHz WAV) out, on a 3-stage vLLM-Omni pipeline.
> Speech-to-speech on a 3-stage vLLM-Omni pipeline: offline single-turn
> inference and experimental model-native, frame-locked Realtime serving.
## Summary
@@ -11,8 +11,8 @@
timeline: a Conformer + NemotronH hybrid-Mamba thinker emits one text token
per acoustic frame, a Gemma3-1B EAR-TTS talker turns the text timeline into
31-quantizer RVQ code stacks, and an RVQ-VAE codec decodes them to audio.
- Mode: Offline single-turn inference (batch=1). Online/duplex serving,
function calling, and batch>1 are documented follow-ups.
- Mode: Offline single-turn inference (batch=1), plus experimental native
duplex Realtime serving at one 80 ms microphone frame per scheduler wake.
- Maintainer: [`@yuekaizhang`](https://github.com/yuekaizhang)
## When to use this recipe
@@ -33,6 +33,11 @@ NeMo modules (`nemo_vendored/`), so no `nemo_toolkit` install is needed.
[`vllm_omni/model_executor/models/nemotron_voicechat/`](../../vllm_omni/model_executor/models/nemotron_voicechat/)
- Staged pipeline config:
[`vllm_omni/deploy/nemotron_labs_voicechat.yaml`](../../vllm_omni/deploy/nemotron_labs_voicechat.yaml)
- Native duplex config and E2E probe:
[`vllm_omni/deploy/nemotron_labs_voicechat_duplex.yaml`](../../vllm_omni/deploy/nemotron_labs_voicechat_duplex.yaml),
[`tests/e2e/online_serving/nemotron_voicechat_realtime_duplex.py`](../../tests/e2e/online_serving/nemotron_voicechat_realtime_duplex.py)
- Nightly model-level gate:
[`tests/e2e/online_serving/test_nemotron_voicechat_duplex.py`](../../tests/e2e/online_serving/test_nemotron_voicechat_duplex.py)
- Upstream model card:
[`nvidia/NVIDIA-NemotronLabs-VoiceChat-11B`](https://huggingface.co/nvidia/NVIDIA-NemotronLabs-VoiceChat-11B)
- Reference implementation: NVIDIA-NeMo/Speech, branch `nemotron-labs-voicechat`
@@ -60,7 +65,7 @@ output stayed within one word of the reference in testing.
- OS: Linux
- Python: 3.12
- vLLM version: 0.26.0
- vLLM version: 0.27.0
- vLLM-Omni version or commit: this PR / current `main`
#### Command
@@ -88,6 +93,55 @@ The reply text should read as a coherent spoken-style answer to the question in
the input WAV, and the WAV should transcribe to (approximately) the same text
with any ASR model.
#### Native duplex serving
The checkpoint does not contain the underlying Nemotron text tokenizer. Set
`NEMOTRON_VOICECHAT_LLM_PATH` to a local snapshot of
`nvidia/NVIDIA-Nemotron-Nano-9B-v2` before starting an offline deployment.
```bash
export HF_HUB_OFFLINE=1
export TRANSFORMERS_OFFLINE=1
export NEMOTRON_VOICECHAT_LLM_PATH=/path/to/NVIDIA-Nemotron-Nano-9B-v2
vllm-omni serve /path/to/NVIDIA-NemotronLabs-VoiceChat-11B \
--omni \
--served-model-name nemotron-voicechat \
--deploy-config vllm_omni/deploy/nemotron_labs_voicechat_duplex.yaml
```
The single-GPU profile runs three eager fp32 engine processes on one device.
In another shell, stream only the user channel of one of NVIDIA's bundled
stereo conversation fixtures:
```bash
python tests/e2e/online_serving/nemotron_voicechat_realtime_duplex.py \
--model nemotron-voicechat \
--input-wav /path/to/NVIDIA-NemotronLabs-VoiceChat-11B/turn_taking.wav \
--input-channel 0 \
--output-dir results/nemotron_voicechat_duplex \
--timeout-s 600
```
The probe requires a completed response, non-silent 22.05 kHz output, and the
advertised native 80 ms append capabilities. Add `--expect-function-call` and
use `tool_call.wav` to validate the function-call channel. To validate the
Realtime tool round trip, also provide the expected arguments and return a
tool result:
```bash
python tests/e2e/online_serving/nemotron_voicechat_realtime_duplex.py \
--model nemotron-voicechat \
--input-wav /path/to/NVIDIA-NemotronLabs-VoiceChat-11B/tool_call.wav \
--output-dir results/nemotron_voicechat_tool_call \
--expect-function-call \
--expected-function-name generate_random_number \
--expected-function-arguments '{"min":1,"max":50}' \
--function-output 20 \
--expected-post-tool-text "random number"
```
#### Notes
- Memory usage: the shipped yaml runs all three stages on one GPU
@@ -109,5 +163,14 @@ with any ASR model.
instead of pausing.
- The talker's `max_tokens` is 16383 (its stage prompt is one placeholder
token, and the stage context is 16384).
- Known limitations: batch=1 only; offline single turn (no online/duplex
serving yet); the function-call channel is decoded but not yet acted on.
- The 80 ms frame period is a model/protocol contract, not a throughput
guarantee. In an eager fp32 H200 profile, Stage 2 emitted 80 ms packets at
roughly 0.87--0.88 s intervals (RTF about 10.8--10.9); placing the three
stages on separate GPUs did not remove the serial 8-step EAR-TTS bottleneck.
The current native duplex path is functionally streaming but not wall-clock
realtime.
- Known limitations: batch=1 only. The native duplex deployment allows one
active session and does not support barge-in. Tool execution remains
client-owned: the server emits function-call events, accepts a validated
`function_call_output`, and resumes the live model with the returned result;
it does not execute arbitrary tools itself.
@@ -198,6 +198,7 @@ def test_resumable_segment_boundary_keeps_pre_transition_send_watermark() -> Non
inter_stage_output,
session,
True,
new_token_ids=[42],
confirmed_num_computed_tokens=26,
)
@@ -427,3 +428,33 @@ def test_ready_async_chunk_prompt_replacement_releases_stale_kv_once() -> None:
assert sched.chunk_transfer_adapter.replaced_streaming_prompt_ids == set()
assert sched.chunk_transfer_adapter.requests_with_ready_chunks == {session.request_id}
assert sched.chunk_transfer_adapter.requests_num_chunks_sent == {}
def test_chunk_segment_cleanup_keeps_requeued_resumable_receiver() -> None:
"""A WAITING_FOR_CHUNK stop must not delete its newly parked session."""
session = _make_request()
session.resumable = True
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
def queue(*requests):
result = MagicMock()
result.requests = set(requests)
result.add_request.side_effect = result.requests.add
result.remove_requests.side_effect = result.requests.difference_update
return result
sched = OmniARScheduler.__new__(OmniARScheduler)
sched.running = []
sched.waiting = queue()
sched.skipped_waiting = queue(session)
sched.num_waiting_for_streaming_input = 1
sched.chunk_transfer_adapter = SimpleNamespace(segment_finished_requests={session.request_id})
sched._resume_downstream_chunk_receiver(session)
sched._remove_stopped_requests_from_queues(set(), {session})
assert session.status == RequestStatus.WAITING
assert session in sched.waiting.requests
assert session not in sched.skipped_waiting.requests
assert sched.num_waiting_for_streaming_input == 0
assert session.request_id not in sched.chunk_transfer_adapter.segment_finished_requests
@@ -1209,6 +1209,67 @@ def test_cleanup_preserves_pending_save(build_adapter):
assert len(adapter._pending_save_reqs) == 1
def test_abort_invalidates_queued_sender_task_before_external_id_reuse(build_adapter):
adapter, connector = build_adapter(stage_id=1)
request = _req("req-old", RequestStatus.WAITING, external_req_id="ext-reused")
adapter.custom_process_next_stage_input_func = lambda **kwargs: OmniPayloadStruct(
codes=CodesStruct(audio=torch.tensor([1], dtype=torch.long))
)
adapter.save_async(multimodal_output=None, request=request)
stale_task = adapter._pending_save_reqs.popleft()
adapter.cleanup(request.request_id, request.external_req_id)
adapter._send_single_request(stale_task)
connector.put.assert_not_called()
assert request.external_req_id not in adapter.put_req_chunk
replacement = _req("req-new", RequestStatus.WAITING, external_req_id=request.external_req_id)
adapter.save_async(multimodal_output=None, request=replacement)
current_task = adapter._pending_save_reqs.popleft()
adapter._send_single_request(current_task)
connector.put.assert_called_once()
assert connector.put.call_args.kwargs["put_key"] == "ext-reused_1_0"
def test_finish_requests_does_not_wait_for_inflight_send(build_adapter):
adapter, connector = build_adapter(stage_id=1)
first = _req("req-first", RequestStatus.WAITING, external_req_id="ext-first")
adapter.custom_process_next_stage_input_func = lambda **kwargs: OmniPayloadStruct()
put_started = threading.Event()
release_put = threading.Event()
def blocking_put(**kwargs):
put_started.set()
release_put.wait(timeout=2)
return True, 1, {}
connector.put.side_effect = blocking_put
adapter.save_async(multimodal_output=None, request=first)
task = adapter._pending_save_reqs.popleft()
sender = threading.Thread(target=adapter._send_single_request, args=(task,))
sender.start()
assert put_started.wait(timeout=1)
cleaner = threading.Thread(
target=adapter.finish_requests,
args=([first.request_id], RequestStatus.FINISHED_ABORTED, {first.request_id: first}),
)
cleaner.start()
try:
cleaner.join(timeout=0.5)
assert not cleaner.is_alive()
assert task["sender_token"].cancelled
assert first.external_req_id in adapter._sender_tokens
finally:
release_put.set()
sender.join(timeout=1)
cleaner.join(timeout=1)
assert not sender.is_alive()
assert first.external_req_id not in adapter._sender_tokens
assert first.external_req_id not in adapter.put_req_chunk
def test_cleanup_only_affects_target_request(build_adapter):
"""Cleanup for one request must not affect another request's state."""
adapter, _ = build_adapter(stage_id=1)
@@ -2216,3 +2277,43 @@ def test_expiry_is_per_request(build_adapter):
assert adapter.collect_timed_out_request_ids(timeout_s=600.0) == {"stalled"}
assert "healthy" in adapter._waiting_since
def test_abort_clears_native_codec_state_before_external_id_reuse(build_adapter):
from vllm_omni.model_executor.stage_input_processors.nemotron_voicechat import (
talker2code2wav_async_chunk,
)
adapter, _ = build_adapter(stage_id=1, connector_extra={"codec_chunk_frames": 1})
external_req_id = "ext-native-abort"
first = _req("req-native-abort", RequestStatus.WAITING, external_req_id=external_req_id)
first.resumable = True
first.model_intermediate_buffer = None
first.additional_information = {"meta": {"codec_streaming": True}, "nvc_logical_prompt_len": 1}
first_frame = torch.full((1, 31), 101, dtype=torch.long)
talker2code2wav_async_chunk(
adapter,
{"codes": {"audio": first_frame}, "meta": {"codec_streaming": True}},
first,
is_finished=False,
)
adapter.requests_num_chunks_sent[external_req_id] = 1
adapter.finish_requests([first.request_id], RequestStatus.FINISHED_ABORTED, {first.request_id: first})
assert external_req_id not in adapter.request_payload
assert external_req_id not in adapter.requests_num_chunks_sent
replacement = _req("req-native-abort", RequestStatus.WAITING, external_req_id=external_req_id)
replacement.resumable = True
replacement.model_intermediate_buffer = None
replacement.additional_information = first.additional_information
replacement_payload = talker2code2wav_async_chunk(
adapter,
{"codes": {"audio": torch.full((1, 31), 202, dtype=torch.long)}, "meta": {"codec_streaming": True}},
replacement,
is_finished=False,
)
assert torch.equal(replacement_payload.codes.audio, torch.full((1, 31), 202, dtype=torch.long))
@@ -0,0 +1,94 @@
from __future__ import annotations
from types import SimpleNamespace
import numpy as np
import pytest
from vllm_omni.experimental.fullduplex.engine.duplex_runtime import duplex_resource_request_id
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
from vllm_omni.experimental.fullduplex.nemotron_voicechat.data_plane import (
NemotronVoiceChatDataPlaneContext,
NemotronVoiceChatDataPlaneSession,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_RUNTIME = {
"nvc_text_bos_id": 0,
"nvc_text_eos_id": 1,
"nvc_text_pad_id": 12,
"nvc_function_sotc_id": 20,
"nvc_function_eotc_id": 21,
"nvc_tokenizer_ref": "test-tokenizer",
}
def _projector(encode_audio=lambda *_: None) -> NemotronVoiceChatDataPlaneSession:
projector = NemotronVoiceChatDataPlaneSession(encode_audio)
projector.configure_runtime(_RUNTIME, tokenizer=SimpleNamespace(decode=lambda *_args, **_kwargs: "text"))
return projector
def _stage0_output(request_id: str = "req-0", *, text_token: int = 12, function_token: int | None = None) -> object:
metadata: dict[str, object] = {"nvc_text_token_ids": [text_token]}
if function_token is not None:
metadata["nvc_function_token"] = [function_token]
completion = SimpleNamespace(multimodal_output=metadata)
return SimpleNamespace(
stage_id=0,
request_output=SimpleNamespace(request_id=request_id, outputs=[completion]),
)
def test_function_channel_projects_completed_call_without_ending_speech() -> None:
projector = _projector()
projector._decode = lambda token_ids: (
'[{"name":"weather","arguments":{"city":"Shanghai"}}]' if token_ids == [99] else ""
)
context = NemotronVoiceChatDataPlaneContext(epoch=0)
events = []
for function_token in (20, 99, 21):
events.extend(projector.project_output(_stage0_output(function_token=function_token), context=context))
listen = [event for event in events if event.get("is_listen") is True]
function = [event for event in events if event.get("function_call") is True]
assert len(listen) == 3
assert len(function) == 1
assert function[0]["name"] == "weather"
assert function[0]["arguments"] == '{"city":"Shanghai"}'
projector._decode = lambda _token_ids: "not-json"
bad_events = []
for function_token in (20, 98, 21):
bad_events.extend(projector.project_output(_stage0_output(function_token=function_token)))
assert any(event.get("error_code") == "nemotron_function_call_parse_error" for event in bad_events)
def test_new_epoch_request_does_not_inherit_partial_function_or_frame_state() -> None:
projector = _projector(lambda *_: "audio")
old_request = duplex_resource_request_id(DuplexFence("sid", epoch=1), "stage0")
new_request = duplex_resource_request_id(DuplexFence("sid", epoch=2), "stage0")
projector.begin_request(old_request)
list(projector.project_output(_stage0_output(old_request, text_token=42, function_token=20)))
projector.begin_request(new_request)
# EOTC in the new epoch must not close the old epoch's partial function.
new_events = list(projector.project_output(_stage0_output(new_request, text_token=1, function_token=21)))
assert not [event for event in new_events if event.get("function_call") is True]
assert not [event for event in new_events if event.get("end_of_turn") is True]
audio = SimpleNamespace(
stage_id=2,
request_output=SimpleNamespace(
request_id=new_request,
outputs=[
SimpleNamespace(multimodal_output={"model_outputs": [np.ones(1764, dtype=np.float32)], "sr": [22050]})
],
),
)
audio_events = list(projector.project_output(audio))
assert sum(event.get("end_of_turn") is True for event in audio_events) == 1
@@ -0,0 +1,73 @@
from __future__ import annotations
import base64
import numpy as np
import pytest
from vllm_omni.experimental.fullduplex.nemotron_voicechat.input import (
NemotronVoiceChatPcmAppendBuffer,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _payload(samples: np.ndarray, *, sample_rate_hz: int = 16000, fmt: str = "pcm_f32le") -> dict[str, object]:
return {
"type": "audio",
"audio": base64.b64encode(np.asarray(samples, dtype=np.float32).tobytes()).decode("ascii"),
"format": fmt,
"sample_rate_hz": sample_rate_hz,
}
def _append(buffer, samples, operation_id):
return buffer.prepare_append(
_payload(np.asarray(samples, dtype=np.float32)),
operation_id=operation_id,
chunk_period_ms=80,
allow_emit=True,
)
def test_irregular_browser_packets_emit_exact_ordered_80ms_frame() -> None:
buffer = NemotronVoiceChatPcmAppendBuffer()
assert _append(buffer, np.arange(1000), "packet-1") is None
reservation = _append(buffer, np.arange(1000, 1280), "packet-2")
assert reservation is not None
assert reservation.operation_id == "packet-2"
decoded = np.frombuffer(base64.b64decode(reservation.payload["audio"]), dtype=np.float32)
np.testing.assert_array_equal(decoded, np.arange(1280, dtype=np.float32))
assert buffer.pending_byte_count == 0
def test_append_rejects_invalid_frame_contract() -> None:
cases = (
(1281, 16000, "pcm_f32le", 0.0, "at most 1280"),
(1, 8000, "pcm_f32le", 0.0, "sample_rate_hz"),
(1, 16000, "audio/pcm", 0.0, "format"),
(1, 16000, "pcm_f32le", np.nan, "finite"),
)
for samples, sample_rate_hz, fmt, value, message in cases:
with pytest.raises(ValueError, match=message):
NemotronVoiceChatPcmAppendBuffer().prepare_append(
_payload(np.full(samples, value, dtype=np.float32), sample_rate_hz=sample_rate_hz, fmt=fmt),
operation_id="invalid",
chunk_period_ms=80,
allow_emit=True,
)
def test_terminal_commit_rejects_more_than_one_frame_tail() -> None:
buffer = NemotronVoiceChatPcmAppendBuffer()
for index, samples in enumerate((1000, 281)):
buffer.prepare_append(
_payload(np.zeros(samples, dtype=np.float32)),
operation_id=f"tail-{index}",
chunk_period_ms=80,
allow_emit=False,
)
with pytest.raises(ValueError, match="more than one 80 ms frame"):
buffer.prepare_commit(operation_id="commit", chunk_period_ms=80)
@@ -0,0 +1,115 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import base64
from types import SimpleNamespace
import numpy as np
import pytest
from vllm.sampling_params import SamplingParams
from vllm_omni.experimental.fullduplex.engine.contracts import DuplexInputMode
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
from vllm_omni.experimental.fullduplex.nemotron_voicechat.runtime import (
NemotronVoiceChatDuplexRuntimeExtension,
)
from vllm_omni.experimental.fullduplex.nemotron_voicechat.serving_adapter import (
NemotronVoiceChatServingRuntimeAdapter,
_render_tool_response,
)
from vllm_omni.model_executor.models.nemotron_voicechat.nemotron_voicechat_thinker import (
NemotronVoiceChatThinkerForConditionalGeneration,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _frame() -> dict[str, object]:
raw = np.zeros(1280, dtype=np.float32).tobytes()
return {
"type": "audio",
"audio": base64.b64encode(raw).decode("ascii"),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
def _plan(extension, *, input_seq: int):
return extension.plan_append(
request_id="req",
fence=DuplexFence("sid", incarnation=2, epoch=3),
session_config={},
runtime_config={
"nvc_prompt_token_ids": [0, 42, 1],
"nvc_text_pad_id": 12,
"nvc_max_model_len": 8192,
},
seq=input_seq,
turn_seq=input_seq,
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
payload=_frame(),
final=False,
sampling_params=SamplingParams(),
)
def test_first_append_prefills_prompt_then_each_append_consumes_one_frame() -> None:
extension = NemotronVoiceChatDuplexRuntimeExtension()
params = extension.configure_sampling_params(
runtime_config={}, defaults=(SamplingParams(temperature=0.7, max_tokens=4),)
)[0]
assert params.temperature == 0.0 and params.max_tokens == 1 and params.top_p == 1.0 and params.top_k == 0
first = _plan(extension, input_seq=1)
later = _plan(extension, input_seq=2)
assert first.prompt["prompt_token_ids"] == [0, 42, 1, 12]
assert later.prompt["prompt_token_ids"] == [12]
assert first.prompt["model_intermediate_buffer"]["duplex"]["source_input_seq"] == 1
assert later.prompt["model_intermediate_buffer"]["duplex"]["source_input_seq"] == 2
def test_append_rejects_stage0_context_overflow() -> None:
with pytest.raises(ValueError, match="max_model_len"):
_plan(NemotronVoiceChatDuplexRuntimeExtension(), input_seq=8190)
def test_function_output_becomes_versioned_nvidia_channel_tokens() -> None:
encoded: list[str] = []
adapter = NemotronVoiceChatServingRuntimeAdapter(lambda *_: None)
adapter._tokenizer = SimpleNamespace(
encode=lambda text, **_kwargs: encoded.append(text) or [31, 32, 33],
)
first = adapter.runtime_config_for_function_output(
{"type": "function_call_output", "call_id": "call-1", "output": '{"result":20}'},
{},
)
second = adapter.runtime_config_for_function_output(
{"type": "function_call_output", "call_id": "call-2", "output": "plain text"},
first,
)
assert _render_tool_response('{"result":20}') == '<TOOL_RESPONSE>[{"result":20}]</TOOL_RESPONSE>'
assert encoded == [
'<TOOL_RESPONSE>[{"result":20}]</TOOL_RESPONSE>',
'<TOOL_RESPONSE>["plain text"]</TOOL_RESPONSE>',
]
assert first["nvc_function_response_generation"] == 1
assert second["nvc_function_response_generation"] == 2
assert second["nvc_function_response_token_ids"] == [31, 32, 33]
assert second["nvc_function_response_call_id"] == "call-2"
assert second["nvc_function_response_batches"] == [
{"generation": 1, "call_id": "call-1", "token_ids": [31, 32, 33]},
{"generation": 2, "call_id": "call-2", "token_ids": [31, 32, 33]},
]
session = {"function_response_generation": 0, "forced_function_tokens": []}
NemotronVoiceChatThinkerForConditionalGeneration._sync_forced_function_response(session, second)
assert session["function_response_generation"] == 2
assert session["forced_function_token"] == 31
assert session["forced_function_tokens"] == [31, 32, 33, 31, 32, 33]
@@ -0,0 +1,364 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import argparse
import asyncio
import base64
import json
import math
import uuid
import wave
from collections.abc import Sequence
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import numpy as np
from scipy.signal import resample_poly
from vllm_omni.experimental.fullduplex.client import RealtimeDuplexClient, wait_for, write_pcm16_wav
INPUT_SAMPLE_RATE_HZ = 16_000
OUTPUT_SAMPLE_RATE_HZ = 22_050
FRAME_SAMPLES = 1_280
FRAME_PERIOD_S = FRAME_SAMPLES / INPUT_SAMPLE_RATE_HZ
DEFAULT_FUNCTION_TOOLS = [
{
"type": "function",
"name": "generate_random_number",
"description": "Generate a random integer between min and max (inclusive).",
"parameters": {
"type": "object",
"properties": {
"min": {"type": "integer", "description": "Minimum value (inclusive)"},
"max": {"type": "integer", "description": "Maximum value (inclusive)"},
},
"required": ["min", "max"],
},
}
]
DEFAULT_INSTRUCTIONS = "You are NVIDIA Voice Chat. Answer briefly. Start by greeting the user."
DEFAULT_FUNCTION_INSTRUCTIONS = (
"You are NVIDIA Voice Chat. If the user's request matches an available tool, "
"you MUST call that tool instead of answering from your own knowledge. "
"Use only argument values spoken by the user and never invent missing values."
)
def _url(base_url: str, model: str, session_id: str) -> str:
parts = urlsplit(base_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query.update(duplex="1", model=model, autostart="0", session_id=session_id)
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
def _read_wav(path: Path, *, input_channel: int = 0) -> np.ndarray:
with wave.open(str(path), "rb") as wav_file:
channels = wav_file.getnchannels()
width = wav_file.getsampwidth()
source_rate = wav_file.getframerate()
raw = wav_file.readframes(wav_file.getnframes())
dtypes = {1: np.uint8, 2: np.dtype("<i2"), 4: np.dtype("<i4")}
if width not in dtypes:
raise ValueError(f"unsupported input sample width: {width}")
pcm = np.frombuffer(raw, dtype=dtypes[width]).astype(np.float32)
pcm = (pcm - 128.0) / 128.0 if width == 1 else pcm / float(1 << (width * 8 - 1))
if not 0 <= input_channel < channels:
raise ValueError(f"input channel {input_channel} is outside WAV channel count {channels}")
if channels > 1:
pcm = pcm.reshape(-1, channels)[:, input_channel]
if source_rate != INPUT_SAMPLE_RATE_HZ:
divisor = math.gcd(source_rate, INPUT_SAMPLE_RATE_HZ)
pcm = resample_poly(pcm, up=INPUT_SAMPLE_RATE_HZ // divisor, down=source_rate // divisor)
return np.ascontiguousarray(pcm, dtype="<f4")
async def _stream(client: RealtimeDuplexClient, pcm: np.ndarray, *, max_frames: int | None, realtime: bool) -> int:
count = math.ceil(pcm.size / FRAME_SAMPLES)
if max_frames is not None:
count = min(count, max_frames)
for seq in range(count):
frame = pcm[seq * FRAME_SAMPLES : (seq + 1) * FRAME_SAMPLES]
frame = np.pad(frame, (0, FRAME_SAMPLES - frame.size)).astype("<f4")
await client.send(
{
"type": "input_audio_buffer.append",
"audio": base64.b64encode(frame).decode("ascii"),
"format": "pcm_f32le",
"sample_rate_hz": INPUT_SAMPLE_RATE_HZ,
"duration_ms": 80,
"audio_end_ms": (seq + 1) * 80,
}
)
if realtime:
await asyncio.sleep(FRAME_PERIOD_S)
return count
def _events(client: RealtimeDuplexClient, event_type: str) -> list[dict[str, object]]:
return [event for event in client.events.events if event.get("type") == event_type]
async def _return_function_output_when_ready(
client: RealtimeDuplexClient,
*,
output: str,
timeout_s: float,
) -> tuple[str, int]:
await wait_for(
lambda: bool(client.events.errors()) or client.events.count("response.function_call_arguments.done") > 0,
timeout_s=timeout_s,
label="function call to execute",
)
if client.events.errors():
raise AssertionError(f"function call failed before tool execution: {client.events.errors()}")
function_done = _events(client, "response.function_call_arguments.done")[-1]
call_id = function_done.get("call_id")
if not isinstance(call_id, str) or not call_id:
raise AssertionError(f"completed function call has no call_id: {function_done}")
event_count_before_output = len(client.events.events)
await client.send(
{
"type": "conversation.item.create",
"item": {
"type": "function_call_output",
"call_id": call_id,
"output": output,
},
}
)
return call_id, event_count_before_output
def _write_events(path: Path, client: RealtimeDuplexClient) -> None:
path.write_text(
"".join(json.dumps(event, ensure_ascii=False) + "\n" for event in client.events.events),
encoding="utf-8",
)
@asynccontextmanager
async def _managed_client(client: RealtimeDuplexClient, *, timeout_s: float):
async with client:
try:
yield
finally:
if client.events.count("session.created") and not client.events.count("session.closed"):
with suppress(Exception):
await client.close_session(timeout_s=min(timeout_s, 30.0))
async def run(args: argparse.Namespace) -> dict[str, object]:
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
if args.expect_function_call and args.instructions == DEFAULT_INSTRUCTIONS:
instructions = DEFAULT_FUNCTION_INSTRUCTIONS
else:
instructions = args.instructions
tools = DEFAULT_FUNCTION_TOOLS if args.expect_function_call else None
session_id = f"nemotron-voicechat-{uuid.uuid4().hex}"
client = RealtimeDuplexClient(_url(args.url, args.model, session_id))
async with _managed_client(client, timeout_s=args.timeout_s):
session_payload: dict[str, object] = {
"session_id": session_id,
"model": args.model,
"modalities": ["audio", "text"],
"input_audio_format": "pcm_f32le",
"output_audio_format": "pcm16",
"instructions": instructions,
"idle_timeout_s": args.timeout_s,
"turn_detection": None,
"extra_body": {"auto_response": True},
}
if tools is not None:
session_payload["tools"] = tools
await client.send({"type": "session.update", "session": session_payload})
await wait_for(
lambda: client.events.count("session.created") > 0 or bool(client.events.errors()),
timeout_s=args.timeout_s,
label="session.created",
)
if client.events.errors():
raise AssertionError(f"session setup failed: {client.events.errors()}")
created = _events(client, "session.created")[-1]
session = created.get("session")
capabilities = session.get("capabilities") if isinstance(session, dict) else None
expected = dict(
implementation_level="model_native_duplex",
chunk_period_ms=80,
supports_core_resumable_request=True,
supports_core_kv_lease=False,
supports_multi_session=False,
)
if not isinstance(capabilities, dict) or any(capabilities.get(key) != value for key, value in expected.items()):
raise AssertionError(f"unexpected capabilities: {capabilities}")
function_output_task = (
asyncio.create_task(
_return_function_output_when_ready(
client,
output=args.function_output,
timeout_s=args.timeout_s,
)
)
if args.function_output is not None
else None
)
pcm = _read_wav(Path(args.input_wav), input_channel=args.input_channel)
frame_count = await _stream(client, pcm, max_frames=args.max_frames, realtime=not args.no_realtime)
completed_responses_at_commit = client.events.count("response.done")
await client.send({"type": "input_audio_buffer.commit", "final": True})
await wait_for(
lambda: (
bool(client.events.errors())
or (
client.events.count("response.function_call_arguments.done") > 0
if args.expect_function_call
else (
client.events.count("response.audio.delta") >= args.minimum_audio_chunks
and (client.events.count("response.done") > completed_responses_at_commit)
)
)
),
timeout_s=args.timeout_s,
label="model output",
)
await asyncio.sleep(args.drain_s)
if client.events.errors():
raise AssertionError(f"Realtime session emitted errors: {client.events.errors()}")
done_events = _events(client, "response.done")
if not args.expect_function_call:
response = done_events[-1].get("response") if done_events else None
status = response.get("status") if isinstance(response, dict) else None
if status != "completed":
raise AssertionError(f"response did not complete successfully: {done_events[-1:]}")
function_events = [
event for event in client.events.events if str(event.get("type", "")).startswith("response.function_call")
]
function_items = [
event
for event in _events(client, "response.output_item.done")
if isinstance(event.get("item"), dict) and event["item"].get("type") == "function_call"
]
if args.expect_function_call and not any(
event.get("type") == "response.function_call_arguments.done" for event in function_events
):
raise AssertionError(f"no completed function call: {function_events}")
if args.expect_function_call:
matching_items = [
event for event in function_items if event["item"].get("name") == args.expected_function_name
]
if not matching_items:
raise AssertionError(f"expected {args.expected_function_name!r}, got {function_items}")
function_item = matching_items[-1]["item"]
try:
function_arguments = json.loads(str(function_item.get("arguments", "")))
except json.JSONDecodeError as exc:
raise AssertionError(f"function arguments are not JSON: {function_item}") from exc
if args.expected_function_arguments is not None:
expected_arguments = json.loads(args.expected_function_arguments)
if function_arguments != expected_arguments:
raise AssertionError(
f"function arguments differ: expected={expected_arguments!r}, actual={function_arguments!r}"
)
if args.function_output is not None:
call_id = function_item.get("call_id")
if not isinstance(call_id, str) or not call_id:
raise AssertionError(f"function item has no call_id: {function_item}")
assert function_output_task is not None
returned_call_id, event_count_before_output = await function_output_task
assert returned_call_id == call_id
def tool_result_completed() -> bool:
later = client.events.events[event_count_before_output:]
transcript = "".join(
str(event.get("delta", ""))
for event in later
if event.get("type") == "response.audio_transcript.delta"
).lower()
return (
any(event.get("type") == "response.audio.delta" for event in later)
and any(event.get("type") == "response.done" for event in later)
and (args.expected_post_tool_text is None or args.expected_post_tool_text.lower() in transcript)
)
await wait_for(
lambda: bool(client.events.errors()) or tool_result_completed(),
timeout_s=args.timeout_s,
label="completed response after function output",
)
if client.events.errors():
raise AssertionError(f"function output failed: {client.events.errors()}")
await asyncio.sleep(args.drain_s)
audio = client.events.audio_bytes()
audio_events = _events(client, "response.audio.delta")
rates = {event.get("sample_rate_hz") for event in audio_events}
if not args.expect_function_call and audio and rates != {OUTPUT_SAMPLE_RATE_HZ}:
raise AssertionError(f"unexpected output sample rates: {rates}")
if not args.expect_function_call and args.minimum_audio_chunks and not audio:
raise AssertionError("model produced no audio")
expected_bytes = 2 * OUTPUT_SAMPLE_RATE_HZ * expected["chunk_period_ms"] // 1000
packet_sizes = [len(base64.b64decode(str(event.get("delta", "")), validate=True)) for event in audio_events]
if not args.expect_function_call and any(size != expected_bytes for size in packet_sizes):
raise AssertionError(f"audio deltas are not fixed 80 ms PCM16 packets: {packet_sizes}")
audio_pcm = np.frombuffer(audio, dtype="<i2").astype(np.float32) / 32768.0
audio_rms = float(np.sqrt(np.mean(np.square(audio_pcm)))) if audio_pcm.size else 0.0
if not args.expect_function_call and audio and audio_rms < args.minimum_audio_rms:
raise AssertionError(
f"model output RMS {audio_rms:.6f} is below {args.minimum_audio_rms:.6f}; "
"received packets contain only silence"
)
_write_events(output_dir / "events.jsonl", client)
if audio:
write_pcm16_wav(output_dir / "output.wav", audio, sample_rate_hz=OUTPUT_SAMPLE_RATE_HZ)
result = {
"ok": True,
"session_id": session_id,
"input_frames": frame_count,
"capabilities": capabilities,
"event_counts": {
event_type: client.events.count(event_type)
for event_type in sorted({str(event.get("type")) for event in client.events.events})
},
"audio_bytes": len(audio),
"audio_rms": audio_rms,
"function_events": function_events,
"function_items": function_items,
"output_dir": str(output_dir),
}
(output_dir / "result.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
return result
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="ws://127.0.0.1:8125/v1/realtime")
parser.add_argument("--model", required=True)
parser.add_argument("--input-wav", required=True)
parser.add_argument("--input-channel", type=int, default=0)
parser.add_argument("--output-dir", default="/tmp/nemotron-voicechat-duplex")
parser.add_argument("--instructions", default=DEFAULT_INSTRUCTIONS)
parser.add_argument("--max-frames", type=int)
parser.add_argument("--minimum-audio-chunks", type=int, default=1)
parser.add_argument("--minimum-audio-rms", type=float, default=1e-4)
parser.add_argument("--expect-function-call", action="store_true")
parser.add_argument("--expected-function-name", default="generate_random_number")
parser.add_argument("--expected-function-arguments")
parser.add_argument("--function-output")
parser.add_argument("--expected-post-tool-text")
parser.add_argument("--no-realtime", action="store_true")
parser.add_argument("--drain-s", type=float, default=2.0)
parser.add_argument("--timeout-s", type=float, default=600.0)
return parser.parse_args(argv)
def main() -> None:
print(json.dumps(asyncio.run(run(parse_args())), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -52,9 +52,8 @@ def get_prompt(prompt_type="zh"):
def _set_rocm_request_timeout(request_config: dict) -> None:
if current_omni_platform.is_rocm():
# A cold AITER MHA kernel build took 301 seconds on MI300X.
# Allow 600 seconds for the first request; later requests reuse the cache.
request_config["timeout"] = 600.0
# Cold AITER MHA builds on loaded MI300 agents can exceed 20 minutes.
request_config["timeout"] = 1800.0
tts_server_params = [
@@ -0,0 +1,56 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import asyncio
import os
from pathlib import Path
import pytest
from huggingface_hub import snapshot_download
from tests.e2e.online_serving.nemotron_voicechat_realtime_duplex import parse_args, run
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServerParams, get_model_prefix
from tests.helpers.stage_config import get_deploy_config_path
MODEL = os.environ.get("VLLM_TEST_NEMOTRON_VOICECHAT_MODEL", "nvidia/NVIDIA-NemotronLabs-VoiceChat-11B")
DEPLOY_CONFIG = get_deploy_config_path("nemotron_labs_voicechat_duplex.yaml")
TOKENIZER = os.environ.get("VLLM_TEST_NEMOTRON_VOICECHAT_LLM_PATH")
pytestmark = [pytest.mark.full_model, pytest.mark.omni]
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.parametrize(
"omni_server",
[
pytest.param(
OmniServerParams(
model=MODEL,
stage_config_path=DEPLOY_CONFIG,
env_dict={"NEMOTRON_VOICECHAT_LLM_PATH": TOKENIZER} if TOKENIZER else None,
),
id="native-duplex",
)
],
indirect=True,
)
def test_native_duplex_turn_taking_streams_model_audio(omni_server, tmp_path: Path) -> None:
model_prefix = get_model_prefix()
root = Path(model_prefix) / MODEL if model_prefix else Path(MODEL)
if not root.is_dir():
root = Path(snapshot_download(MODEL, local_files_only=True))
args = parse_args(
(
f"--url ws://{omni_server.host}:{omni_server.port}/v1/realtime --model {omni_server.model} "
f"--input-wav {root / 'turn_taking.wav'} --input-channel 0 --max-frames 190 "
f"--minimum-audio-chunks 48 --minimum-audio-rms 0.001 --no-realtime --timeout-s 300 "
f"--output-dir {tmp_path / 'native_duplex'}"
).split()
)
result = asyncio.run(run(args))
assert result["ok"] is True
assert result["input_frames"] == 190
assert result["event_counts"]["response.speak"] > 0
assert result["audio_bytes"] >= result["input_frames"] * 1764 * 2 // 4
@@ -551,6 +551,54 @@ async def test_native_realtime_protocol_conversation_item_create_commits_user_te
}
@pytest.mark.asyncio
async def test_function_output_is_recorded_only_after_runtime_delivery():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
protocol.encode_outbound_event(
{
"type": "conversation.item.created",
"item": {
"id": "item-call",
"type": "function_call",
"call_id": "call-1",
"name": "weather",
"arguments": "{}",
"status": "completed",
},
}
)
event = {
"type": "conversation.item.create",
"item": {
"id": "item-output",
"type": "function_call_output",
"call_id": "call-1",
"output": '{"temperature":20}',
},
}
first = await protocol._to_duplex_event(event)
retry = await protocol._to_duplex_event(event)
assert first is not None
assert retry is not None
assert "item-output" not in protocol._conversation_items
delivered_item = first["payload"]["item"]
protocol.encode_outbound_event(
{
"type": "conversation.item.created",
"item": delivered_item,
}
)
assert protocol._conversation_items["item-output"] == delivered_item
assert await protocol._to_duplex_event(event) is None
assert ws.sent[-1]["error"]["code"] == "invalid_function_call_output"
@pytest.mark.asyncio
async def test_native_realtime_protocol_audio_commit_requires_non_empty_buffer():
ws = TimedWebSocket()
@@ -5481,7 +5529,7 @@ async def test_failed_native_append_prevents_queued_append_from_running():
@pytest.mark.asyncio
async def test_failed_final_append_keeps_committed_audio_for_response_retry():
async def test_failed_final_append_discards_retained_audio_without_retry():
failed_result = {
"operation": "append",
"session_id": "sid-final-append-retry",
@@ -5490,15 +5538,7 @@ async def test_failed_final_append_keeps_committed_audio_for_response_retry():
"error_count": 1,
"stage_results": [],
}
successful_result = {
"operation": "append",
"session_id": "sid-final-append-retry",
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [],
}
engine = FakeEngineClient(append_results=[failed_result, successful_result])
engine = FakeEngineClient(append_result=failed_result)
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
@@ -5523,15 +5563,18 @@ async def test_failed_final_append_keeps_committed_audio_for_response_retry():
handler_task = asyncio.create_task(handler.handle_session(ws))
for _ in range(100):
if len(engine.appended) >= 2:
state = handler._serving_runtime_adapter.session_states.get("sid-final-append-retry")
if len(engine.appended) >= 1 and state is not None and state.committed_audio_payload is None:
break
await asyncio.sleep(0.01)
state = handler._serving_runtime_adapter.session_states["sid-final-append-retry"]
assert state.committed_audio_payload is None
assert state.committed_audio_reserved_bytes == 0
assert handler._registry.get("sid-final-append-retry").pending_input_bytes == 0
ws.put({"type": "session.close"})
await asyncio.wait_for(handler_task, timeout=2)
assert len(engine.appended) == 2
assert engine.appended[0][2]["audio"] == engine.appended[1][2]["audio"]
assert engine.append_operation_ids[0] == engine.append_operation_ids[1]
assert len(engine.appended) == 1
@pytest.mark.asyncio
@@ -225,3 +225,39 @@ def test_torchaudio_mel_filterbank_matches_librosa() -> None:
reference = librosa.filters.mel(sr=16000, n_fft=512, n_mels=128, fmin=0.0, fmax=8000.0, norm="slaney")
assert ta.shape == reference.shape
assert abs(ta - reference).max() < 1e-6
def test_code2wav_reuses_and_clears_per_request_streaming_cache() -> None:
import torch
from torch import nn
from vllm_omni.model_executor.models.nemotron_voicechat.nemotron_voicechat_code2wav import (
NemotronVoiceChatCode2Wav,
)
class Codec(nn.Module):
def __init__(self) -> None:
super().__init__()
self.anchor = nn.Parameter(torch.zeros(()))
self.caches = []
def decode(self, codes, lengths, *, cache):
self.caches.append(cache)
wav = torch.zeros((1, int(codes.shape[1]) * 1764), device=codes.device)
return wav, lengths * 1764
model = NemotronVoiceChatCode2Wav.__new__(NemotronVoiceChatCode2Wav)
nn.Module.__init__(model)
model._sample_rate, model._num_quantizers, model._codebook_size, model._wav_per_frame = 22050, 31, 1024, 1764
model._duplex_codec_caches = {}
model.audio_codec = Codec()
def info(codes):
return [{"meta": {"codec_streaming": True, "request_id": "req"}, "codes": {"audio": codes}}]
model.forward(runtime_additional_information=info(torch.zeros((1, 31), dtype=torch.long)))
model.forward(runtime_additional_information=info(torch.zeros((2, 31), dtype=torch.long)))
assert model.audio_codec.caches[0] is model.audio_codec.caches[1]
assert "req" in model._duplex_codec_caches
model.on_requests_finished({"req"})
assert "req" not in model._duplex_codec_caches
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import SimpleNamespace
@@ -285,9 +285,46 @@ def test_talker2code2wav_async_chunk_requires_prompt_len() -> None:
talker2code2wav_async_chunk(tm, {"codes": {"audio": torch.zeros(5, 31, dtype=torch.long)}}, req)
# =========================
# Talker drain-per-wake (async scheduling review P1/P2)
# =========================
def test_talker2code2wav_duplex_keeps_cumulative_history_across_wakes() -> None:
from vllm_omni.model_executor.stage_input_processors.nemotron_voicechat import (
talker2code2wav_async_chunk,
)
tm = _fake_transfer_manager(codec_chunk_frames=2)
prompt_len = 3
req = _streaming_request(
prompt_len=prompt_len,
generated=[],
info={"meta": {"codec_streaming": torch.tensor([True])}, "nvc_logical_prompt_len": prompt_len},
)
prompt_rows = torch.zeros((prompt_len - 1, 31), dtype=torch.long)
first = torch.full((1, 31), 101, dtype=torch.long)
second = torch.full((1, 31), 202, dtype=torch.long)
for rows, finished in ((prompt_rows, False), (torch.cat([prompt_rows, first]), False)):
payload = talker2code2wav_async_chunk(
tm,
{"codes": {"audio": rows}, "meta": {"codec_streaming": torch.tensor([True])}},
req,
is_finished=finished,
)
assert payload is not None and not bool(payload.meta.finished)
payload = talker2code2wav_async_chunk(
tm,
{
"codes": {"audio": torch.cat([prompt_rows, first, second])},
"meta": {"codec_streaming": torch.tensor([True])},
},
req,
is_finished=True,
)
assert payload is not None
assert not bool(payload.meta.finished)
assert not bool(payload.meta.is_segment_finished)
assert torch.equal(payload.codes.audio, torch.cat([first, second]))
assert tm.request_payload["req-0"]["nvc_frames_seen"] == 2
class _FakeTTSModelOutput:
@@ -342,7 +379,14 @@ def _bare_talker():
return talker
def _async_info(timeline: list[int], prompt_len: int, finished: bool, request_id: str = "req-0") -> dict:
def _async_info(
timeline: list[int],
prompt_len: int,
finished: bool,
request_id: str = "req-0",
*,
codec_streaming: bool = False,
) -> dict:
return {
"request_id": request_id,
"additional_information": {
@@ -351,6 +395,7 @@ def _async_info(timeline: list[int], prompt_len: int, finished: bool, request_id
"num_processed_tokens": prompt_len,
"finished": torch.tensor(finished),
"request_id": request_id,
"codec_streaming": codec_streaming,
},
},
}
@@ -373,7 +418,7 @@ def test_talker_drains_all_received_positions_per_wake() -> None:
_, embeds, update = talker.preprocess(ids, None, _omni_is_prefill=False, **info)
assert session["step"] == prompt_len # t advanced 1 -> P in one wake
assert update["codes"]["audio"].shape == (prompt_len - 1, 31)
assert float(embeds[0, 0]) == 0.0 # not finished
assert float(embeds[0, 0]) == 0.0 # offline stream waits for upstream terminal
# Coalesced chunk: TWO new frame tokens arrive in ONE wake (delayed save
# thread merged two thinker steps). Both must be consumed by this wake.
@@ -383,14 +428,13 @@ def test_talker_drains_all_received_positions_per_wake() -> None:
assert update["codes"]["audio"].shape == (prompt_len + 1, 31)
assert float(embeds[0, 0]) == 0.0
# Zero-progress wake (no new positions, not finished): plain CONTINUE, no
# fabricated frames, no codes update.
# Zero-progress wake: never fabricate frames or end the offline stream
# before the thinker sends its terminal marker.
_, embeds, update = talker.preprocess(ids, None, _omni_is_prefill=False, **info2)
assert session["step"] == prompt_len + 2
assert update == {}
assert float(embeds[0, 0]) == 0.0
# Final chunk: one more token + finished marker -> drain + stop flag.
info3 = _async_info([pad] * prompt_len + [7, 8, 9], prompt_len, finished=True)
_, embeds, update = talker.preprocess(ids, None, _omni_is_prefill=False, **info3)
assert session["step"] == prompt_len + 3
@@ -398,6 +442,18 @@ def test_talker_drains_all_received_positions_per_wake() -> None:
assert float(embeds[0, 0]) == 1.0 # stop
def test_talker_native_codec_stream_ends_each_received_segment() -> None:
talker = _bare_talker()
ids = torch.zeros(1, dtype=torch.long)
info = _async_info([12, 12, 7], 2, finished=False, codec_streaming=True)
talker.preprocess(ids, None, _omni_is_prefill=True, **info)
_, embeds, update = talker.preprocess(ids, None, _omni_is_prefill=False, **info)
assert update["codes"]["audio"].shape == (2, 31)
assert float(embeds[0, 0]) == 1.0
def test_talker_sync_mode_steps_once_per_wake() -> None:
"""The verified sync parity path keeps its one-TTS-step-per-engine-step cadence."""
talker = _bare_talker()
+1
View File
@@ -1832,6 +1832,7 @@ class TestPreferModelSamplerNoneFallback:
"hunyuan_image3",
"minicpmo_4_5",
"minimax_music3",
"nemotron_voicechat",
}
assert declarers == expected, (
"The set of models declaring `prefer_model_sampler` changed:\n"
+5 -2
View File
@@ -561,8 +561,10 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
if self.chunk_transfer_adapter:
if self.vllm_config.model_config.stage_id != 0:
# Downstream async-chunk stages receive real payloads from the
# connector. This update only resumes polling for the next segment.
self.chunk_transfer_adapter.segment_finished_requests.discard(request.request_id)
# connector, not an external StreamingUpdate. Move the
# resumable request back to the ordinary waiting queue so
# connector polling starts again immediately.
self._resume_downstream_chunk_receiver(request)
outstanding_async_tokens = request.num_output_placeholders
# Always record the discard signal (0 when nothing is in
# flight). Upstream a0c092ee72 removed the
@@ -641,6 +643,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
inter_stage_output,
request,
is_segment_finished,
new_token_ids=new_token_ids,
confirmed_num_computed_tokens=confirmed_num_computed_tokens,
)
+31 -2
View File
@@ -564,8 +564,37 @@ class OmniSchedulerMixin:
if stopped_running_reqs:
self.running = remove_all(self.running, stopped_running_reqs)
if stopped_preempted_reqs:
self.waiting.remove_requests(stopped_preempted_reqs)
self.skipped_waiting.remove_requests(stopped_preempted_reqs)
# ``_handle_stopped_request`` re-enqueues a resumable segment as
# WAITING_FOR_STREAMING_REQ before this cleanup runs. A
# downstream async-chunk request can have entered the update with
# the older WAITING_FOR_CHUNK status, which puts it in
# ``stopped_preempted_reqs``; deleting it here would immediately
# undo the requeue and the receiver would never poll chunk 1.
removable = {
request
for request in stopped_preempted_reqs
if not (
request.resumable
and request.status
in (
RequestStatus.WAITING,
RequestStatus.WAITING_FOR_STREAMING_REQ,
)
)
}
self.waiting.remove_requests(removable)
self.skipped_waiting.remove_requests(removable)
def _resume_downstream_chunk_receiver(self, request: Request) -> None:
"""Resume connector polling without waiting for an external update."""
adapter = self.chunk_transfer_adapter
adapter.segment_finished_requests.discard(request.request_id)
if request.status != RequestStatus.WAITING_FOR_STREAMING_REQ:
return
self.num_waiting_for_streaming_input -= 1
request.status = RequestStatus.WAITING
self.skipped_waiting.remove_requests((request,))
self._enqueue_waiting_request(request)
def _aggregate_kv_connector_stats(
self,
@@ -0,0 +1,47 @@
# Nemotron VoiceChat native Full-Duplex deployment.
#
# One scheduler segment consumes one 1280-sample (80 ms) microphone frame.
# Stage 0 remains one resumable request, so its NemotronH KV/Mamba state is
# preserved between frame appends. Stage 1 and the codec overlap per frame.
base_config: nemotron_labs_voicechat_streaming.yaml
pipeline: nemotron_voicechat
session_mode: duplex
active_stream_window: 1
duplex_session:
idle_ttl_s: 300
disconnect_grace_s: 30
reaper_interval_s: 5
resume_replay_ttl_s: 60
resume_replay_max_bytes_per_session: 8388608
max_pending_input_bytes_per_session: 16777216
max_pending_turns_per_session: 4
max_sessions: 1
completed_append_cache_size: 256
async_chunk: true
connectors:
connector_of_shared_memory:
name: SharedMemoryConnector
extra:
shm_threshold_bytes: 65536
codec_chunk_frames: 1
stages:
- stage_id: 0
max_num_seqs: 1
async_scheduling: false
default_sampling_params:
temperature: 0.0
max_tokens: 1
ignore_eos: true
seed: 0
- stage_id: 1
max_num_seqs: 1
async_scheduling: false
- stage_id: 2
max_num_seqs: 1
async_scheduling: false
@@ -3,9 +3,11 @@
import copy
import importlib
import inspect
import threading
import time
from collections import defaultdict, deque
from collections.abc import Callable, Mapping
from collections.abc import Callable, Iterable, Mapping
from typing import Any
import torch
@@ -24,6 +26,14 @@ from .base import OmniTransferAdapterBase
logger = get_connector_logger(__name__)
class _SenderGeneration:
"""Fence one external request generation without blocking cleanup."""
def __init__(self) -> None:
self.in_flight = False
self.cancelled = False
class OmniChunkTransferAdapter(OmniTransferAdapterBase):
"""Chunk-level transfer adapter for Omni connector pipelines.
@@ -44,6 +54,12 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
def __init__(self, vllm_config: Any):
model_config = vllm_config.model_config
# The base constructor starts the save thread, so sender-generation
# state must exist before it can observe an enqueued task. The global
# lock only protects this map and short state transitions. Slow payload
# construction and connector writes never hold it.
self._sender_state_lock = threading.Lock()
self._sender_tokens: dict[str, _SenderGeneration] = {}
self.scheduler_max_num_seqs = vllm_config.scheduler_config.max_num_seqs
active_stream_window = int(getattr(model_config, "active_stream_window", 0) or 0)
model_max_num_seqs = int(getattr(model_config, "max_num_seqs", self.scheduler_max_num_seqs) or 0)
@@ -70,6 +86,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
module_path, func_name = custom_process_next_stage_input_func.rsplit(".", 1)
module = importlib.import_module(module_path)
self.custom_process_next_stage_input_func = getattr(module, func_name)
self._processor_accepts_step_tokens: dict[Callable[..., Any], bool] = {}
# mapping for request id and chunk id
self.put_req_chunk: dict[str, int] = defaultdict(int)
self.get_req_chunk: dict[str, int] = defaultdict(int)
@@ -198,11 +215,28 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
with self._recv_cond:
self._recv_cond.notify()
def _accepts_new_token_ids(self, processor: Callable[..., Any]) -> bool:
cached = self._processor_accepts_step_tokens.get(processor)
if cached is not None:
return cached
try:
parameters = inspect.signature(processor).parameters.values()
except (TypeError, ValueError):
accepts = False
else:
accepts = any(
parameter.name == "new_token_ids" or parameter.kind is inspect.Parameter.VAR_KEYWORD
for parameter in parameters
)
self._processor_accepts_step_tokens[processor] = accepts
return accepts
def save_async(
self,
multimodal_output: dict[str, Any] | None = None,
request: Request | None = None,
is_segment_finished: bool = False,
new_token_ids: Iterable[int] | None = None,
confirmed_num_computed_tokens: int | None = None,
):
"""Build and enqueue one chunk for asynchronous sending.
@@ -221,38 +255,56 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
multimodal_output: Per-request multimodal output dictionary
request: Request object
is_segment_finished: whether the segment of request is finished
new_token_ids: immutable snapshot of tokens generated by this
scheduler step, before resumable cleanup mutates the request
confirmed_num_computed_tokens: committed token count captured
before a streaming transition can mutate ``request``
"""
is_finished = request.is_finished() and not request.resumable
external_req_id = request.external_req_id
if confirmed_num_computed_tokens is None:
confirmed_num_computed_tokens = self._confirmed_num_computed_tokens(request)
# If the request is preempted, skip the already saved chunks.
if confirmed_num_computed_tokens < self.requests_num_chunks_sent.get(request.external_req_id, 0):
logger.warning(
f"Enqueue save_async for request {request.external_req_id}, "
f"request.num_computed_tokens={request.num_computed_tokens}, "
f"request.num_output_placeholders={getattr(request, 'num_output_placeholders', 0)}, "
f"previous_chunks_sent={self.requests_num_chunks_sent.get(request.external_req_id, 0)}"
)
return
self.requests_num_chunks_sent[request.external_req_id] = confirmed_num_computed_tokens
processor_request = (
self._snapshot_processor_request(request) if self.custom_process_next_stage_input_func else request
)
task = {
"multimodal_output": multimodal_output,
"request": (
self._snapshot_processor_request(request) if self.custom_process_next_stage_input_func else request
),
"request": processor_request,
"is_finished": is_finished,
"is_segment_finished": is_segment_finished,
"new_token_ids": tuple(int(token_id) for token_id in (new_token_ids or ())),
}
self._pending_save_reqs.append(task)
if is_segment_finished:
# The queued FIFO item now owns the old segment. Start the next
# segment's deduplication watermark before the worker sends it.
self.requests_num_chunks_sent.pop(request.external_req_id, None)
reject_reason = None
with self._sender_state_lock:
sender_token = self._sender_tokens.get(external_req_id)
if sender_token is not None and sender_token.cancelled:
reject_reason = "previous sender generation is still draining"
# If the request is preempted, skip the already saved chunks.
elif confirmed_num_computed_tokens < self.requests_num_chunks_sent.get(external_req_id, 0):
logger.warning(
f"Enqueue save_async for request {external_req_id}, "
f"request.num_computed_tokens={request.num_computed_tokens}, "
f"request.num_output_placeholders={getattr(request, 'num_output_placeholders', 0)}, "
f"previous_chunks_sent={self.requests_num_chunks_sent.get(external_req_id, 0)}"
)
return
else:
self.requests_num_chunks_sent[external_req_id] = confirmed_num_computed_tokens
if sender_token is None:
sender_token = _SenderGeneration()
self._sender_tokens[external_req_id] = sender_token
task["sender_token"] = sender_token
self._pending_save_reqs.append(task)
if is_segment_finished:
# The queued FIFO item now owns the old segment. Start the next
# segment's deduplication watermark before the worker sends it.
self.requests_num_chunks_sent.pop(external_req_id, None)
if reject_reason is not None:
logger.error("Cannot enqueue %s: %s", external_req_id, reject_reason)
self.record_send_failure(request.request_id, reject_reason)
return
with self._save_cond:
self._save_cond.notify()
@@ -378,6 +430,44 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
return False
def _send_single_request(self, task: dict):
request = task["request"]
external_req_id = request.external_req_id
sender_token = task.get("sender_token")
if sender_token is None:
self._send_single_request_for_generation(task)
return
with self._sender_state_lock:
is_current = self._sender_tokens.get(external_req_id) is sender_token
if is_current and not sender_token.cancelled:
sender_token.in_flight = True
else:
is_current = False
if not is_current:
logger.debug("Discarding stale queued chunk for aborted request %s", external_req_id)
return
try:
self._send_single_request_for_generation(task, sender_token)
finally:
with self._sender_state_lock:
if self._sender_tokens.get(external_req_id) is sender_token:
sender_token.in_flight = False
if sender_token.cancelled:
self._sender_tokens.pop(external_req_id, None)
self._clear_sender_state_locked(external_req_id)
def _sender_generation_is_active(
self,
external_req_id: str,
sender_token: _SenderGeneration,
) -> bool:
with self._sender_state_lock:
return self._sender_tokens.get(external_req_id) is sender_token and not sender_token.cancelled
def _send_single_request_for_generation(
self,
task: dict,
sender_token: _SenderGeneration | None = None,
):
raw_mm = task["multimodal_output"]
multimodal_output = unflatten_payload(raw_mm) if isinstance(raw_mm, Mapping) else raw_mm
request = task["request"]
@@ -392,10 +482,11 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
payload_data: OmniPayloadStruct | None = None
if self.custom_process_next_stage_input_func:
try:
payload_data = self.custom_process_next_stage_input_func(
transfer_manager=self,
multimodal_output=multimodal_output,
request=request,
processor = self.custom_process_next_stage_input_func
processor_kwargs = {
"transfer_manager": self,
"multimodal_output": multimodal_output,
"request": request,
# Existing processors use is_finished as a flush signal.
# Terminal stops no longer count as segment boundaries
# (is_segment_finished is False when the request finishes,
@@ -403,8 +494,11 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
# accumulated tail on the terminal chunk — otherwise the
# downstream stage receives the finished marker without
# the final payload (#5413).
is_finished=is_segment_finished or is_finished,
)
"is_finished": is_segment_finished or is_finished,
}
if self._accepts_new_token_ids(processor):
processor_kwargs["new_token_ids"] = task.get("new_token_ids", ())
payload_data = processor(**processor_kwargs)
except Exception as e:
logger.error(f"Failed to use custom_process_input_func for payload extraction: {e}")
@@ -417,10 +511,23 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
payload_data = OmniPayloadStruct()
if payload_data.meta is None:
payload_data.meta = MetaStruct()
payload_data.meta.finished = torch.tensor(is_finished, dtype=torch.bool)
# A model-native codec stream may span many resumable scheduler
# requests. Its processor is the only layer that knows whether a
# request finish is a transport wake or the lifetime of the codec
# state itself. Preserve an explicit streaming lifetime decision;
# all ordinary producers keep the scheduler-derived default.
processor_controls_codec_lifetime = payload_data.meta.finished is not None and self._is_truthy_scalar(
payload_data.meta.codec_streaming
)
if not processor_controls_codec_lifetime:
payload_data.meta.finished = torch.tensor(is_finished, dtype=torch.bool)
if payload_data.meta.is_segment_finished is None:
payload_data.meta.is_segment_finished = torch.tensor(is_segment_finished, dtype=torch.bool)
if sender_token is not None and not self._sender_generation_is_active(external_req_id, sender_token):
logger.debug("Skipping cancelled chunk for request %s before connector put", external_req_id)
return
success, size, metadata = self.connector.put(
from_stage=str(stage_id),
to_stage=str(next_stage_id),
@@ -428,6 +535,13 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
data=payload_data,
)
if sender_token is not None and not self._sender_generation_is_active(external_req_id, sender_token):
# cleanup_sender() may cancel this generation while put() is
# blocked. Do not let that stale completion recreate state for a
# request whose cleanup is already in progress.
logger.debug("Ignoring completed put for cancelled request %s", external_req_id)
return
if success:
self.put_req_chunk[external_req_id] += 1
self.ramp_chunk_count[external_req_id] += 1
@@ -535,11 +649,29 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
def cleanup_sender(self, external_req_id: str) -> None:
"""Reclaim sender-side per-request state (keyed by external id).
Must only be called after the terminal chunk has actually been
sent (i.e. from ``_send_single_request``), not before.
Called after a terminal chunk is sent or when the scheduler aborts the
request before a terminal chunk can be produced. In-flight sends are
cancelled here and reclaim their own state in ``finally``; cleanup
never waits for connector I/O on the scheduler thread.
Idempotent: calling with an already-cleaned or unknown id is safe.
"""
with self._sender_state_lock:
sender_token = self._sender_tokens.get(external_req_id)
if sender_token is None:
self._clear_sender_state_locked(external_req_id)
return
sender_token.cancelled = True
if sender_token.in_flight:
# The sender's finally block is the first point where no
# in-flight code can mutate this request's state. Let it own
# cleanup without making the scheduler wait for connector I/O.
return
self._sender_tokens.pop(external_req_id, None)
self._clear_sender_state_locked(external_req_id)
def _clear_sender_state_locked(self, external_req_id: str) -> None:
"""Clear sender state while ``_sender_state_lock`` is held."""
self.put_req_chunk.pop(external_req_id, None)
self.request_payload.pop(external_req_id, None)
self.code_prompt_token_ids.pop(external_req_id, None)
@@ -959,6 +1091,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
}
# First pass: collect requests to remove from queues
request_ids = set(request_ids)
for req_id in request_ids:
request = requests.get(req_id) if requests else None
if request is None:
@@ -974,7 +1107,18 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
if req_id in self.requests_origin_status and req_id in connector_owned_ids:
request.status = self.requests_origin_status.pop(req_id)
request_ids = set(request_ids)
# An abort can terminate a long-lived native codec stream before it
# emits a terminal payload. Reclaim both sides of the adapter so a
# later request reusing the same external id starts with empty state.
for req_id in request_ids:
request = requests.get(req_id) if requests else None
external_req_id = (
getattr(request, "external_req_id", None)
if request is not None
else self.request_ids_mapping.get(req_id)
) or req_id
self.cleanup_receiver(req_id)
self.cleanup_sender(external_req_id)
self.waiting_for_chunk_waiting_requests = deque(
request for request in self.waiting_for_chunk_waiting_requests if request.request_id not in request_ids
@@ -0,0 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Native Full-Duplex integration for NVIDIA NemotronLabs VoiceChat."""
@@ -0,0 +1,403 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Output projection for Nemotron VoiceChat's independent channels."""
from __future__ import annotations
import json
from collections.abc import Callable, Iterator, Mapping
from dataclasses import dataclass, field
from typing import Any
from uuid import uuid4
import numpy as np
import torch
from vllm.logger import init_logger
from vllm_omni.experimental.fullduplex.engine.contracts import (
duplex_resource_request_belongs_to_session,
)
from vllm_omni.experimental.fullduplex.output import (
get_duplex_output_decision,
)
logger = init_logger(__name__)
EncodeAudio = Callable[[object, int, str, float | None], str | None]
@dataclass(frozen=True, slots=True)
class NemotronVoiceChatDataPlaneContext:
epoch: int = 0
turn_id: int = 0
auto_responds: bool = True
response_format: str = "wav"
speed: float | None = None
modalities: tuple[str, ...] = ("text", "audio")
@dataclass(slots=True)
class _RequestState:
text_frames: int = 0
audio_frames: int = 0
pending_speech_end_frames: list[int] = field(default_factory=list)
function_active: bool = False
function_tokens: list[int] = field(default_factory=list)
function_call_id: str | None = None
terminal: bool = False
def _coerce_ints(value: object) -> list[int]:
if value is None:
return []
if isinstance(value, torch.Tensor):
value = value.detach().cpu().reshape(-1).tolist()
elif isinstance(value, np.ndarray):
value = value.reshape(-1).tolist()
if isinstance(value, int):
return [value]
if not isinstance(value, list | tuple):
return []
values: list[int] = []
for item in value:
if isinstance(item, torch.Tensor):
if item.numel() != 1:
continue
item = item.item()
elif isinstance(item, np.generic):
item = item.item()
try:
values.append(int(item))
except (TypeError, ValueError):
continue
return values
def _unwrap(output: object) -> tuple[object, object | None, int | None]:
stage_id = getattr(output, "stage_id", None)
inner = getattr(output, "request_output", None)
if inner is not None and inner is not output:
output = inner
outputs = getattr(output, "outputs", None)
completion = outputs[0] if isinstance(outputs, list) and outputs else None
if stage_id is None:
stage_id = getattr(output, "stage_id", None)
return output, completion, int(stage_id) if isinstance(stage_id, int) else None
def _multimodal(output: object, completion: object | None) -> dict[str, object]:
decision = get_duplex_output_decision(output)
metadata = getattr(decision, "metadata", None)
if isinstance(metadata, Mapping):
return dict(metadata)
for candidate in (
getattr(output, "multimodal_output", None),
getattr(completion, "multimodal_output", None) if completion is not None else None,
):
if isinstance(candidate, Mapping):
return dict(candidate)
return {}
def _audio_value(metadata: Mapping[str, object]) -> object | None:
value = next((metadata[key] for key in ("audio", "model_outputs", "latent") if key in metadata), None)
if isinstance(value, list) and len(value) == 1:
return value[0]
return value
def _sample_rate(metadata: Mapping[str, object]) -> int:
value = metadata.get("sr", metadata.get("sample_rate_hz", 22050))
if isinstance(value, list) and value:
value = value[0]
if hasattr(value, "item"):
value = value.item()
return int(value) if isinstance(value, int | float) else 22050
def _audio_samples(audio: object | None) -> int:
if audio is None:
return 0
if isinstance(audio, torch.Tensor):
return int(audio.numel())
try:
return int(np.asarray(audio, dtype=np.float32).size)
except (TypeError, ValueError):
return 0
def _speech_end_event(request_id: str) -> dict[str, object]:
return {
"stage_role": "tts",
"is_listen": False,
"data_plane_request_id": request_id,
"text": "",
"end_of_turn": True,
}
class NemotronVoiceChatDataPlaneSession:
"""Join frame-locked text/function outputs with Stage-2 audio."""
def __init__(self, encode_audio: EncodeAudio) -> None:
self._encode_audio = encode_audio
self._requests: dict[str, _RequestState] = {}
self._tokenizer = None
self._tokenizer_ref: str | None = None
self._special_ids: dict[str, int] | None = None
def configure_runtime(self, runtime_config: Mapping[str, object], *, tokenizer: Any | None = None) -> None:
"""Install the serving-resolved tokenizer contract as the sole truth."""
try:
special_ids = {
"bos": int(runtime_config["nvc_text_bos_id"]),
"eos": int(runtime_config["nvc_text_eos_id"]),
"pad": int(runtime_config["nvc_text_pad_id"]),
"sotc": int(runtime_config["nvc_function_sotc_id"]),
"eotc": int(runtime_config["nvc_function_eotc_id"]),
}
except (KeyError, TypeError, ValueError) as exc:
raise ValueError("Nemotron VoiceChat data plane requires resolved special-token ids") from exc
tokenizer_ref = runtime_config.get("nvc_tokenizer_ref")
if not isinstance(tokenizer_ref, str) or not tokenizer_ref:
raise ValueError("Nemotron VoiceChat data plane requires nvc_tokenizer_ref")
self._special_ids = special_ids
self._tokenizer_ref = tokenizer_ref
self._tokenizer = tokenizer
def begin_request(self, request_id: str) -> None:
self._requests.setdefault(request_id, _RequestState()).terminal = False
def is_terminal(self, request_id: str | None) -> bool:
return bool(request_id and self._requests.get(request_id) and self._requests[request_id].terminal)
def mark_terminal(self, request_id: str) -> None:
self._requests.setdefault(request_id, _RequestState()).terminal = True
def close_stream(self, request_id: str) -> None:
self._requests.pop(request_id, None)
def close_session(self, session_id: str, *, active_request_id: str | None = None) -> None:
if active_request_id:
self._requests.pop(active_request_id, None)
for request_id in tuple(self._requests):
if duplex_resource_request_belongs_to_session(request_id, session_id):
self._requests.pop(request_id, None)
def _load_tokenizer(self):
if self._tokenizer is None:
from transformers import AutoTokenizer
if self._tokenizer_ref is None:
raise RuntimeError("Nemotron VoiceChat data plane was used before runtime configuration")
self._tokenizer = AutoTokenizer.from_pretrained(self._tokenizer_ref, trust_remote_code=False)
return self._tokenizer
def _ids(self) -> dict[str, int]:
if self._special_ids is None:
raise RuntimeError("Nemotron VoiceChat data plane was used before runtime configuration")
return self._special_ids
def _decode(self, token_ids: list[int]) -> str:
if not token_ids:
return ""
return str(self._load_tokenizer().decode(token_ids, skip_special_tokens=True))
def project(
self,
result: object,
*,
context: NemotronVoiceChatDataPlaneContext | None = None,
) -> Iterator[dict[str, object]]:
if not isinstance(result, dict):
return
outputs = result.get("data_plane_outputs")
if not isinstance(outputs, list):
return
for output in outputs:
yield from self.project_output(output, context=context)
def project_output(
self,
output: object,
*,
context: NemotronVoiceChatDataPlaneContext | None = None,
) -> Iterator[dict[str, object]]:
context = context or NemotronVoiceChatDataPlaneContext()
outer = output
output, completion, stage_id = _unwrap(output)
request_id = getattr(output, "request_id", None) or getattr(outer, "request_id", None)
request_id = str(request_id) if request_id is not None else ""
state = self._requests.setdefault(request_id, _RequestState())
metadata = _multimodal(outer, completion)
modalities = {modality.lower() for modality in context.modalities}
wants_text = "text" in modalities
wants_audio = "audio" in modalities
if stage_id == 0 or "nvc_text_token_ids" in metadata:
ids = self._ids()
text_ids = _coerce_ints(metadata.get("nvc_text_token_ids"))
if not text_ids and completion is not None:
text_ids = _coerce_ints(
getattr(completion, "token_ids", None) or getattr(completion, "cumulative_token_ids", None)
)
for token_id in text_ids[-1:]:
state.text_frames += 1
if token_id == ids["eos"]:
if state.audio_frames >= state.text_frames:
yield _speech_end_event(request_id)
else:
state.pending_speech_end_frames.append(state.text_frames)
elif token_id == ids["pad"]:
yield {
"stage_role": "llm",
"is_listen": True,
"model_listen": True,
"listen_source": "model_listen",
"data_plane_request_id": request_id,
"end_of_turn": False,
}
elif token_id != ids["bos"] and wants_text:
text = self._decode([token_id])
if text:
yield {
"stage_role": "llm",
"is_listen": False,
"data_plane_request_id": request_id,
"text": text,
"end_of_turn": False,
}
for function_id in _coerce_ints(metadata.get("nvc_function_token"))[-1:]:
yield from self._project_function_token(
function_id,
state=state,
request_id=request_id,
ids=ids,
)
return
audio = _audio_value(metadata)
sample_rate = _sample_rate(metadata)
sample_count = _audio_samples(audio)
encoded = (
self._encode_audio(audio, sample_rate, context.response_format, context.speed)
if wants_audio and audio is not None
else None
)
audio_chunk_complete = sample_count > 0
if audio_chunk_complete:
# A native-duplex Stage-2 segment can contain one or more 80 ms
# codec frames. Count them against the frame-locked Stage-0 text
# channel. The long-lived codec request deliberately keeps
# ``finished=False`` across scheduler wakes; a non-empty decoded
# chunk, not request lifetime, is the audio-coverage signal.
duration_ms = sample_count * 1000 / max(1, sample_rate)
state.audio_frames += max(1, round(duration_ms / 80))
end_of_turn = bool(state.pending_speech_end_frames) and state.audio_frames >= state.pending_speech_end_frames[0]
if encoded:
yield {
"stage_role": "tts",
"is_listen": False,
"data_plane_request_id": request_id,
"text": "",
"audio_data": encoded,
"audio_format": context.response_format,
"sample_rate_hz": sample_rate,
"audio_duration_ms": round(sample_count * 1000 / max(1, sample_rate)),
"audio_text_mark": True,
"end_of_turn": end_of_turn,
}
elif end_of_turn:
yield _speech_end_event(request_id)
if end_of_turn:
state.pending_speech_end_frames.pop(0)
def _project_function_token(
self,
token_id: int,
*,
state: _RequestState,
request_id: str,
ids: Mapping[str, int],
) -> Iterator[dict[str, object]]:
if token_id == ids["sotc"]:
state.function_active = True
state.function_tokens.clear()
state.function_call_id = f"call_{uuid4().hex}"
return
if token_id == ids["eotc"] and state.function_active:
state.function_active = False
raw = self._decode(state.function_tokens)
state.function_tokens.clear()
try:
calls = self._parse_calls(raw)
except ValueError as exc:
state.function_call_id = None
yield {
"stage_role": "function",
"data_plane_request_id": request_id,
"error_code": "nemotron_function_call_parse_error",
"error": str(exc),
}
return
for index, call in enumerate(calls):
yield {
"stage_role": "function",
"data_plane_request_id": request_id,
"function_call": True,
"call_id": (
state.function_call_id if index == 0 and state.function_call_id else f"call_{uuid4().hex}"
),
"name": str(call["name"]),
"arguments": (
call.get("arguments")
if isinstance(call.get("arguments"), str)
else json.dumps(call.get("arguments", {}), separators=(",", ":"))
),
}
state.function_call_id = None
return
if state.function_active and token_id != ids["pad"]:
state.function_tokens.append(token_id)
@staticmethod
def _parse_calls(value: str) -> list[dict[str, object]]:
text = value.strip()
if "<TOOLCALL>" in text:
text = text.split("<TOOLCALL>", 1)[1]
if "</TOOLCALL>" in text:
text = text.split("</TOOLCALL>", 1)[0]
try:
parsed = json.loads(text)
except json.JSONDecodeError as exc:
raise ValueError("Nemotron VoiceChat produced malformed function-call JSON") from exc
if isinstance(parsed, dict):
parsed = [parsed]
if not isinstance(parsed, list) or not parsed:
raise ValueError("Nemotron VoiceChat function-call payload must be a non-empty object or list")
calls: list[dict[str, object]] = []
for item in parsed:
if not isinstance(item, dict):
raise ValueError("Nemotron VoiceChat function-call entries must be objects")
name = item.get("name")
if not isinstance(name, str) or not name.strip():
raise ValueError("Nemotron VoiceChat function-call entries require a non-empty name")
arguments = item.get("arguments", {})
if isinstance(arguments, str):
try:
decoded_arguments = json.loads(arguments)
except json.JSONDecodeError as exc:
raise ValueError("Nemotron VoiceChat function-call arguments contain malformed JSON") from exc
if not isinstance(decoded_arguments, dict):
raise ValueError("Nemotron VoiceChat function-call arguments must encode a JSON object")
elif not isinstance(arguments, dict):
raise ValueError("Nemotron VoiceChat function-call arguments must be a JSON object")
calls.append({"name": name.strip(), "arguments": arguments})
return calls
__all__ = [
"NemotronVoiceChatDataPlaneContext",
"NemotronVoiceChatDataPlaneSession",
]
@@ -0,0 +1,225 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""80 ms PCM packetization for Nemotron VoiceChat native duplex."""
from __future__ import annotations
import binascii
import numpy as np
import pybase64 as base64
NEMOTRON_VOICECHAT_SAMPLE_RATE = 16000
NEMOTRON_VOICECHAT_FRAME_SAMPLES = 1280
_SAMPLE_BYTES = 4
_FRAME_BYTES = NEMOTRON_VOICECHAT_FRAME_SAMPLES * _SAMPLE_BYTES
def _require_terminal_tail(buffered_bytes: int) -> None:
if buffered_bytes > _FRAME_BYTES:
raise ValueError(
"Nemotron VoiceChat manual/deferred commit cannot contain more than one 80 ms frame; "
"enable extra_body.auto_response for native full-duplex streaming"
)
def decode_pcm_f32le(payload: object, *, exact_frame: bool = False) -> bytes:
if not isinstance(payload, dict):
raise ValueError("Nemotron VoiceChat duplex audio payload must be a mapping")
if payload.get("format") != "pcm_f32le":
raise ValueError("Nemotron VoiceChat duplex audio format must be pcm_f32le")
if payload.get("sample_rate_hz") != NEMOTRON_VOICECHAT_SAMPLE_RATE:
raise ValueError("Nemotron VoiceChat duplex audio sample_rate_hz must be 16000")
encoded = payload.get("audio")
if not isinstance(encoded, str):
raise ValueError("Nemotron VoiceChat duplex audio must be base64 pcm_f32le")
try:
raw = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("Nemotron VoiceChat duplex audio is not valid base64") from exc
if len(raw) % _SAMPLE_BYTES:
raise ValueError("Nemotron VoiceChat pcm_f32le payload has a partial sample")
values = np.frombuffer(raw, dtype="<f4")
if values.size and not bool(np.isfinite(values).all()):
raise ValueError("Nemotron VoiceChat duplex audio samples must be finite")
if exact_frame and len(raw) != _FRAME_BYTES:
raise ValueError(
f"Nemotron VoiceChat native duplex append must contain exactly {NEMOTRON_VOICECHAT_FRAME_SAMPLES} samples"
)
return raw
def _frame_payload(raw: bytes, *, final: bool) -> dict[str, object]:
return {
"type": "audio",
"audio": base64.b64encode(raw).decode("ascii"),
"format": "pcm_f32le",
"sample_rate_hz": NEMOTRON_VOICECHAT_SAMPLE_RATE,
"final": final,
}
class NemotronVoiceChatPcmAppendReservation:
def __init__(
self,
owner: NemotronVoiceChatPcmAppendBuffer,
*,
operation_id: str,
payload: dict[str, object] | None,
raw: bytes,
) -> None:
self._owner = owner
self.operation_id = operation_id
self.payload = payload
self.raw = raw
self._active = True
@property
def active(self) -> bool:
return self._active
@property
def byte_count(self) -> int:
return len(self.raw)
def commit(self) -> None:
if not self._active:
return
self._active = False
if self in self._owner._reservations:
self._owner._reservations.remove(self)
def rollback(self) -> None:
if not self._active:
return
try:
index = self._owner._reservations.index(self)
except ValueError:
self._active = False
return
restore = bytearray()
for reservation in self._owner._reservations[index:]:
if reservation._active:
restore.extend(reservation.raw)
reservation._active = False
del self._owner._reservations[index:]
self._owner._buffer[:0] = restore
class NemotronVoiceChatPcmAppendBuffer:
"""Adapt Realtime packets to the current full-duplex PCM buffer contract.
The model consumes exactly one 1280-sample frame per scheduler append.
Realtime packets may split a frame arbitrarily, but a single packet is
capped at one frame so the serving contract never silently drops a second
ready frame behind a single-reservation API.
"""
def __init__(self) -> None:
self._buffer = bytearray()
self._reservations: list[NemotronVoiceChatPcmAppendReservation] = []
@property
def pending_byte_count(self) -> int:
return len(self._buffer)
def clear(self) -> None:
for reservation in self._reservations:
reservation._active = False
self._reservations.clear()
self._buffer.clear()
def clear_force_listen(self) -> None:
# Nemotron VoiceChat has no serving-side force-listen packet bit.
return
def has_pending(self) -> bool:
return bool(self._buffer)
def has_reserved(self) -> bool:
return any(reservation.active for reservation in self._reservations)
def _reserve_frame(
self,
*,
operation_id: str,
final: bool,
) -> NemotronVoiceChatPcmAppendReservation | None:
if len(self._buffer) < _FRAME_BYTES:
return None
raw = bytes(self._buffer[:_FRAME_BYTES])
del self._buffer[:_FRAME_BYTES]
reservation = NemotronVoiceChatPcmAppendReservation(
self,
operation_id=operation_id,
payload=_frame_payload(raw, final=final),
raw=raw,
)
self._reservations.append(reservation)
return reservation
def prepare_append(
self,
payload: dict[str, object],
*,
operation_id: str,
chunk_period_ms: int,
allow_emit: bool,
) -> NemotronVoiceChatPcmAppendReservation | None:
if chunk_period_ms != 80:
raise ValueError("Nemotron VoiceChat native duplex chunk_period_ms must be 80")
raw = decode_pcm_f32le(payload)
if len(raw) > _FRAME_BYTES:
raise ValueError("Nemotron VoiceChat Realtime append must contain at most 1280 samples")
self._buffer.extend(raw)
if not allow_emit:
return None
return self._reserve_frame(operation_id=operation_id, final=False)
def prepare_commit(
self,
*,
operation_id: str,
chunk_period_ms: int,
) -> NemotronVoiceChatPcmAppendReservation:
if chunk_period_ms != 80:
raise ValueError("Nemotron VoiceChat native duplex chunk_period_ms must be 80")
_require_terminal_tail(len(self._buffer))
if not self._buffer:
reservation = NemotronVoiceChatPcmAppendReservation(
self,
operation_id=operation_id,
payload=None,
raw=b"",
)
else:
raw = bytes(self._buffer)
self._buffer.clear()
reservation = NemotronVoiceChatPcmAppendReservation(
self,
operation_id=operation_id,
payload=_frame_payload(raw + bytes(_FRAME_BYTES - len(raw)), final=True),
raw=raw,
)
self._reservations.append(reservation)
return reservation
def flush(self, *, chunk_period_ms: int) -> dict[str, object] | None:
if chunk_period_ms != 80:
raise ValueError("Nemotron VoiceChat native duplex chunk_period_ms must be 80")
_require_terminal_tail(len(self._buffer))
if not self._buffer:
return None
raw = bytes(self._buffer)
self._buffer.clear()
return _frame_payload(raw + bytes(_FRAME_BYTES - len(raw)), final=True)
__all__ = [
"NEMOTRON_VOICECHAT_FRAME_SAMPLES",
"NEMOTRON_VOICECHAT_SAMPLE_RATE",
"NemotronVoiceChatPcmAppendBuffer",
"NemotronVoiceChatPcmAppendReservation",
"decode_pcm_f32le",
]
@@ -0,0 +1,167 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Engine policy for Nemotron VoiceChat's frame-locked duplex timeline."""
from __future__ import annotations
from typing import Any
from vllm.sampling_params import RequestOutputKind, SamplingParams
from vllm_omni.experimental.fullduplex.engine.contracts import (
DuplexAppendPlan,
DuplexInputMode,
DuplexOutputAction,
DuplexOutputDecision,
)
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
from vllm_omni.experimental.fullduplex.nemotron_voicechat.input import (
decode_pcm_f32le,
)
def _plain_token_ids(value: object, *, name: str) -> list[int]:
if not isinstance(value, list | tuple) or not value:
raise ValueError(f"Nemotron VoiceChat runtime requires non-empty {name}")
try:
return [int(token_id) for token_id in value]
except (TypeError, ValueError) as exc:
raise ValueError(f"Nemotron VoiceChat {name} must contain token ids") from exc
def _positive_int(value: object, *, name: str) -> int:
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"Nemotron VoiceChat requires a positive {name}") from exc
if parsed <= 0:
raise ValueError(f"Nemotron VoiceChat requires a positive {name}")
return parsed
class NemotronVoiceChatDuplexRuntimeExtension:
"""Append one 80 ms acoustic frame to one resumable thinker request."""
def configure_sampling_params(
self,
*,
runtime_config: dict[str, Any],
defaults: tuple[object, ...],
) -> tuple[object, ...]:
del runtime_config
if not defaults:
return defaults
# Every scheduler segment is one transport wake of a long-lived
# request. CUMULATIVE output makes the multimodal output processor
# concatenate all earlier Stage-2 waveforms and resend them on every
# wake (80, 160, 240, ... ms), causing O(n^2) audio replay. Duplex
# consumers require per-wake deltas at every stage.
configured = [params.clone() if isinstance(params, SamplingParams) else params for params in defaults]
for params in configured:
if isinstance(params, SamplingParams):
params.output_kind = RequestOutputKind.DELTA
stage0 = defaults[0]
if isinstance(stage0, SamplingParams):
stage0 = stage0.clone()
stage0.temperature = 0.0
stage0.top_p = 1.0
stage0.top_k = 0
stage0.max_tokens = 1
stage0.ignore_eos = True
stage0.output_kind = RequestOutputKind.DELTA
configured[0] = stage0
return tuple(configured)
def plan_append(
self,
*,
request_id: str,
fence: DuplexFence,
session_config: dict[str, Any],
runtime_config: dict[str, Any],
seq: int,
turn_seq: int,
mode: DuplexInputMode,
payload: object,
final: bool,
sampling_params: object,
) -> DuplexAppendPlan:
del sampling_params, turn_seq
if mode is not DuplexInputMode.APPEND_AUDIO_CHUNK:
raise ValueError(f"Nemotron VoiceChat does not support duplex input mode {mode.value!r}")
decode_pcm_f32le(payload, exact_frame=True)
normalized_payload = dict(payload)
prompt_ids = _plain_token_ids(
runtime_config.get("nvc_prompt_token_ids"),
name="nvc_prompt_token_ids",
)
max_model_len = _positive_int(
runtime_config.get("nvc_max_model_len"),
name="nvc_max_model_len",
)
required_model_len = len(prompt_ids) + seq + 1
if required_model_len > max_model_len:
raise ValueError(
"Nemotron VoiceChat native duplex session exceeds the Stage-0 "
f"max_model_len: prompt_tokens={len(prompt_ids)} + input_frames={seq} + "
f"sampled_token=1 gives {required_model_len} > {max_model_len}; "
"start a new session or raise Stage 0 max_model_len"
)
try:
pad_id = int(runtime_config["nvc_text_pad_id"])
except (KeyError, TypeError, ValueError) as exc:
raise ValueError("Nemotron VoiceChat runtime requires nvc_text_pad_id") from exc
scheduler_prompt = prompt_ids + [pad_id] if seq <= 1 else [pad_id]
return DuplexAppendPlan(
prompt={
"prompt_token_ids": scheduler_prompt,
"model_intermediate_buffer": {
"request_id": request_id,
"global_request_id": [fence.session_id],
"duplex": {
"data_plane": True,
"fence": fence,
"session_id": fence.session_id,
"incarnation": fence.incarnation,
"epoch": fence.epoch,
"source_input_seq": seq,
"seq": seq,
"mode": mode.value,
"payload": normalized_payload,
"final": final,
"session_config": dict(session_config),
"runtime_config": dict(runtime_config),
"scheduler_token_budget": len(scheduler_prompt),
},
},
}
)
def decide_output(
self,
*,
stage_id: int,
final_stage_id: int,
segment_finished: bool,
segment_token_ids: tuple[int, ...],
segment_output_metadata: dict[str, Any],
output: object,
) -> DuplexOutputDecision | None:
del final_stage_id, segment_finished, output
if stage_id != 0:
return None
metadata = dict(segment_output_metadata)
metadata["nvc_text_token_ids"] = list(segment_token_ids)
# Stage 0 is a client-visible side channel even though Stage 2 is the
# configured audio response stage. Mark it explicitly so the shared
# collector does not discard it while waiting for the final stage.
metadata["duplex_direct_response"] = True
return DuplexOutputDecision(
action=DuplexOutputAction.DIRECT_RESPONSE,
metadata=metadata,
final_output_type="text",
)
__all__ = ["NemotronVoiceChatDuplexRuntimeExtension"]
@@ -0,0 +1,363 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Realtime serving adapter for Nemotron VoiceChat native duplex."""
from __future__ import annotations
import asyncio
import json
import os
from collections.abc import Callable, Mapping
from copy import deepcopy
from typing import Any
from vllm_omni.experimental.fullduplex.minicpmo45.session import (
MiniCPMO45ServingSessionState,
)
from vllm_omni.experimental.fullduplex.nemotron_voicechat.data_plane import (
NemotronVoiceChatDataPlaneContext,
NemotronVoiceChatDataPlaneSession,
)
from vllm_omni.experimental.fullduplex.nemotron_voicechat.input import (
NemotronVoiceChatPcmAppendBuffer,
)
from vllm_omni.experimental.fullduplex.openai.protocol import DuplexCapabilities
from vllm_omni.experimental.fullduplex.openai.runtime_adapter import ServingRuntimeConfigError
EncodeAudio = Callable[[object, int, str, float | None], str | None]
_DEFAULT_SYSTEM_PROMPT = (
"You are an AI voice assistant developed by NVIDIA. "
"Your name is NVIDIA Voice Chat. "
"Answer in a spoken, conversational style rather than a written one. "
"Do not repeat the same sentence over and over again. "
"Start the conversation by greeting the user."
)
_PRIVATE_KEYS = frozenset(
{
"nvc_prompt_token_ids",
"nvc_text_bos_id",
"nvc_text_eos_id",
"nvc_text_pad_id",
"nvc_function_sotc_id",
"nvc_function_eotc_id",
"nvc_function_eotr_id",
"nvc_max_model_len",
"nvc_tokenizer_ref",
"nvc_tools_signature",
"nvc_function_response_generation",
"nvc_function_response_token_ids",
"nvc_function_response_call_id",
"nvc_function_response_batches",
}
)
def _stt_config(model_config: Any) -> dict[str, Any]:
hf_config = getattr(model_config, "hf_config", None)
stt_cfg = getattr(hf_config, "stt_cfg", None)
if not isinstance(stt_cfg, dict):
raise ServingRuntimeConfigError("Nemotron VoiceChat checkpoint STT configuration is unavailable")
return stt_cfg
def _normalized_tools(config: object) -> tuple[list[dict[str, object]], str]:
extra_body = getattr(config, "extra_body", None)
raw_tools = extra_body.get("realtime_tools") if isinstance(extra_body, dict) else None
if raw_tools is None:
return [], "[]"
if not isinstance(raw_tools, list):
raise ServingRuntimeConfigError("Nemotron VoiceChat tools must be a list")
if len(raw_tools) > 5:
raise ServingRuntimeConfigError("Nemotron VoiceChat supports at most 5 tools per session")
normalized: list[dict[str, object]] = []
for index, tool in enumerate(raw_tools):
if not isinstance(tool, dict):
raise ServingRuntimeConfigError(f"Nemotron VoiceChat tool {index} must be an object")
function = tool.get("function", tool)
if not isinstance(function, dict):
raise ServingRuntimeConfigError(f"Nemotron VoiceChat tool {index} has no function definition")
definition = {key: value for key, value in function.items() if key != "type"}
if not isinstance(definition.get("name"), str) or not str(definition["name"]).strip():
raise ServingRuntimeConfigError(f"Nemotron VoiceChat tool {index} requires a name")
normalized.append(definition)
signature = json.dumps(normalized, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return normalized, signature
def _render_tool_prompt(instructions: str, tools: list[dict[str, object]]) -> str:
if not tools:
return instructions
available = json.dumps(tools, ensure_ascii=False, sort_keys=True)
return (
f"{instructions}\n\n"
"You can use the following tools to assist the user if required:"
f"\n<AVAILABLE_TOOLS>{available}</AVAILABLE_TOOLS>\n\n"
"If you decide to call any tool(s), use the following format:\n"
'<TOOLCALL>[{"name": "tool_name1", "arguments": "tool_args1"}, '
'{"name": "tool_name2", "arguments": "tool_args2"}]</TOOLCALL>\n\n'
"The user will execute tool-calls and return responses from tool(s) in this format:\n"
'<TOOL_RESPONSE>[{"tool_response1"}, {"tool_response2"}]</TOOL_RESPONSE>\n\n'
"Based on the tool responses, you can call additional tools if needed, correct tool calls if any "
"errors are found, or just respond to the user."
)
def _render_tool_response(output: str) -> str:
"""Serialize one Realtime function result using NVIDIA's function channel."""
try:
value = json.loads(output)
except json.JSONDecodeError:
value = output
payload = json.dumps([value], ensure_ascii=True, separators=(",", ":"))
return f"<TOOL_RESPONSE>{payload}</TOOL_RESPONSE>"
def _require_native_full_duplex(config: object) -> None:
extra_body = getattr(config, "extra_body", None)
enabled = isinstance(extra_body, dict) and (
extra_body.get("auto_response") is True or extra_body.get("full_duplex") is True
)
if not enabled:
raise ServingRuntimeConfigError(
"Nemotron VoiceChat currently supports model-native full-duplex streaming only; "
"set extra_body.auto_response=true",
code="unsupported_nemotron_duplex_mode",
)
def _tokenize_runtime(model_config: Any, instructions: str) -> tuple[dict[str, object], Any]:
from transformers import AutoTokenizer
stt_cfg = _stt_config(model_config)
tokenizer_ref = os.environ.get("NEMOTRON_VOICECHAT_LLM_PATH") or stt_cfg.get(
"pretrained_llm", "nvidia/NVIDIA-Nemotron-Nano-9B-v2"
)
tokenizer = AutoTokenizer.from_pretrained(tokenizer_ref, trust_remote_code=False)
def token(name: str, default: str) -> int:
value = tokenizer.convert_tokens_to_ids(stt_cfg.get(name, default))
if value is None:
raise ServingRuntimeConfigError(f"Nemotron VoiceChat tokenizer does not define {name}")
return int(value)
bos_id = token("bos_token", "<s>")
eos_id = token("eos_token", "</s>")
pad_id = token("pad_token", "<SPECIAL_12>")
prompt_ids = [bos_id] + list(tokenizer.encode(instructions, add_special_tokens=False)) + [eos_id]
runtime = {
"instructions": instructions,
"nvc_prompt_token_ids": prompt_ids,
"nvc_text_bos_id": bos_id,
"nvc_text_eos_id": eos_id,
"nvc_text_pad_id": pad_id,
"nvc_function_sotc_id": int(tokenizer.convert_tokens_to_ids("<SPECIAL_20>")),
"nvc_function_eotc_id": int(tokenizer.convert_tokens_to_ids("<SPECIAL_21>")),
"nvc_function_eotr_id": int(tokenizer.convert_tokens_to_ids("<SPECIAL_22>")),
"nvc_tokenizer_ref": str(tokenizer_ref),
}
return runtime, tokenizer
class NemotronVoiceChatServingRuntimeAdapter:
adapter_id = "nemotron_voicechat"
silence_continuation_samples = 1280
# The session-owned persistent drain is the sole data-plane output
# consumer. Collecting on append would race that drain and can consume a
# Stage2 audio output before the realtime projector observes it.
collect_outputs_on_append = False
clean_response_done_prefix = ""
interrupted_tts_prefix = ""
private_runtime_config_keys = _PRIVATE_KEYS
def __init__(self, encode_audio: EncodeAudio) -> None:
self.session_states: dict[str, MiniCPMO45ServingSessionState] = {}
self.data_plane = NemotronVoiceChatDataPlaneSession(encode_audio)
self._tokenizer: Any | None = None
def create_session_state(self) -> MiniCPMO45ServingSessionState:
return MiniCPMO45ServingSessionState(
audio_buffer=NemotronVoiceChatPcmAppendBuffer(),
)
def session_state(self, session_id: str) -> MiniCPMO45ServingSessionState:
return self.session_states.setdefault(session_id, self.create_session_state())
def remove_session_state(self, session_id: str) -> None:
self.session_states.pop(session_id, None)
@staticmethod
def is_enabled(config: object) -> bool:
del config
return True
@staticmethod
def capabilities(*, max_sessions: int) -> DuplexCapabilities:
supports_multi_session = max_sessions > 1
return DuplexCapabilities(
supports_model_native_turn_policy=True,
supports_external_turn_signal=False,
supports_client_commit=True,
supports_barge_in=False,
supports_playback_ack=True,
supports_input_append=True,
supports_replace_latest_chunk=False,
supports_reencode_context=False,
supports_rollback_to_checkpoint=False,
supports_turn_commit_only=False,
supports_kv_lease=False,
supports_core_kv_lease=False,
supports_model_internal_state=True,
supports_stage_resumption=True,
supports_scheduler_native_append=False,
supports_core_resumable_request=True,
supports_stage_connector_handoff=True,
supports_independent_io_streams=True,
supports_realtime_endpoint=True,
supports_multi_session=supports_multi_session,
supports_multi_session_same_replica=False,
supports_session_lease=True,
supports_session_resume=True,
session_admission_mode="engine_managed",
supports_audio_truncate=False,
requires_model_runner_kv=True,
requires_native_stage_role=True,
implementation_level="model_native_duplex",
adapter_patterns=["scheduler_data_plane"],
input_modes=["append_audio_chunk"],
signal_sources=["model_native", "client_event"],
stage_handoff_transport="scheduler_data_plane",
chunk_period_ms=80,
target_barge_in_latency_ms=None,
)
@staticmethod
def validate_client_extra_body(extra_body: object) -> None:
if not isinstance(extra_body, dict):
return
private = sorted(_PRIVATE_KEYS.intersection(extra_body))
if private:
raise ServingRuntimeConfigError(
"Nemotron VoiceChat runtime configuration is server-owned: " + ", ".join(private)
)
async def prepare_runtime_config(
self,
config: object,
*,
model_config: Any,
) -> dict[str, object]:
self.validate_client_extra_body(getattr(config, "extra_body", None))
_require_native_full_duplex(config)
instructions = str(getattr(config, "instructions", None) or _DEFAULT_SYSTEM_PROMPT)
tools, tools_signature = _normalized_tools(config)
rendered_prompt = _render_tool_prompt(instructions, tools)
runtime, tokenizer = await asyncio.to_thread(_tokenize_runtime, model_config, rendered_prompt)
max_model_len = getattr(model_config, "max_model_len", None)
if not isinstance(max_model_len, int) or max_model_len <= 0:
raise ServingRuntimeConfigError("Nemotron VoiceChat requires a positive Stage-0 max_model_len")
runtime["nvc_max_model_len"] = max_model_len
# Keep raw session values for immutable-in-incarnation update checks;
# only the rendered prompt token ids enter Stage-0 KV.
runtime["instructions"] = instructions
runtime["nvc_tools_signature"] = tools_signature
self.data_plane.configure_runtime(runtime, tokenizer=tokenizer)
self._tokenizer = tokenizer
return runtime
def runtime_config_for_function_output(
self,
item: Mapping[str, object],
current: Mapping[str, object],
) -> dict[str, object]:
"""Queue a client tool result for frame-locked function-channel injection."""
call_id = item.get("call_id")
output = item.get("output")
if not isinstance(call_id, str) or not call_id:
raise ServingRuntimeConfigError(
"function_call_output requires call_id",
code="invalid_function_call_output",
)
if not isinstance(output, str):
raise ServingRuntimeConfigError(
"function_call_output requires a string output",
code="invalid_function_call_output",
)
if self._tokenizer is None:
raise ServingRuntimeConfigError(
"Nemotron VoiceChat tokenizer is unavailable for function output",
code="invalid_function_call_output",
)
response_token_ids = list(
self._tokenizer.encode(
_render_tool_response(output),
add_special_tokens=False,
)
)
if not response_token_ids:
raise ServingRuntimeConfigError(
"function_call_output tokenized to an empty response",
code="invalid_function_call_output",
)
runtime = deepcopy(dict(current))
generation = int(runtime.get("nvc_function_response_generation", 0)) + 1
token_ids = [int(token_id) for token_id in response_token_ids]
batches = runtime.get("nvc_function_response_batches")
if not isinstance(batches, list):
batches = []
batches = [*batches, {"generation": generation, "call_id": call_id, "token_ids": token_ids}]
runtime["nvc_function_response_generation"] = generation
runtime["nvc_function_response_token_ids"] = token_ids
runtime["nvc_function_response_call_id"] = call_id
runtime["nvc_function_response_batches"] = batches
return runtime
@staticmethod
def runtime_config_for_update(
config: object,
current: Mapping[str, object],
) -> dict[str, object]:
NemotronVoiceChatServingRuntimeAdapter.validate_client_extra_body(getattr(config, "extra_body", None))
_require_native_full_duplex(config)
runtime = deepcopy(dict(current))
instructions = str(getattr(config, "instructions", None) or _DEFAULT_SYSTEM_PROMPT)
_, tools_signature = _normalized_tools(config)
# The system/tool prompt is already resident in Stage-0 KV. Updating it
# without a new incarnation would make the advertised config disagree
# with model state, so reject such updates explicitly.
if runtime and instructions != runtime.get("instructions"):
raise ServingRuntimeConfigError(
"Nemotron VoiceChat instructions cannot change inside an active duplex incarnation"
)
if runtime and tools_signature != runtime.get("nvc_tools_signature", "[]"):
raise ServingRuntimeConfigError(
"Nemotron VoiceChat tools cannot change inside an active duplex incarnation"
)
return runtime
@staticmethod
def data_plane_context(
*,
epoch: int,
turn_id: int,
active_response_turn_id: int | None,
active_response_id: str | None,
auto_responds: bool,
response_format: str,
speed: float | None,
modalities: tuple[str, ...],
) -> NemotronVoiceChatDataPlaneContext:
del active_response_turn_id, active_response_id
return NemotronVoiceChatDataPlaneContext(
epoch=epoch,
turn_id=turn_id,
auto_responds=auto_responds,
response_format=response_format,
speed=speed,
modalities=modalities,
)
__all__ = ["NemotronVoiceChatServingRuntimeAdapter"]
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import asyncio
@@ -1052,6 +1055,46 @@ class RealtimeInputTranslator:
item_id = str(item["id"])
item_type = item.get("type")
role = item.get("role")
if item_type == "function_call_output":
call_id = item.get("call_id")
output = item.get("output")
matching_call = any(
known.get("type") == "function_call" and known.get("call_id") == call_id
for known in self._conversation_items.values()
)
duplicate_output = any(
known.get("type") == "function_call_output" and known.get("call_id") == call_id
for known in self._conversation_items.values()
)
if not isinstance(call_id, str) or not call_id or not matching_call:
await self._send_realtime_payload(
self._realtime_error_payload(
"invalid_function_call_output",
"function_call_output requires the call_id of a completed function call",
event_id=event.get("event_id"),
param="item.call_id",
)
)
return None
if not isinstance(output, str) or duplicate_output:
await self._send_realtime_payload(
self._realtime_error_payload(
"invalid_function_call_output",
(
"function_call_output requires a string output"
if not isinstance(output, str)
else f"function_call_output already exists for call_id {call_id}"
),
event_id=event.get("event_id"),
param="item.output",
)
)
return None
# Do not retain the result until SessionRunner has delivered its
# runtime update. On success, the resulting
# ``conversation.item.created`` event records it in the shared
# input/output protocol state; on failure the client may retry the
# same call_id instead of being rejected by provisional history.
if item_type != "message" or role in {"assistant", "system"}:
return {
"type": "turn.signal",
@@ -346,6 +346,67 @@ class RealtimeOutputProjector:
"reason": event.get("finish_reason") or "stop",
},
)
if event_type == "function_call.done":
call_id = event.get("call_id")
name = event.get("name")
arguments = event.get("arguments", "")
if not isinstance(call_id, str) or not call_id or not isinstance(name, str) or not name:
return [{"type": "duplex.function_call.done", "event": event}]
if not isinstance(arguments, str):
arguments = str(arguments)
response_id = f"resp_{uuid4().hex}"
item = {
"id": f"item_{uuid4().hex}",
"object": "realtime.item",
"type": "function_call",
"status": "completed",
"name": name,
"call_id": call_id,
"arguments": arguments,
}
self._conversation_items[str(item["id"])] = item
response = {
"id": response_id,
"object": "realtime.response",
"status": "completed",
"status_details": {"type": "completed", "reason": "completed"},
"output": [dict(item)],
"metadata": {},
}
return [
{"type": "response.created", "response": {**response, "status": "in_progress", "output": []}},
*self._conversation_item_added_events(item),
{
"type": "response.output_item.added",
"response_id": response_id,
"output_index": 0,
"item": dict(item),
},
{
"type": "response.function_call_arguments.delta",
"response_id": response_id,
"item_id": item["id"],
"output_index": 0,
"call_id": call_id,
"delta": arguments,
},
{
"type": "response.function_call_arguments.done",
"response_id": response_id,
"item_id": item["id"],
"output_index": 0,
"call_id": call_id,
"arguments": arguments,
},
{
"type": "response.output_item.done",
"response_id": response_id,
"output_index": 0,
"item": dict(item),
},
self._conversation_item_done_event(item),
{"type": "response.done", "response": response},
]
if event_type == "error":
raw_error = event.get("error")
if isinstance(raw_error, dict):
@@ -124,6 +124,7 @@ class ServingRuntimeAdapter(Protocol):
clean_response_done_prefix: str
interrupted_tts_prefix: str
private_runtime_config_keys: frozenset[str]
collect_outputs_on_append: bool
def create_session_state(self) -> ServingRuntimeSessionState: ...
@@ -135,7 +135,9 @@ class NativeRuntimeBridgeMixin:
incarnation=session.incarnation,
)
if self._callable_accepts_keyword(append_input, "collect_outputs"):
append_kwargs["collect_outputs"] = False
append_kwargs["collect_outputs"] = bool(
getattr(self._serving_runtime_adapter, "collect_outputs_on_append", False)
)
result = await append_input(session.session_id, **append_kwargs)
except Exception as exc:
logger.exception("Failed to append duplex runtime input: %s", exc)
@@ -315,16 +317,21 @@ class NativeRuntimeBridgeMixin:
native.data_plane_task = task
return True
# One model unit (1 s at 16 kHz) of pcm_f32le silence, matching the
# official full-duplex behavior where the microphone keeps streaming
# silence while the assistant speaks; replies span multiple units.
# One MiniCPM model unit (1 s at 16 kHz) is the compatibility default.
# Other native-duplex adapters may use a different frame-locked unit.
_NATIVE_SILENCE_UNIT_PAYLOAD_AUDIO = base64.b64encode(bytes(16000 * 4)).decode("ascii")
_NATIVE_RESPONSE_MAX_CONTINUATION_UNITS = 8
def _native_silence_unit_payload(self) -> dict[str, object]:
samples = int(getattr(self._serving_runtime_adapter, "silence_continuation_samples", 16000))
audio = (
self._NATIVE_SILENCE_UNIT_PAYLOAD_AUDIO
if samples == 16000
else base64.b64encode(bytes(samples * 4)).decode("ascii")
)
return {
"type": "audio",
"audio": self._NATIVE_SILENCE_UNIT_PAYLOAD_AUDIO,
"audio": audio,
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
@@ -826,6 +833,18 @@ class NativeRuntimeBridgeMixin:
}
)
return close_reason, True
if native_result.get("function_call") is True:
await send_json(
{
"type": "function_call.done",
"session_id": session.session_id,
"epoch": session.epoch,
"call_id": native_result.get("call_id"),
"name": native_result.get("name"),
"arguments": native_result.get("arguments", ""),
}
)
return close_reason, True
if native_result.get("requires_stage_handoff") is True or native_result.get("requires_tts_stage") is True:
# Stage0 announces a talker handoff before Stage1 has produced
# client-visible audio. A single client input stream may contain
@@ -1728,6 +1728,10 @@ class OmniDuplexSessionHandler(
session.truncate_history_item(item_id, audio_end_ms=committed_ms)
new_epoch, old_playback = self._advance_barge_in_epoch(session)
if old_request_id is not None:
# Epoch-scoped request ids prevent state reuse, but explicitly
# release projector/parser cursors so cancelled epochs do not
# accumulate until the whole session closes.
self._serving_runtime_adapter.data_plane.close_stream(old_request_id)
await self._abort_request_background(
session,
old_request_id,
@@ -345,6 +345,13 @@ class DuplexSessionRunnerMixin:
)
precreated_response_id = session.active_response_id if precreate_response else None
def _discard_retained_committed_audio() -> None:
if (
retained_committed_payload is not None
and native.committed_audio_payload is retained_committed_payload
):
session.release_input_bytes(native.clear_committed_audio())
async def _run() -> bool:
nonlocal runtime_closed
try:
@@ -367,8 +374,10 @@ class DuplexSessionRunnerMixin:
and native.committed_audio_payload is retained_committed_payload
):
session.release_input_bytes(native.clear_committed_audio())
elif pcm_reservation is not None:
pcm_reservation.rollback()
else:
if pcm_reservation is not None:
pcm_reservation.rollback()
_discard_retained_committed_audio()
if (
not append_ok
and precreated_response_id is not None
@@ -406,6 +415,7 @@ class DuplexSessionRunnerMixin:
except Exception as exc:
if pcm_reservation is not None:
pcm_reservation.rollback()
_discard_retained_committed_audio()
logger.exception("Native duplex append task failed: %s", exc)
await self._send_runtime_error(emit_event, "runtime_append_task_failed", exc, session=session)
if session.state != DuplexSessionState.CLOSED:
@@ -440,19 +450,34 @@ class DuplexSessionRunnerMixin:
if not predecessor_ok:
if pcm_reservation is not None:
pcm_reservation.rollback()
_discard_retained_committed_audio()
return False
if actor.closing or runtime_closed or session.state != DuplexSessionState.OPEN:
if pcm_reservation is not None:
pcm_reservation.rollback()
_discard_retained_committed_audio()
return False
if before_append is not None and not before_append():
if pcm_reservation is not None:
pcm_reservation.rollback()
_discard_retained_committed_audio()
return True
if pcm_reservation is not None and not pcm_reservation.active:
_discard_retained_committed_audio()
return False
return await _run()
def _release_cancelled_retained_audio(done: asyncio.Task[bool]) -> None:
if done.cancelled():
_discard_retained_committed_audio()
return
try:
append_ok = done.result()
except Exception:
append_ok = False
if not append_ok:
_discard_retained_committed_audio()
predecessor = actor.native_append_tail
if predecessor is not None and predecessor.done():
try:
@@ -465,6 +490,7 @@ class DuplexSessionRunnerMixin:
# is an explicit retry and starts a new chain.
predecessor = None
task = asyncio.create_task(_run_in_wire_order(predecessor))
task.add_done_callback(_release_cancelled_retained_audio)
actor.native_append_tail = task
actor.track_append_task(
task,
@@ -929,6 +955,8 @@ class DuplexSessionRunnerMixin:
had_native_append = await actor.cancel_append_tasks(
response_bound_only=event_type in {"response.cancel", "output_audio_buffer.clear"},
)
if event_type == "response.cancel":
session.release_input_bytes(native.clear_committed_audio())
had_native_stream = native.data_plane_task is not None
cancelled = await self._cancel_active_response(
session,
@@ -1109,6 +1137,53 @@ class DuplexSessionRunnerMixin:
if turn_event == "conversation.item.create":
payload = event.get("payload")
item = payload.get("item") if isinstance(payload, dict) else None
item_type = item.get("type") if isinstance(item, dict) else None
prepare_function_output = getattr(
self._serving_runtime_adapter,
"runtime_config_for_function_output",
None,
)
if item_type == "function_call_output" and callable(prepare_function_output):
if not await wait_for_native_append_tail():
continue
try:
candidate_runtime_config = prepare_function_output(
item,
dict(session.runtime_config),
)
except ServingRuntimeConfigError as exc:
await emit_event(
{
"type": "error",
"session_id": session.session_id,
"code": exc.code,
"error": str(exc),
}
)
continue
if not await self._signal_runtime_session(
session,
"session.update",
emit_event,
runtime_config=candidate_runtime_config,
):
continue
session.replace_runtime_config(candidate_runtime_config)
await emit_event(
{
"type": "conversation.item.created",
"session_id": session.session_id,
"item": item,
"created": True,
}
)
await self._maybe_continue_native_response(
emit_event,
session=session,
expected_epoch=session.epoch,
expected_model_turn_id=session.turn_id,
)
continue
message = self._realtime_item_to_history_message(item)
item_id = item.get("id") if isinstance(item, dict) else None
if message is not None:
@@ -121,12 +121,15 @@ class DuplexSessionTasks:
]
if not tasks:
return False
cancelled_tail = self.native_append_tail if self.native_append_tail in tasks else None
for task in tasks:
task.cancel()
try:
await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=timeout_s)
except TimeoutError:
pass
if cancelled_tail is not None and self.native_append_tail is cancelled_tail:
self.native_append_tail = None
return True
@@ -830,6 +830,75 @@ class ConformerEncoder(NeuralModule, StreamingEncoder, Exportable, AccessMixin):
self.streaming_cfg = streaming_cfg
def streaming_post_process(self, rets, keep_all_outputs=True):
"""Apply NeMo's cache-aware output trimming contract."""
if len(rets) == 2:
return rets[0], rets[1], None, None, None
encoded, encoded_len, cache_channel, cache_time, cache_channel_len = rets
if cache_channel is not None and self.streaming_cfg.last_channel_cache_size >= 0:
if self.streaming_cfg.last_channel_cache_size > 0:
cache_channel = cache_channel[:, :, -self.streaming_cfg.last_channel_cache_size :, :]
if self.streaming_cfg.valid_out_len > 0 and (not keep_all_outputs or self.att_context_style == "regular"):
encoded = encoded[:, :, : self.streaming_cfg.valid_out_len]
encoded_len = torch.clamp(encoded_len, max=self.streaming_cfg.valid_out_len)
return encoded, encoded_len, cache_channel, cache_time, cache_channel_len
def get_initial_cache_state(
self,
batch_size=1,
dtype=torch.float32,
device=None,
max_dim=0,
):
"""Create the channel/time caches used by cache-aware streaming."""
if self.streaming_cfg is None:
self.setup_streaming_params()
if device is None:
device = next(self.parameters()).device
create_tensor = torch.randn if max_dim > 0 else torch.zeros
cache_last_channel = create_tensor(
(
len(self.layers),
batch_size,
self.streaming_cfg.last_channel_cache_size,
self.d_model,
),
device=device,
dtype=dtype,
)
cache_last_time = create_tensor(
(
len(self.layers),
batch_size,
self.d_model,
self.conv_context_size[0],
),
device=device,
dtype=dtype,
)
if max_dim > 0:
cache_last_channel_len = torch.randint(
0,
min(max_dim, self.streaming_cfg.last_channel_cache_size),
(batch_size,),
device=device,
dtype=torch.int64,
)
for i in range(batch_size):
cache_last_channel[:, i, cache_last_channel_len[i] :, :] = 0
if cache_last_channel_len[i] == 0:
cache_last_time[:, i, :, :] = 0
else:
cache_last_channel_len = torch.zeros(
batch_size,
device=device,
dtype=torch.int64,
)
return cache_last_channel, cache_last_time, cache_last_channel_len
def change_subsampling_conv_chunking_factor(self, subsampling_conv_chunking_factor: int):
"""
Update the conv_chunking_factor (int)
@@ -259,6 +259,51 @@ class StreamingEncoder(ABC):
"""
pass
@abstractmethod
def get_initial_cache_state(self, batch_size, dtype, device, max_dim):
pass
def cache_aware_stream_step(
self,
processed_signal,
processed_signal_length=None,
cache_last_channel=None,
cache_last_time=None,
cache_last_channel_len=None,
keep_all_outputs=True,
drop_extra_pre_encoded=None,
):
"""Run one incremental encoder step using NeMo's cache contract."""
if self.streaming_cfg is None:
self.setup_streaming_params()
if drop_extra_pre_encoded is not None:
prev_drop_extra_pre_encoded = self.streaming_cfg.drop_extra_pre_encoded
self.streaming_cfg.drop_extra_pre_encoded = drop_extra_pre_encoded
else:
prev_drop_extra_pre_encoded = None
if processed_signal_length is None:
processed_signal_length = processed_signal.new_full(
(processed_signal.size(0),),
processed_signal.size(-1),
)
try:
encoder_output = self(
audio_signal=processed_signal,
length=processed_signal_length,
cache_last_channel=cache_last_channel,
cache_last_time=cache_last_time,
cache_last_channel_len=cache_last_channel_len,
)
return self.streaming_post_process(
encoder_output,
keep_all_outputs=keep_all_outputs,
)
finally:
if prev_drop_extra_pre_encoded is not None:
self.streaming_cfg.drop_extra_pre_encoded = prev_drop_extra_pre_encoded
# ==============================================================================
# CacheAwareStreamingConfig — exact copy of the dataclass from
@@ -935,8 +935,8 @@ class GatedProjectedSumRMSNorm(nn.Module):
audio_emb = audio_emb / self.num_codebooks
# projections run in model dtype (BF16)
audio_h = self.audio_proj(audio_emb)
text_h = self.text_proj(text_emb)
audio_h = self.audio_proj(audio_emb.to(dtype=self.audio_proj.weight.dtype))
text_h = self.text_proj(text_emb.to(dtype=self.text_proj.weight.dtype))
dtype = audio_h.dtype
@@ -1302,7 +1302,7 @@ class RVQEARTTSModel(nn.Module):
pre_bos_mask = bos_mask.cumsum(dim=1) == 0 # [B, T, 1]
# Apply projection to model size
code_embed = self.embed_code(code_embed)
code_embed = self.embed_code(code_embed.to(dtype=self.embed_code.weight.dtype))
# Choose projection
if self.config.get("audio_prompt_encoder_config", None):
@@ -1336,7 +1336,7 @@ class RVQEARTTSModel(nn.Module):
code_embeds = code_embed + bos_mask * self.bos_emb
else: # Inference
code_embeds = self.embed_code(self.depthsum_embedding(code))
code_embeds = self.embed_code(self.depthsum_embedding(code).to(dtype=self.embed_code.weight.dtype))
uncond_dec_flag = torch.zeros(code.size(0), 1, 1, device=code.device, dtype=torch.bool)
if guidance_enabled:
@@ -1591,7 +1591,7 @@ class RVQEARTTSModel(nn.Module):
top_p_or_k_i = top_p_or_k[i] if top_p_or_k is not None else 1.0
noise_scale_i = noise_scale[i] if noise_scale is not None else 1.0
mog_input_embeds = self.embed_code(self.depthsum_embedding(code))
mog_input_embeds = self.embed_code(self.depthsum_embedding(code).to(self.embed_code.weight.dtype))
if self.config.random_target_masking:
mog_input_embeds += self.embed_target_mask(cnt + k - 1)
if guidance_scale_i > 0.0:
@@ -24,6 +24,7 @@ import torch.nn as nn
from vllm.config import VllmConfig
from vllm.logger import init_logger
from vllm_omni.model_executor.models.nemotron_voicechat.runtime_info import scalar_bool
from vllm_omni.model_executor.models.output_templates import OmniOutput
logger = init_logger(__name__)
@@ -83,6 +84,7 @@ class NemotronVoiceChatCode2Wav(nn.Module):
# Constructed in load_weights (owns the tts_model.audio_codec.* subtree).
self.audio_codec: nn.Module | None = None
self._duplex_codec_caches: dict[str, object] = {}
# ------------------------------------------------------------------
# Runner-facing placeholder hooks.
@@ -147,6 +149,12 @@ class NemotronVoiceChatCode2Wav(nn.Module):
meta = runtime_info.get("meta") if isinstance(runtime_info, Mapping) else None
if isinstance(meta, Mapping) and meta.get("nemotron_voicechat_dummy_profile") is True:
continue
codec_streaming = scalar_bool(meta.get("codec_streaming")) if isinstance(meta, Mapping) else False
if isinstance(runtime_info, Mapping):
codec_streaming = codec_streaming or scalar_bool(runtime_info.get("meta.codec_streaming"))
cache_request_id = meta.get("request_id") if isinstance(meta, Mapping) else None
if cache_request_id is None and isinstance(runtime_info, Mapping):
cache_request_id = runtime_info.get("meta.request_id")
codes = self._codes_from_runtime_info(runtime_info)
if codes is None:
# No input_ids fallback (unlike PersonaPlexCode2Wav): this
@@ -159,6 +167,12 @@ class NemotronVoiceChatCode2Wav(nn.Module):
"code stack (placeholder input_ids are never decoded)."
)
codes = validate_code_stack(codes, self._num_quantizers, self._codebook_size).to(device=device)
logger.debug(
"Nemotron VoiceChat code2wav input: request=%s shape=%s codec_streaming=%s",
cache_request_id,
tuple(codes.shape),
codec_streaming,
)
frames = codes.shape[0]
if frames == 0:
continue
@@ -176,19 +190,42 @@ class NemotronVoiceChatCode2Wav(nn.Module):
if left_context_frames >= frames:
continue # terminal empty chunk: all frames already emitted
lens = torch.tensor([frames], device=device, dtype=torch.long)
codec_cache = None
if codec_streaming:
if not isinstance(cache_request_id, str) or not cache_request_id:
raise ValueError("Nemotron VoiceChat incremental codec input lacks meta.request_id")
from vllm_omni.model_executor.models.nemotron_voicechat.nemo_vendored.ear_tts_vae_codec import (
CausalConv1dCache,
)
codec_cache = self._duplex_codec_caches.setdefault(cache_request_id, CausalConv1dCache())
# NeMo decodes the codec strictly in fp32.
with torch.autocast(device_type=device.type, enabled=False):
wav, wav_len = self.audio_codec.decode(codes.unsqueeze(0), lens)
wav, wav_len = self.audio_codec.decode(
codes.unsqueeze(0),
lens,
cache=codec_cache,
)
wav = wav.reshape(-1)[: int(wav_len.reshape(-1)[0])]
if left_context_frames > 0:
if left_context_frames > 0 and not codec_streaming:
wav = wav[left_context_frames * self._wav_per_frame :]
audios[i] = wav.detach().to(dtype=torch.float32).cpu()
logger.debug(
"Nemotron VoiceChat code2wav output: request=%s frames=%s samples=%s",
cache_request_id,
frames,
int(audios[i].numel()),
)
return OmniOutput(
text_hidden_states=None,
multimodal_outputs={"model_outputs": audios, "sr": srs},
)
def on_requests_finished(self, finished_req_ids: set[str] | list[str]) -> None:
for request_id in finished_req_ids:
self._duplex_codec_caches.pop(str(request_id), None)
def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput | tuple, **kwargs: Any) -> OmniOutput:
if isinstance(model_outputs, OmniOutput):
return model_outputs
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Stage-1 talker for NemotronVoiceChat: frame-locked text -> 31-quantizer codes.
``LLM_AR`` stage driven per frame by the vLLM engine, with the vendored NeMo
@@ -46,6 +46,7 @@ from vllm.logger import init_logger
from vllm_omni.model_executor.models.nemotron_voicechat.runtime_info import (
merge_runtime_info,
require_request_id,
scalar_bool,
)
from vllm_omni.model_executor.models.output_templates import OmniOutput
@@ -164,6 +165,7 @@ class NemotronVoiceChatTalkerForConditionalGeneration(nn.Module):
info_dicts = kwargs.get("model_intermediate_buffer") or kwargs.get("runtime_additional_information") or []
codes_list: list[torch.Tensor] = []
prompt_len: int | None = None
codec_streaming = False
for info in info_dicts:
if not isinstance(info, dict):
continue
@@ -177,16 +179,32 @@ class NemotronVoiceChatTalkerForConditionalGeneration(nn.Module):
reported = info.get("meta.nvc_logical_prompt_len")
if reported is not None:
prompt_len = int(reported)
streaming = meta.get("codec_streaming") if isinstance(meta, dict) else None
if streaming is None:
streaming = info.get("meta.codec_streaming")
codec_streaming = codec_streaming or scalar_bool(streaming)
if not codes_list:
return OmniOutput(text_hidden_states=hidden, multimodal_outputs={})
outputs: dict[str, Any] = {"codes": {"audio": torch.cat(codes_list, dim=0)}}
codes = torch.cat(codes_list, dim=0)
logger.debug(
"Nemotron VoiceChat talker emitted codes: shapes=%s combined=%s prompt_len=%s",
[tuple(item.shape) for item in codes_list],
tuple(codes.shape),
prompt_len,
)
outputs: dict[str, Any] = {"codes": {"audio": codes}}
# The prompt length must ride the WIRE payload (multimodal_outputs):
# the async-chunk dispatcher hands exactly this dict to the
# talker2code2wav producer, while request.additional_information is
# overwritten concurrently by arriving thinker chunks.
output_meta: dict[str, Any] = {}
if prompt_len is not None:
# 1-D on purpose: the wire-payload builder indexes element.shape[0].
outputs["meta"] = {"nvc_logical_prompt_len": torch.tensor([prompt_len], dtype=torch.long)}
output_meta["nvc_logical_prompt_len"] = torch.tensor([prompt_len], dtype=torch.long)
if codec_streaming:
output_meta["codec_streaming"] = torch.tensor([True], dtype=torch.bool)
if output_meta:
outputs["meta"] = output_meta
return OmniOutput(
text_hidden_states=hidden,
multimodal_outputs=outputs,
@@ -269,8 +287,9 @@ class NemotronVoiceChatTalkerForConditionalGeneration(nn.Module):
# offline_inference lines around get_init_inputs/first forward).
self.tts.set_init_inputs(speaker_name=self._speaker_name)
init_inputs = self.tts.get_init_inputs(B=1)
generation_config = self.tts._get_generation_config(self._guidance_enabled())
init_inputs.update({"use_cache": True, "past_key_values": None, "guidance_enabled": self._guidance_enabled()})
guidance_enabled = self._guidance_enabled()
generation_config = self.tts._get_generation_config(guidance_enabled)
init_inputs.update({"use_cache": True, "past_key_values": None, "guidance_enabled": guidance_enabled})
with torch.inference_mode():
outputs = self.tts.tts_model(**init_inputs)
prompt_len = self._logical_prompt_len(info)
@@ -281,6 +300,10 @@ class NemotronVoiceChatTalkerForConditionalGeneration(nn.Module):
"meta.num_processed_tokens); the code2wav producer cannot trim the "
"prompt-region codes without it."
)
meta = info.get("meta")
codec_streaming = scalar_bool(meta.get("codec_streaming")) if isinstance(meta, dict) else False
codec_streaming = codec_streaming or scalar_bool(info.get("meta.codec_streaming"))
codec_request_id = meta.get("request_id") if isinstance(meta, dict) else None
session = {
"timeline": timeline,
"prompt_len": prompt_len,
@@ -288,9 +311,13 @@ class NemotronVoiceChatTalkerForConditionalGeneration(nn.Module):
"past_key_values": outputs.past_key_values,
"code": init_inputs["code"][:, -1:],
"first_context_subword_id": init_inputs["subword_ids"][:, -1].unsqueeze(-1),
"current_subword_mask": torch.ones(1, 1, device=device, dtype=torch.bool),
"tts_initialized": False,
"guidance_enabled": guidance_enabled,
"generation_config": generation_config,
"codes_rows": [],
"codec_streaming": codec_streaming,
"codec_request_id": codec_request_id,
# Sync mode ships the whole timeline up front via
# nvc_text_timeline; async-chunk mode grows it across chunks and
# the last chunk sets meta.finished.
@@ -333,25 +360,27 @@ class NemotronVoiceChatTalkerForConditionalGeneration(nn.Module):
session["tts_initialized"] = True
else:
prev_subword_id = timeline[t - 1].reshape(1, 1)
current_subword_mask = torch.ones(1, 1, device=timeline.device, dtype=torch.bool)
with torch.inference_mode():
code, past_key_values = self.tts.infer_codes_one_step(
current_subword_id=current_subword_id,
prev_subword_id=prev_subword_id,
current_subword_mask=current_subword_mask,
current_subword_mask=session["current_subword_mask"],
prev_audio_tokens=session["code"],
past_key_values=session["past_key_values"],
guidance_enabled=self._guidance_enabled(),
guidance_enabled=session["guidance_enabled"],
generation_config=session["generation_config"],
ignore_eos_flag_stop=True,
)
session["code"] = code
session["past_key_values"] = past_key_values
session["step"] = t + 1
# Async mode: exhausting the received timeline only ends the request
# once the upstream marked its final chunk; otherwise more entries are
# in flight and the scheduler parks us until they arrive.
finished = (t + 1) >= int(session["timeline"].numel()) and bool(session["upstream_finished"])
# Native codec streaming uses one resumable scheduler segment per
# received timeline suffix. The pre-existing offline async-chunk path,
# however, must stay alive until the thinker sends its terminal marker;
# stopping at an intermediate cumulative snapshot starves Stage 2.
finished = (t + 1) >= int(session["timeline"].numel()) and (
bool(session["codec_streaming"]) or bool(session["upstream_finished"])
)
return code.reshape(1, -1).to(torch.long), finished
def preprocess(
@@ -388,21 +417,34 @@ class NemotronVoiceChatTalkerForConditionalGeneration(nn.Module):
# chunk each.
finished = False
steps_run = 0
new_code_rows: list[torch.Tensor] = []
codec_streaming = bool(session["codec_streaming"])
while int(session["step"]) < int(session["timeline"].numel()):
codes_row, finished = self._step_session(session)
session["codes_rows"].append(codes_row.cpu())
if codec_streaming:
# One D2H copy per wake is enough; avoid synchronizing after every drained TTS step.
new_code_rows.append(codes_row)
else:
session["codes_rows"].append(codes_row.cpu())
steps_run += 1
if session["sync_mode"]:
break
if steps_run == 0:
# Zero-progress wake (duplicate/terminal-marker chunk): no TTS step
# was run, so emit a plain CONTINUE placeholder — never a
# fabricated frame. The request finishes only when the upstream is
# exhausted AND the timeline is fully consumed.
finished = bool(session["upstream_finished"])
# was run, so never fabricate a frame. Stop the current scheduler
# segment only for native codec streaming, or once the ordinary
# offline async producer has sent its terminal marker.
finished = int(session["step"]) >= int(session["timeline"].numel()) and (
bool(session["codec_streaming"]) or bool(session["upstream_finished"])
)
if finished:
embeds[:, 0] = 1.0
return input_ids, embeds, {}
if codec_streaming:
# The code2wav producer owns suffix slicing and therefore requires
# cumulative history on every wake. Keep the history on CPU and do
# one D2H transfer for all rows produced by this wake.
session["codes_rows"].append(torch.cat(new_code_rows, dim=0).cpu())
cumulative = torch.cat(session["codes_rows"], dim=0)
if finished:
embeds[:, 0] = 1.0 # stop flag -> compute_logits emits the stop token
@@ -413,7 +455,12 @@ class NemotronVoiceChatTalkerForConditionalGeneration(nn.Module):
# chunks and by these model updates, so the async code2wav producer
# reads the prompt length from the per-step multimodal_output
# instead of racing the request-level dict.
"meta": {"nvc_tts_step": int(session["step"]), "nvc_logical_prompt_len": int(session["prompt_len"])},
"meta": {
"nvc_tts_step": int(session["step"]),
"nvc_logical_prompt_len": int(session["prompt_len"]),
"codec_streaming": bool(session["codec_streaming"]),
"request_id": session["codec_request_id"],
},
}
return input_ids, embeds, info_update
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Stage-0 thinker for NemotronVoiceChat: 16 kHz speech -> frame-locked text.
``LLM_AR`` stage combining:
@@ -12,7 +12,8 @@
* the NemotronH (hybrid Mamba) backbone on vLLM paged execution via
``init_vllm_registered_model`` — this is where vLLM earns its keep;
* the separate ``lm_head`` (text channel, sampled by the vLLM engine) and
``function_head`` (greedy, maintained internally per request).
``function_head`` (greedy, maintained internally per request, with client
tool results injected into the function channel).
Timeline contract (verified against NeMo ``_init_inference``/``_step_zero``/
``_step_inference``; the golden shape test guards it):
@@ -29,13 +30,14 @@ Timeline contract (verified against NeMo ``_init_inference``/``_step_zero``/
example sets ``max_tokens = acoustic_frame_count``. Never set ``min_tokens``:
the tokenizer's eos_token is <SPECIAL_12> — the frame-locked PAD/silence
token — and min_tokens masks "EOS" to -inf, forbidding silence entirely.
* The function channel exists for numerical correctness (fusion weight 2.0)
even though tool calling is out of scope; the greedy token timeline is
exposed for debugging via the request info.
* The function channel is load-bearing for numerical correctness (fusion
weight 2.0), emits tool calls, and accepts client tool results while keeping
the text channel padded during forced response injection.
"""
from __future__ import annotations
import copy
import os
from collections.abc import Iterable
from typing import Any
@@ -47,7 +49,10 @@ from vllm.config import VllmConfig
from vllm.logger import init_logger
from vllm.model_executor.models.interfaces import HasInnerState, IsHybrid
from vllm.model_executor.models.utils import init_vllm_registered_model, maybe_prefix
from vllm.v1.outputs import SamplerOutput
from vllm.v1.sample.metadata import SamplingMetadata
from vllm_omni.experimental.fullduplex.model_executor import DuplexSamplingRow
from vllm_omni.model_executor.models.nemotron_voicechat.runtime_info import (
merge_runtime_info,
require_request_id,
@@ -59,6 +64,49 @@ logger = init_logger(__name__)
_LM_ARCHITECTURE = "NemotronHForCausalLM"
def slice_perception_streaming_mel(
processed_signal: torch.Tensor,
frame_idx: int,
streaming_cfg: Any,
) -> tuple[torch.Tensor, int]:
"""Slice one 80 ms mel chunk using NeMo's cache-manager geometry."""
def _at(value: Any, index: int) -> int:
return int(value[index] if isinstance(value, list | tuple) else value)
chunk_size_first = _at(streaming_cfg.chunk_size, 0)
chunk_size = _at(streaming_cfg.chunk_size, 1)
shift_size_first = _at(streaming_cfg.shift_size, 0)
shift_size = _at(streaming_cfg.shift_size, 1)
pre_encode_first = _at(streaming_cfg.pre_encode_cache_size, 0)
pre_encode_size = _at(streaming_cfg.pre_encode_cache_size, 1)
if frame_idx == 0:
mel_chunk = processed_signal[:, :, :chunk_size_first]
if pre_encode_first > 0:
mel_chunk = torch.nn.functional.pad(mel_chunk, (pre_encode_first, 0))
return mel_chunk, 0
chunk_start = shift_size_first + (frame_idx - 1) * shift_size
chunk_end = chunk_start + chunk_size
# Match NeMo's tail-boundary rule. With one 80 ms frame per call this
# normally leaves the expected trailing STFT columns unused.
offset = chunk_size - shift_size_first
if chunk_end > processed_signal.shape[-1] - offset:
chunk_end = processed_signal.shape[-1] - offset
chunk_start = chunk_end - chunk_size
main_chunk = processed_signal[:, :, chunk_start:chunk_end]
cache_start = max(0, chunk_start - pre_encode_size)
cache_mel = processed_signal[:, :, cache_start:chunk_start]
if cache_mel.shape[-1] < pre_encode_size:
cache_mel = torch.nn.functional.pad(
cache_mel,
(pre_encode_size - cache_mel.shape[-1], 0),
)
mel_chunk = torch.cat([cache_mel, main_chunk], dim=-1)
return mel_chunk, int(streaming_cfg.drop_extra_pre_encoded)
def validate_timeline_fits_max_model_len(
logical_prompt_token_len: int,
acoustic_frame_count: int,
@@ -210,6 +258,14 @@ class NemotronVoiceChatThinkerForConditionalGeneration(nn.Module, HasInnerState,
from omegaconf import DictConfig
self.perception = AudioPerceptionModule(DictConfig(perception_cfg))
# NeMo's streaming wrapper deliberately uses a second, deterministic
# preprocessor. The training/offline frontend has dither enabled and
# pads the mel time axis; both are incompatible with exact incremental
# chunk boundaries.
streaming_preprocessor_cfg = copy.deepcopy(self.perception.cfg.preprocessor)
streaming_preprocessor_cfg.dither = 0.0
streaming_preprocessor_cfg.pad_to = 0
self._streaming_preprocessor = self.perception.from_config_dict(streaming_preprocessor_cfg)
# AddFusion weights (the checkpoint uses fuse_method="add" defaults;
# the same duplex_*_weight keys double as the fusion weights).
@@ -235,6 +291,8 @@ class NemotronVoiceChatThinkerForConditionalGeneration(nn.Module, HasInnerState,
# Omni AR runner contract.
self.have_multimodal_outputs = True
self.has_preprocess = True
self.prefer_model_sampler = True
self.omni_client_multimodal_output_keys = ("nvc_function_token",)
self.has_postprocess = True # per-step greedy function-channel token
self.requires_full_prefix_cached_hidden_states = False
# The per-step function token is a scalar; no GPU-resident buffers needed.
@@ -251,6 +309,8 @@ class NemotronVoiceChatThinkerForConditionalGeneration(nn.Module, HasInnerState,
# request_id -> per-request state (audio frames, prefill embeds, counters).
self._sessions: dict[str, dict[str, Any]] = {}
self._duplex_sampling_rows: dict[int, str] = {}
self._duplex_previous_text_tokens: dict[str, int] = {}
# ------------------------------------------------------------------
# Special token ids (from the checkpoint's token strings).
@@ -306,7 +366,57 @@ class NemotronVoiceChatThinkerForConditionalGeneration(nn.Module, HasInnerState,
def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput, **kwargs: Any) -> OmniOutput:
if isinstance(model_outputs, OmniOutput):
return model_outputs
return OmniOutput(text_hidden_states=model_outputs, multimodal_outputs={})
multimodal_outputs: dict[str, torch.Tensor] = {}
if self._use_function_head and model_outputs.numel():
with torch.inference_mode():
function_token = self.function_head(model_outputs[-1:, :].to(self._dtype)).argmax(dim=-1)
info_dicts = kwargs.get("model_intermediate_buffer") or kwargs.get("runtime_additional_information")
info = info_dicts[0] if isinstance(info_dicts, list) and len(info_dicts) == 1 else None
request_id = info.get("request_id") if isinstance(info, dict) else None
session = self._sessions.get(request_id) if isinstance(request_id, str) else None
forced_token = session.get("forced_function_token") if isinstance(session, dict) else None
if isinstance(forced_token, int):
function_token = torch.full_like(function_token, forced_token)
multimodal_outputs["nvc_function_token"] = function_token
return OmniOutput(
text_hidden_states=model_outputs,
multimodal_outputs=multimodal_outputs,
)
def prepare_duplex_sampling(
self,
logits: torch.Tensor,
sampling_metadata: SamplingMetadata,
rows: tuple[DuplexSamplingRow, ...],
) -> None:
del logits, sampling_metadata
self._duplex_sampling_rows = {row.row_idx: row.request_id for row in rows}
def sample(
self,
logits: torch.Tensor,
sampling_metadata: SamplingMetadata,
) -> SamplerOutput | None:
del sampling_metadata
rows = self._duplex_sampling_rows
if not rows:
return None
expected_rows = set(range(int(logits.shape[0])))
if set(rows) != expected_rows:
raise RuntimeError(
"NemotronVoiceChat native-duplex sampling requires every active batch row "
"to be a duplex request; mixed duplex/non-duplex batches are unsupported."
)
sampled = logits.argmax(dim=-1).to(dtype=torch.int32)
for row_idx, request_id in rows.items():
session = self._sessions.get(request_id)
if isinstance(session, dict) and isinstance(session.get("forced_function_token"), int):
sampled[row_idx] = int(session["nvc_text_pad_id"])
self._duplex_previous_text_tokens[request_id] = int(sampled[row_idx].item())
return SamplerOutput(
sampled_token_ids=sampled.unsqueeze(-1),
logprobs_tensors=None,
)
# ------------------------------------------------------------------
# Fusion helpers.
@@ -324,6 +434,263 @@ class NemotronVoiceChatThinkerForConditionalGeneration(nn.Module, HasInnerState,
fused = fused + self.embed_tokens(func_ids) * self._w_func
return fused
@staticmethod
def _duplex_info(info: dict[str, Any]) -> dict[str, Any] | None:
duplex = info.get("duplex")
if isinstance(duplex, dict) and duplex.get("data_plane") is True:
return duplex
return None
def _duplex_stable_frame(
self,
session: dict[str, Any],
duplex: dict[str, Any],
device: torch.device,
) -> torch.Tensor:
try:
source_input_seq = int(duplex["source_input_seq"])
except (KeyError, TypeError, ValueError) as exc:
raise ValueError("Nemotron VoiceChat duplex input lacks source_input_seq") from exc
if source_input_seq == session.get("last_input_seq"):
return session["duplex_frame"]
last_input_seq = int(session.get("last_input_seq", 0))
if source_input_seq < last_input_seq:
raise ValueError(
f"Nemotron VoiceChat duplex input sequence moved backwards: {source_input_seq} < {last_input_seq}"
)
from vllm_omni.experimental.fullduplex.nemotron_voicechat.input import (
decode_pcm_f32le,
)
raw = decode_pcm_f32le(duplex.get("payload"), exact_frame=True)
frame_audio = torch.from_numpy(np.frombuffer(raw, dtype="<f4").copy()).to(device=device, dtype=torch.float32)
audio = session.get("duplex_audio")
if isinstance(audio, torch.Tensor):
if source_input_seq != last_input_seq + 1:
raise ValueError(
"Nemotron VoiceChat cache-aware perception requires contiguous "
f"input frames; got sequence {source_input_seq} after {last_input_seq}"
)
audio = torch.cat([audio, frame_audio])
else:
if source_input_seq != 1:
raise ValueError(
f"Nemotron VoiceChat cache-aware perception must start at input sequence 1, got {source_input_seq}"
)
audio = frame_audio
encoder = self.perception.encoder
streaming_cfg = encoder.streaming_cfg
with torch.inference_mode():
processed_signal, _ = self._streaming_preprocessor(
input_signal=audio.unsqueeze(0),
length=torch.tensor([audio.numel()], device=device, dtype=torch.long),
)
frame_idx = source_input_seq - 1
mel_chunk, drop_extra_pre_encoded = slice_perception_streaming_mel(
processed_signal,
frame_idx,
streaming_cfg,
)
if frame_idx == 0:
caches = encoder.get_initial_cache_state(
batch_size=1,
dtype=next(encoder.parameters()).dtype,
device=device,
)
else:
caches = (
session.get("perception_cache_last_channel"),
session.get("perception_cache_last_time"),
session.get("perception_cache_last_channel_len"),
)
if any(cache is None for cache in caches):
raise RuntimeError("Nemotron VoiceChat perception cache is missing")
encoder_dtype = next(encoder.parameters()).dtype
encoded, encoded_len, cache_channel, cache_time, cache_channel_len = encoder.cache_aware_stream_step(
processed_signal=mel_chunk.to(encoder_dtype),
processed_signal_length=torch.tensor(
[mel_chunk.shape[-1]],
device=device,
dtype=torch.long,
),
cache_last_channel=caches[0],
cache_last_time=caches[1],
cache_last_channel_len=caches[2],
keep_all_outputs=True,
drop_extra_pre_encoded=drop_extra_pre_encoded,
)
encoded, encoded_len = self.perception.modality_adapter(
audio_signal=encoded,
length=encoded_len,
)
stable = self.perception.proj(encoded.transpose(1, 2))
frame_count = int(encoded_len.reshape(-1)[0])
if frame_count != 1 or stable.shape[1] != 1:
raise RuntimeError(
"Nemotron VoiceChat cache-aware perception must produce exactly "
f"one frame per 80 ms input; got encoded_len={frame_count}, "
f"shape={tuple(stable.shape)}"
)
stable = stable[0].to(self._dtype)
session["duplex_audio"] = audio
session["perception_cache_last_channel"] = cache_channel
session["perception_cache_last_time"] = cache_time
session["perception_cache_last_channel_len"] = cache_channel_len
session["duplex_frame"] = stable
session["last_input_seq"] = source_input_seq
return stable
def _preprocess_duplex(
self,
*,
request_id: str,
input_ids: torch.Tensor,
info: dict[str, Any],
duplex: dict[str, Any],
) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]:
device = input_ids.device
runtime_config = duplex.get("runtime_config")
if not isinstance(runtime_config, dict):
raise ValueError("Nemotron VoiceChat duplex input lacks runtime_config")
try:
source_input_seq = int(duplex["source_input_seq"])
pad_id = int(runtime_config["nvc_text_pad_id"])
except (KeyError, TypeError, ValueError) as exc:
raise ValueError("Nemotron VoiceChat duplex runtime token metadata is incomplete") from exc
session = self._sessions.get(request_id)
if session is None:
prompt_ids_raw = runtime_config.get("nvc_prompt_token_ids")
if not isinstance(prompt_ids_raw, list | tuple) or not prompt_ids_raw:
raise ValueError("Nemotron VoiceChat duplex runtime requires nvc_prompt_token_ids")
prompt_ids = torch.as_tensor(
[int(token_id) for token_id in prompt_ids_raw],
dtype=torch.long,
device=device,
)
session = {
"prompt_len": int(prompt_ids.numel()),
"func_token": pad_id,
"last_input_seq": 0,
"nvc_text_pad_id": pad_id,
"function_response_generation": 0,
"forced_function_tokens": [],
}
self._sessions[request_id] = session
frame = self._duplex_stable_frame(session, duplex, device)
timeline = torch.cat(
[self.embed_tokens(prompt_ids).to(self._dtype), frame],
dim=0,
)
pad_ids = torch.full(
(timeline.shape[0],),
pad_id,
dtype=torch.long,
device=device,
)
session["prefill_embeds"] = self._fuse(pad_ids, timeline, pad_ids)
else:
frame = self._duplex_stable_frame(session, duplex, device)
self._sync_forced_function_response(session, runtime_config)
if source_input_seq <= 1:
offset = max(0, int(info.get("_omni_num_computed_tokens", 0) or 0))
prefill = session["prefill_embeds"]
span = int(input_ids.shape[0])
if offset + span > int(prefill.shape[0]):
raise ValueError("Nemotron VoiceChat duplex first append exceeds its fused prompt")
return (
input_ids,
prefill[offset : offset + span].to(self._dtype),
{
"nvc_text_pad_id": pad_id,
},
)
if int(input_ids.shape[0]) != 1:
raise ValueError("Nemotron VoiceChat duplex continuation must schedule exactly one frame token")
previous_function = info.get("nvc_prev_function_token")
if previous_function is not None:
if isinstance(previous_function, torch.Tensor):
previous_function = previous_function.reshape(-1)[-1].item()
session["func_token"] = int(previous_function)
previous_text = self._duplex_previous_text_tokens.get(request_id)
if previous_text is None:
raise RuntimeError("Nemotron VoiceChat duplex continuation has no previous sampled text token")
text_id = torch.tensor([previous_text], dtype=torch.long, device=device)
function_id = torch.tensor(
[int(session["func_token"])],
dtype=torch.long,
device=device,
)
fused = self._fuse(text_id, frame, function_id)
return (
input_ids,
fused,
{
"meta": {"nvc_frame": source_input_seq - 1},
"nvc_text_pad_id": pad_id,
},
)
@staticmethod
def _sync_forced_function_response(
session: dict[str, Any],
runtime_config: dict[str, Any],
) -> None:
try:
generation = int(runtime_config.get("nvc_function_response_generation", 0))
except (TypeError, ValueError) as exc:
raise ValueError("Nemotron VoiceChat function-response generation must be an integer") from exc
seen = int(session.get("function_response_generation", 0))
if generation < seen:
raise ValueError("Nemotron VoiceChat function-response generation moved backwards")
if generation > seen:
raw_batches = runtime_config.get("nvc_function_response_batches")
batches = raw_batches if isinstance(raw_batches, list) else []
unseen: list[tuple[int, list[int]]] = []
for raw_batch in batches:
if not isinstance(raw_batch, dict):
raise ValueError("Nemotron VoiceChat function response batch must be an object")
try:
batch_generation = int(raw_batch["generation"])
except (KeyError, TypeError, ValueError) as exc:
raise ValueError("Nemotron VoiceChat function response batch requires a generation") from exc
if batch_generation <= seen:
continue
raw_tokens = raw_batch.get("token_ids")
if not isinstance(raw_tokens, list | tuple) or not raw_tokens:
raise ValueError("Nemotron VoiceChat function response requires token ids")
unseen.append((batch_generation, [int(token_id) for token_id in raw_tokens]))
if not unseen:
# Backward-compatible single-response payload used by older
# serving adapters and serialized test fixtures.
raw_tokens = runtime_config.get("nvc_function_response_token_ids")
if not isinstance(raw_tokens, list | tuple) or not raw_tokens:
raise ValueError("Nemotron VoiceChat function response requires token ids")
unseen = [(generation, [int(token_id) for token_id in raw_tokens])]
expected = seen + 1
queue = session.setdefault("forced_function_tokens", [])
if not isinstance(queue, list):
raise ValueError("Nemotron VoiceChat forced function-token queue is invalid")
for batch_generation, token_ids in unseen:
if batch_generation != expected:
raise ValueError("Nemotron VoiceChat function-response generations are not contiguous")
queue.extend(token_ids)
expected += 1
if expected - 1 != generation:
raise ValueError("Nemotron VoiceChat function-response batches are incomplete")
session["function_response_generation"] = generation
queue = session.get("forced_function_tokens")
if session.get("forced_function_token") is None and isinstance(queue, list) and queue:
session["forced_function_token"] = int(queue[0])
# ------------------------------------------------------------------
# Per-request session.
# ------------------------------------------------------------------
@@ -418,6 +785,14 @@ class NemotronVoiceChatThinkerForConditionalGeneration(nn.Module, HasInnerState,
is_prefill = bool(is_prefill_raw) if isinstance(is_prefill_raw, bool) else span > 1
request_id = require_request_id(info, "thinker")
duplex = self._duplex_info(info)
if duplex is not None:
return self._preprocess_duplex(
request_id=request_id,
input_ids=input_ids,
info=info,
duplex=duplex,
)
if is_prefill or request_id not in self._sessions:
session = self._sessions.get(request_id) or self._init_session(request_id, info, device)
offset = max(0, int(info.get("_omni_num_computed_tokens", 0) or 0))
@@ -501,9 +876,25 @@ class NemotronVoiceChatThinkerForConditionalGeneration(nn.Module, HasInnerState,
prompt_len = int(kwargs.get("_omni_prompt_len", 0) or 0)
if prompt_len and computed + int(hidden_states.shape[0]) < prompt_len:
return {} # intermediate prefill chunk: frame 0 not reached yet
with torch.inference_mode():
func_logits = self.function_head(hidden_states[-1, :].reshape(1, -1).to(self._dtype))
token = func_logits.argmax(dim=-1).reshape(()).detach()
multimodal_outputs = kwargs.get("multimodal_outputs")
token = multimodal_outputs.get("nvc_function_token") if isinstance(multimodal_outputs, dict) else None
if not isinstance(token, torch.Tensor) or token.numel() == 0:
with torch.inference_mode():
func_logits = self.function_head(hidden_states[-1, :].reshape(1, -1).to(self._dtype))
token = func_logits.argmax(dim=-1)
token = token.reshape(-1)[-1].reshape(()).detach()
request_id = kwargs.get("request_id")
if isinstance(request_id, str):
session = self._sessions.get(request_id)
if session is not None:
# StreamingUpdate replaces the runner payload at each append;
# retain the function channel in model-owned session state.
session["func_token"] = int(token.item())
forced = session.pop("forced_function_token", None)
if isinstance(forced, int):
queue = session.get("forced_function_tokens")
if not isinstance(queue, list) or not queue or queue.pop(0) != forced:
raise RuntimeError("Nemotron VoiceChat forced function-token queue lost alignment")
update: dict[str, Any] = {"nvc_prev_function_token": token}
if os.environ.get("NEMOTRON_VOICECHAT_DEBUG_FUNCTION_TIMELINE", "0") == "1":
existing = kwargs.get("nvc_function_tokens")
@@ -516,6 +907,7 @@ class NemotronVoiceChatThinkerForConditionalGeneration(nn.Module, HasInnerState,
def on_requests_finished(self, finished_req_ids: set[str] | list[str]) -> None:
for request_id in finished_req_ids:
self._sessions.pop(str(request_id), None)
self._duplex_previous_text_tokens.pop(str(request_id), None)
# ------------------------------------------------------------------
# Weights.
@@ -581,6 +973,9 @@ class NemotronVoiceChatThinkerForConditionalGeneration(nn.Module, HasInnerState,
)
device = self.vllm_config.device_config.device
self.perception.to(device=device, dtype=self._dtype).eval()
# Keep the deterministic mel frontend in fp32, matching NeMo's
# PerceptionCacheManager. Its output is cast only at the encoder edge.
self._streaming_preprocessor.to(device=device, dtype=torch.float32).eval()
self.embed_tokens.to(device=device, dtype=self._dtype)
self.function_head.to(device=device, dtype=self._dtype)
loaded |= {f"perception.{n}" for n in perception_state}
@@ -31,6 +31,13 @@ _PROC = "vllm_omni.model_executor.stage_input_processors.nemotron_voicechat"
NEMOTRON_VOICECHAT_PIPELINE = PipelineConfig(
model_type="nemotron_voicechat",
model_arch="NemotronVoiceChatThinkerForConditionalGeneration",
duplex_runtime_extension=(
"vllm_omni.experimental.fullduplex.nemotron_voicechat.runtime.NemotronVoiceChatDuplexRuntimeExtension"
),
duplex_serving_adapter=(
"vllm_omni.experimental.fullduplex.nemotron_voicechat.serving_adapter.NemotronVoiceChatServingRuntimeAdapter"
),
duplex_control_enabled=True,
# Named after the alias key so the deploy-yaml stem substring-matches the
# checkpoint directory name (NVIDIA-NemotronLabs-VoiceChat-11B) during
# bare-path auto-detection.
@@ -6,6 +6,27 @@ from __future__ import annotations
from typing import Any
import torch
def scalar_bool(value: object) -> bool:
"""Normalize a Python or tensor scalar to bool.
Inter-stage scalar metadata is tensorized while it rides the engine output
channel, so model consumers must not rely on object identity with ``True``.
Empty tensors mean "unset"; non-scalar tensors are rejected instead of
silently choosing an element.
"""
if isinstance(value, torch.Tensor):
if value.numel() == 0:
return False
if value.numel() != 1:
raise ValueError(f"Expected scalar boolean metadata, got tensor shape {tuple(value.shape)}.")
value = value.item()
elif hasattr(value, "item"):
value = value.item()
return bool(value)
def merge_runtime_info(info_dict: dict[str, Any]) -> dict[str, Any]:
"""Flatten ``additional_information`` into the info dict (top-level wins)."""
@@ -11,9 +11,10 @@ loop), so the prompt slots are reconstructed here, and the timeline metadata
rides ``additional_information``.
talker (1) -> code2wav (2): PersonaPlex-style full payload. The talker
accumulates one 31-quantizer code stack per frame under ``("codes","audio")``;
the producer ships the prompt-trimmed ``[frames, 31]`` stack once the request
finishes, and the sync placeholder builder sizes stage-2's dummy prompt.
accumulates one 31-quantizer code stack per frame under ``("codes","audio")``.
The synchronous producer ships the prompt-trimmed ``[frames, 31]`` stack once
the request finishes. Native duplex wakes expose that same cumulative stack,
so the streaming producer must delta it before feeding code2wav's causal cache.
"""
from __future__ import annotations
@@ -28,6 +29,7 @@ from vllm_omni.data_entry_keys import (
MetaStruct,
OmniPayloadStruct,
)
from vllm_omni.model_executor.models.nemotron_voicechat.runtime_info import scalar_bool
# Full-sequence tensors are re-shipped whole; the full-payload accumulator must
# replace (not concatenate) them if the producer fires more than once. The AR
@@ -39,6 +41,7 @@ _FULL_PAYLOAD_REPLACE_KEYS: frozenset[str] = frozenset({"codes", "codes.audio",
# stt pad_token. The thinker also reports it in its latent metadata, which
# takes precedence when present.
_DEFAULT_TEXT_PAD_ID = 12
_STEP_TOKEN_SNAPSHOT_MISSING = object()
def _info_get(info: Any, key: str) -> Any:
@@ -51,6 +54,27 @@ def _info_get(info: Any, key: str) -> Any:
return None
def _request_info(request: Any) -> dict[str, Any]:
"""Return the live runner payload, accepting both transport generations."""
for name in (
"model_intermediate_buffer",
"additional_information",
"additional_information_cpu",
):
info = getattr(request, name, None)
if isinstance(info, dict):
return info
if info is not None and hasattr(info, "entries"):
from vllm_omni.engine.serialization import (
deserialize_additional_information,
)
decoded = deserialize_additional_information(info)
if isinstance(decoded, dict):
return decoded
return {}
def thinker2talker_token_only(
source_outputs: list,
prompt: Any = None,
@@ -118,32 +142,60 @@ def thinker2talker_async_chunk(
the payload self-contained. ``meta.num_processed_tokens`` carries the
logical prompt length for the code2wav producer's prompt-region trim.
"""
del multimodal_output, kwargs
step_snapshot = kwargs.pop("new_token_ids", _STEP_TOKEN_SNAPSHOT_MISSING)
has_step_snapshot = step_snapshot is not _STEP_TOKEN_SNAPSHOT_MISSING
generated_delta = [int(token_id) for token_id in ((step_snapshot or ()) if has_step_snapshot else ())]
request_id = request.external_req_id
request_info = _request_info(request)
duplex_info = request_info.get("duplex") if isinstance(request_info, dict) else None
is_duplex = isinstance(duplex_info, dict) and duplex_info.get("data_plane") is True
state = transfer_manager.request_payload.get(request_id)
generated_now = [int(token_id) for token_id in (getattr(request, "output_token_ids", None) or [])]
prompt_ids = list(getattr(request, "prompt_token_ids", None) or [])
# vLLM prompt = logical prompt + 1 placeholder (acoustic frame 0).
logical_prompt_len = max(len(prompt_ids) - 1, 0)
generated = list(getattr(request, "output_token_ids", None) or [])
if is_duplex:
runtime_config = duplex_info.get("runtime_config")
runtime_prompt = runtime_config.get("nvc_prompt_token_ids") if isinstance(runtime_config, dict) else None
if not isinstance(runtime_prompt, list | tuple) or not runtime_prompt:
raise ValueError("Nemotron VoiceChat duplex stage transfer lacks nvc_prompt_token_ids")
logical_prompt_len = len(runtime_prompt)
previous = list(state.get("nvc_duplex_text_tokens", ())) if isinstance(state, dict) else []
if has_step_snapshot:
generated = previous + generated_delta
elif generated_now[: len(previous)] == previous and len(generated_now) > len(previous):
generated = generated_now
elif generated_now:
generated = previous + [generated_now[-1]]
else:
generated = previous
else:
# vLLM prompt = logical prompt + 1 placeholder (acoustic frame 0).
logical_prompt_len = max(len(prompt_ids) - 1, 0)
generated = generated_now
pad_id = _DEFAULT_TEXT_PAD_ID
reported_pad = _info_get(getattr(request, "additional_information", None), "nvc_text_pad_id")
reported_pad = _info_get(request_info, "nvc_text_pad_id")
if reported_pad is not None:
pad_id = int(reported_pad)
timeline = [pad_id] * logical_prompt_len + [int(t) for t in generated]
timeline = [pad_id] * logical_prompt_len + generated
# Skip no-progress wakeups (same length as the last emitted chunk).
state = transfer_manager.request_payload.get(request_id)
last_len = int(state.get("nvc_timeline_len", -1)) if isinstance(state, dict) else -1
if len(timeline) <= last_len and not is_finished:
return None
transfer_manager.request_payload[request_id] = {"nvc_timeline_len": len(timeline)}
transfer_manager.request_payload[request_id] = {
"nvc_timeline_len": len(timeline),
"nvc_duplex_text_tokens": generated if is_duplex else (),
}
return OmniPayloadStruct(
ids=IdsStruct(all=timeline),
ids=IdsStruct(all=timeline, prompt=[0]),
meta=MetaStruct(
finished=torch.tensor(bool(is_finished), dtype=torch.bool),
finished=torch.tensor(bool(is_finished and not is_duplex), dtype=torch.bool),
num_processed_tokens=logical_prompt_len,
# The talker's vLLM prompt stays a single placeholder token.
next_stage_prompt_len=1,
codec_streaming=is_duplex,
request_id=request_id if is_duplex else None,
),
)
@@ -155,18 +207,30 @@ def talker2code2wav_async_chunk(
is_finished: bool = False,
**kwargs: Any,
) -> OmniPayloadStruct | None:
"""Streaming producer: ship prompt-trimmed CUMULATIVE code stacks in chunks.
"""Streaming producer: ship prompt-trimmed code stacks in chunks.
Follows the NeMo incremental-decode recipe (``decode_one_audio_step``):
each chunk carries the FULL trimmed code history and
``meta.left_context_size`` = frames the codec already emitted, so the
code2wav stage re-decodes the whole prefix and slices off only the new
samples — no seams, no crossfade, and the concatenated stream equals the
prefix-decode of the final stack. Chunk cadence comes from the connector
config key ``codec_chunk_frames`` (default 13 frames ~= 1 s of audio).
The regular path follows NeMo's prefix-decode recipe: each chunk carries
the full trimmed history and ``meta.left_context_size`` identifies frames
already emitted. Native duplex talker wakes also carry cumulative history,
but code2wav keeps causal codec state for that path and therefore must see
only rows not observed on an earlier wake. Chunk cadence comes from
``codec_chunk_frames`` (default 13 frames ~= 1 s of audio).
"""
del kwargs
request_id = request.external_req_id
del kwargs
request_info = _request_info(request)
codec_streaming = scalar_bool(_info_get(request_info, "codec_streaming")) or scalar_bool(
_info_get(multimodal_output, "codec_streaming")
)
state = transfer_manager.request_payload.get(request_id)
if isinstance(state, dict):
codec_streaming = codec_streaming or bool(state.get("nvc_codec_stream_started", False))
# ``is_finished`` describes the current resumable Stage-1 scheduler
# segment, not the lifetime of a native-duplex codec stream. Marking each
# segment terminal makes the connector clean sender state after its first
# chunk; every later one-row wake is then mistaken for a fresh stream and
# trimmed as prompt context. Duplex lifetime is closed by session abort.
stream_finished = bool(is_finished and not codec_streaming)
codes = None
if isinstance(multimodal_output, dict):
@@ -175,9 +239,19 @@ def talker2code2wav_async_chunk(
if codes is None:
codes = multimodal_output.get("codes.audio")
if not isinstance(codes, torch.Tensor) or codes.numel() == 0:
if not is_finished:
if not stream_finished:
# Returning ``None`` at a resumable segment boundary makes the
# generic chunk adapter synthesize ``is_segment_finished=True``.
# Stage 2 then stops polling after the first 80 ms wake. An
# explicit non-terminal empty payload consumes this connector
# sequence number without closing the long-lived codec stream.
if codec_streaming:
return _empty_streaming_payload(request_id=request_id)
return None
return _empty_finished_payload()
return _empty_finished_payload(
codec_streaming=codec_streaming,
request_id=request_id,
)
if codes.ndim == 1:
codes = codes.reshape(1, -1)
@@ -186,15 +260,13 @@ def talker2code2wav_async_chunk(
# request-level additional_information is overwritten both by arriving
# thinker chunks and by talker info updates, so it races. Cache the first
# sighting in the transfer-manager state as a fallback.
state = transfer_manager.request_payload.get(request_id)
prompt_len = _info_get(multimodal_output if isinstance(multimodal_output, dict) else None, "nvc_logical_prompt_len")
if prompt_len is None and isinstance(multimodal_output, dict):
prompt_len = multimodal_output.get("meta.nvc_logical_prompt_len")
if prompt_len is None:
info = getattr(request, "additional_information", None)
prompt_len = _info_get(info, "nvc_logical_prompt_len")
prompt_len = _info_get(request_info, "nvc_logical_prompt_len")
if prompt_len is None:
prompt_len = _info_get(info, "num_processed_tokens")
prompt_len = _info_get(request_info, "num_processed_tokens")
if prompt_len is None and isinstance(state, dict):
prompt_len = state.get("nvc_prompt_len")
if prompt_len is None:
@@ -203,41 +275,111 @@ def talker2code2wav_async_chunk(
"('nvc_logical_prompt_len' or async meta.num_processed_tokens); cannot "
"trim the prompt-region codes for streaming code2wav."
)
trimmed = codes[max(int(prompt_len) - 1, 0) :]
frames_sent = int(state.get("nvc_frames_sent", 0)) if isinstance(state, dict) else 0
new_frames = int(trimmed.shape[0]) - frames_sent
if new_frames <= 0 and not is_finished:
if codec_streaming:
# The AR runner exposes its cumulative model_intermediate_buffer on
# every wake. Feeding that whole prefix into a stateful causal codec
# replays history into its cache (live traces showed lengths
# 19,20,20,21,21,...) and corrupts the waveform. Track rows OBSERVED,
# separately from rows EMITTED at the chunk cadence, and ship only the
# suffix first seen on this wake.
full_trimmed = codes[max(int(prompt_len) - 1, 0) :].detach().to(dtype=torch.long, device="cpu")
frames_seen = int(state.get("nvc_frames_seen", 0)) if isinstance(state, dict) else 0
if int(full_trimmed.shape[0]) < frames_seen:
raise ValueError(
"NemotronVoiceChat duplex talker code history shrank from "
f"{frames_seen} to {int(full_trimmed.shape[0])} frames; cannot "
"safely continue the stateful code2wav stream."
)
trimmed = full_trimmed[frames_seen:]
frames_seen = int(full_trimmed.shape[0])
pending = state.get("nvc_pending_codes") if isinstance(state, dict) else None
if isinstance(pending, torch.Tensor) and pending.numel() > 0:
trimmed = torch.cat([pending.reshape(-1, codes.shape[-1]), trimmed], dim=0)
new_frames = int(trimmed.shape[0])
else:
trimmed = codes[max(int(prompt_len) - 1, 0) :]
new_frames = int(trimmed.shape[0]) - frames_sent
if new_frames <= 0 and not stream_finished:
if codec_streaming:
transfer_manager.request_payload[request_id] = {
"nvc_frames_sent": frames_sent,
"nvc_frames_seen": frames_seen,
"nvc_prompt_len": int(prompt_len),
"nvc_codec_stream_started": True,
"nvc_pending_codes": trimmed,
}
return _empty_streaming_payload(request_id=request_id)
return None
connector = getattr(transfer_manager, "connector", None)
raw_cfg = getattr(connector, "config", {}) or {}
cfg = raw_cfg.get("extra", raw_cfg) if isinstance(raw_cfg, dict) else {}
chunk_frames = int(cfg.get("codec_chunk_frames", 13))
if new_frames < chunk_frames and not is_finished:
if new_frames < chunk_frames and not stream_finished:
if codec_streaming:
transfer_manager.request_payload[request_id] = {
"nvc_frames_sent": frames_sent,
"nvc_frames_seen": frames_seen,
"nvc_prompt_len": int(prompt_len),
"nvc_codec_stream_started": True,
"nvc_pending_codes": trimmed,
}
return _empty_streaming_payload(request_id=request_id)
return None
if new_frames <= 0 and is_finished and frames_sent > 0:
if new_frames <= 0 and stream_finished and frames_sent > 0:
# Everything already shipped; emit a terminal empty chunk so the
# code2wav request observes meta.finished.
return _empty_finished_payload()
return _empty_finished_payload(
codec_streaming=codec_streaming,
request_id=request_id,
)
transfer_manager.request_payload[request_id] = {
"nvc_frames_sent": int(trimmed.shape[0]),
"nvc_frames_sent": frames_sent + new_frames if codec_streaming else int(trimmed.shape[0]),
**({"nvc_frames_seen": frames_seen} if codec_streaming else {}),
"nvc_prompt_len": int(prompt_len),
"nvc_codec_stream_started": codec_streaming,
}
code_payload = trimmed
return OmniPayloadStruct(
codes=CodesStruct(audio=trimmed.detach().to(dtype=torch.long, device="cpu")),
codes=CodesStruct(audio=code_payload.detach().to(dtype=torch.long, device="cpu")),
meta=MetaStruct(
left_context_size=frames_sent,
finished=torch.tensor(bool(is_finished), dtype=torch.bool),
left_context_size=0 if codec_streaming else frames_sent,
codec_streaming=codec_streaming,
request_id=request_id if codec_streaming else None,
finished=torch.tensor(stream_finished, dtype=torch.bool),
# Native duplex scheduler segments are transport wakes, not codec
# stream boundaries. Preserve polling across successive wakes.
is_segment_finished=(torch.tensor(False, dtype=torch.bool) if codec_streaming else None),
),
)
def _empty_finished_payload() -> OmniPayloadStruct:
def _empty_streaming_payload(*, request_id: str) -> OmniPayloadStruct:
return OmniPayloadStruct(
codes=CodesStruct(audio=torch.empty(0, dtype=torch.long)),
meta=MetaStruct(finished=torch.tensor(True, dtype=torch.bool)),
meta=MetaStruct(
codec_streaming=True,
request_id=request_id,
finished=torch.tensor(False, dtype=torch.bool),
is_segment_finished=torch.tensor(False, dtype=torch.bool),
),
)
def _empty_finished_payload(
*,
codec_streaming: bool = False,
request_id: str | None = None,
) -> OmniPayloadStruct:
return OmniPayloadStruct(
codes=CodesStruct(audio=torch.empty(0, dtype=torch.long)),
meta=MetaStruct(
finished=torch.tensor(True, dtype=torch.bool),
codec_streaming=codec_streaming,
request_id=request_id if codec_streaming else None,
),
)
+22 -2
View File
@@ -444,9 +444,11 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin, Duplex
def _resolve_pooler_payload_req_ids(self, req_ids_output_copy: list[str]) -> tuple[str, list[str]]:
downstream_req_ids = [rid for rid in req_ids_output_copy if self._request_needs_downstream_stage_payload(rid)]
engine_output_type = (self.vllm_config.model_config.engine_output_type or "").lower()
if self._client_multimodal_output_keys():
downstream_req_ids = req_ids_output_copy
# Single-stage AR TTS models (e.g. VoxCPM2) finish on this stage but still
# need multimodal payloads for final audio postprocess/output.
if engine_output_type == "audio" and not downstream_req_ids:
elif engine_output_type == "audio" and not downstream_req_ids:
downstream_req_ids = req_ids_output_copy
return engine_output_type, downstream_req_ids
@@ -1555,7 +1557,7 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin, Duplex
lists are wrapped into tensors, and anything that cannot be safely
converted is dropped.
"""
if self.vllm_config.model_config.engine_output_type == "text":
if self.vllm_config.model_config.engine_output_type == "text" and not self._client_multimodal_output_keys():
return None
if per_req_payloads is None:
return None
@@ -1614,6 +1616,18 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin, Duplex
def _runner_model_omni_flag(self, name: str, default: bool = False) -> bool:
return self._model_omni_flag(getattr(self, "model", None), name, default)
def _client_multimodal_output_keys(self) -> tuple[str, ...]:
raw = getattr(
getattr(self, "model", None),
"omni_client_multimodal_output_keys",
(),
)
if not isinstance(raw, tuple) or any(not isinstance(key, str) or not key for key in raw):
raise TypeError("omni_client_multimodal_output_keys must be a tuple of non-empty strings")
if len(raw) != len(set(raw)):
raise ValueError("omni_client_multimodal_output_keys must not contain duplicates")
return raw
def _model_omni_pooler_payload_include_hidden(self) -> bool:
return self._runner_model_omni_flag("omni_pooler_payload_include_hidden", default=True)
@@ -1921,6 +1935,12 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin, Duplex
# Connector-less stages expose the same payload through the
# orchestrator bridge; non-async stages preserve legacy behavior.
pooler_inter, pooler_client = pooler_output, pooler_output
client_output_keys = self._client_multimodal_output_keys()
if client_output_keys:
allowed = frozenset(client_output_keys)
pooler_client = [
{key: value for key, value in payload.items() if key in allowed} for payload in pooler_output
]
if pooler_inter and self._should_accumulate_full_payload_output():
with record_function_or_nullcontext("omni_output_builder:accumulate_full_payload_output"):