Repository navigation
feat(server): /v1/realtime WebSocket for Nemotron VoiceChat sessions - #2041
Merged
Merged
Conversation
Expose the cache-aware VoiceChat streaming session over a WebSocket on mlxcel-server, following the mlx-vlm reference server protocol (session.update, input_audio_buffer.append/commit, cancel, ping; text, transcript, audio and done events; server_busy with close code 1013). A dedicated engine thread loads the checkpoint and owns the active session; the socket task only awaits one-shot replies, pushes appends in 1280-sample slices, and releases the single-session reservation on commit, cancel or disconnect. The chat worker, scheduler and text warmup are skipped for this model type and the chat routes answer 501 naming /v1/realtime. Hyper connections now serve upgrades. Adds fake-engine unit and integration tests, a WAV file client example, and protocol docs. Refs #1376
Replace the transcript placeholder with an excerpt of a real 4-bit session ("What is the capital of France?" plus 3 s of silence, seed 0): event types and fields as they arrived, audio payloads elided, plus the event counts, the server_busy/1013 result for a concurrent connection, and the successful reconnect after the session closed.
Refs #1376
examples/voicechat_microphone.rs captures the input device with cpal, downmixes and resamples to 16 kHz, sends an input_audio_buffer.append every 80 ms, plays response.audio.delta through a bounded ring buffer resampled to the output device rate, prints transcript and answer deltas, and commits on Ctrl-C. --list-devices, --input-device and --output-device select devices. cpal is an optional dependency behind the voicechat-mic feature and the example declares it in required-features, so the library, the server, and the test targets never compile the audio-device crate. The docs note that there is no echo cancellation and headphones are needed. Refs #1376
…t loop The single session slot could be held or exhausted by one client: sessions without max_streaming_seconds grew their caches without limit, messages could be 64 MiB, and a connection that never configured (or only pinged) kept the slot until the transport timeout. Sessions are now capped at 600 s of audio (client values are clamped), messages and frames at 1 MiB, a connection must configure within 30 s, and a configured session with no client message for 120 s is closed with 1008. A task dropped while its open is in flight now still closes the engine session, /health reports the engine thread dying, and whole-number floats are accepted for sample_rate and seed as the reference's int() does. The microphone client appends its buffered tail before commit, quits on a second Ctrl-C or before the session is ready, and stops waiting after an error following commit. Adds an abrupt-disconnect release test and a float sample_rate test. Refs #1376
5 of 8 tasks
inureyes
added a commit
that referenced
this pull request
Sep 29, 2026
#2044) ## Summary Fills the Performance table in `docs/nemotron-voicechat.md`, which PRs #2037, #2039 and #2041 left as TBD because their measurements ran while other units shared the GPU. Epic #1372's definition of done asks for the measured real-time factor for the 4-bit and 8-bit checkpoints. ## Measurement - Machine: Apple M1 Ultra (128 GB), Metal, otherwise idle (no builds, servers or other model runs). - Command: `mlxcel generate -m <ckpt> --audio question.wav --stream --profile --seed 0` on main `38eaaee6`, with the "What is the capital of France?" prompt and 3 s of extra decoding (56 frames, first 5 dropped as cold). - One warm-up run, then three measured runs per checkpoint. | Checkpoint | Real-time factor (3 runs) | Frame time p50 | Frame time p95 | |---|---|---|---| | 4-bit | 1.039, 1.039, 1.037 | 82.7 to 82.8 ms | 83.8 to 84.0 ms | | 8-bit | 1.119, 1.122, 1.119 | 89.3 to 89.4 ms | 90.4 to 91.0 ms | `realtime_factor` is mean per-frame processing time divided by the 80 ms frame, so values above 1.0 mean slower than real time. Neither checkpoint keeps up on this machine. The doc states that plainly and gives the per-stage breakdown (TTS 36 ms is the largest stage). ## Changes - `docs/nemotron-voicechat.md`: replace the TBD rows with the measured values, the method, and the per-stage breakdown. Documentation only.
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Serves the Nemotron VoiceChat online session (#1378) over a WebSocket at
/v1/realtimeonmlxcel-server/mlxcel serve. The protocol uses JSON control messages, base64 PCM16 audio in both directions, and one active session at a time, with all MLX work on one dedicated engine thread. The PR also adds a WAV file client and a live microphone/speaker client.What changed
server::realtime_engine,server::realtime_session): one thread loads the checkpoint and owns the activeVoiceChatStreamingSession. The engine acceptsopen/push/flush/cancel/closecommands over a channel with one-shot replies.try_reserveadmits exactly one connection.closecancels an unflushed session and clears the MLX buffer cache. A panicking session is contained and reported as an error.server::realtime_protocol): client messages and server events as tabulated in the issue and in the mlx-vlm reference server. That coverssession.created/updated,input_audio_buffer.append/commit/committed,session.cancel/response.cancel,session.ping/pong, the text, function, transcript and audio deltas,response.done/cancelled, anderrorwith the four codes. It also includes PCM16 base64 decoding (odd byte counts rejected) and encoding withround(clip(x) * 32767).server::routes::realtime): the WebSocket loop.server_busyand close code 1013.--api-keyand--api-prefixlayers./v1/realtime, and/healthreports ready. The registry lists the family as generate + serve. The feat(nemotron_voicechat): load the VoiceChat checkpoint and run offline duplex inference (FastConformer+RNNT, Nemotron-H dual-head, EAR-TTS, codec) #1374 serve refusal is removed.examples/voicechat_file_client.rsdrives a WAV through the socket.examples/voicechat_microphone.rsis a cpal capture/playback loop. It sends an append every 80 ms, plays the answer through a ring buffer, commits on Ctrl-C, and supports--list-devices,--input-device, and--output-device. cpal is an optional dependency behind thevoicechat-micfeature (examplerequired-features), so the library, the server, and CI never compile it.websocattranscript, the single-session limit, the clients, and a README server entry.Validation
Real checkpoint (4-bit),
mlxcel-server -m ..., file client driving "What is the capital of France?" plus 3 s of silence, seed 0, system prompt "Be concise and answer in one sentence." (run before and again after the review fixes):session.created,session.updated, 6 transcript deltas building "What is the capital of France", 7 text deltas building "The capital of France is Paris.", 61response.audio.deltawith 1764 samples each andframe_index0..60,input_audio_buffer.committed,response.done, close 1000.mlxcel generate --streamfor the same input: 107604 samples each, every sample consistent with one shared float waveform; the integer WAVs differ only by the writers' scaling (the CLI WAV writer uses 32768, the wire format the reference's 32767), 107266 of 107604 identical.server_busyand close 1013; after the first closes, a new connection configures and cancels normally./v1/chat/completionsanswers 501 naming/v1/realtime;/healthis ok.voicechat-micexample),cargo test --lib server::(3268 passed), the registry/detection/voicechat unit tests,tests/realtime_ws.rs(9 passed with a fake engine: the seven issue cases plus abrupt disconnect and whole-float sample rate),cli_help_consistency,dead_doc_pointers,llama_compat_manifest. GitHub CI was not awaited (self-hosted runner down).Not verified: live microphone exchange
The microphone client compiles, enumerates the audio devices (
--list-devices), and uses the same protocol path the file client validated, but a live exchange could not be run here: in this non-interactive agent session, opening an input stream on this Mac blocks or fails (CoreAudio "Unknown property" on the default camera microphone, a hang on the USB microphone), consistent with the process lacking microphone permission. The acceptance item "microphone example holds a live exchange with headphones" therefore stays unchecked and needs a manual run: start the server, thencargo run --release --features voicechat-mic --example voicechat_microphone -- ws://127.0.0.1:8080/v1/realtimefrom a terminal that has microphone access.Changes during review
session.update, and a 120 s idle limit after configuration (pings do not count; close 1008).openis in flight still closes the engine session;/healthreports a dead engine thread; whole-number floats are accepted forsample_rateandseedas the reference'sint()does.--cors-originsis not enforced on the WebSocket upgrade (browsers do not apply CORS to WebSockets); with a restricted origin list and no--api-key, any page can still take the slot. Tracked as a follow-up candidate.Closes #1376