Skip to content

server: add realtime transcription API - #30636

Merged
ngxson merged 9 commits into
masterfrom
xsn/server_realtime_audio
Oct 20, 2026
Merged

ngxson merged 9 commits into
masterfrom
xsn/server_realtime_audio

Conversation

@ngxson

@ngxson ngxson commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Overview

Follow up #30629

Add /v1/realtime websocket API, OAI-compatible

GGUF for testing: https://huggingface.co/ggml-org/Voxtral-Mini-4B-Realtime-2602-GGUF

TODO:

  • add docs
  • impl ws proxy

Requirements

@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Oct 16, 2026
@ngxson
ngxson marked this pull request as ready for review October 16, 2026 12:41
@ngxson
ngxson requested a review from a team as a code owner October 16, 2026 12:41
@ngxson

ngxson commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author

/bot review

@ggml-gh-bot

ggml-gh-bot Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Automated code review

Review of PR #30636 (server: add realtime transcription API). Checklists run: scope/quick-reject gate, security, general, server (tools/server/), and approach/design (new ws transport layer). Scope itself looks fine: single-purpose, follows #30629, docs included, no new deps (httplib ws support is already vendored), model-agnostic gating via mtmd_support_audio_stream. The control_action string-to-enum refactor is a good cleanup.

Blocking (correctness)

(point 1) First-audio race kills live sessions. session.created is sent before the SERVER_TASK_TYPE_TRANSCRIBE_RT task has a slot (tools/server/server-context.cpp:5827 vs :5836). A client that starts appending audio immediately (the normal case for this API) will hit get_slot_by_cmpl_id returning nullptr ("no active completion", :2940) or a slot still in SLOT_STATE_STARTED where audio_stream is null and the stream is only created later in pre_decode (:3681, "not a realtime transcription slot", :2964). Either failure makes the reader thread break (:5895), silently terminating the session. The window widens to unbounded under load while the task sits queued. Suggestion: make audio controls retryable - process_single_task can return false to decline and re-offer the task when the slot exists but the stream is not initialized yet, and/or hold the control_audio on the slot/task until the stream is live, so early appends are not dropped.

(point 2) Waiting realtime slots allow the server to sleep underneath them. The all_idle change (:3348) makes a slot with audio_wait == true count as idle, but the sleeping decision in the queue loop only checks task idleness. With --sleep-idle N, a live realtime session that goes quiet for N seconds triggers handle_sleeping_state(true) -> destroy() (:1071), which does mtmd_free(mctx) without touching slots: the slot's audio_stream and mem now dangle, and the next append gives a use-after-free. Before this PR the invariant was "sleep implies no processing slots" (generating slots keep NEXT_RESPONSE tasks flowing); this PR breaks it. Waiting realtime slots need to be excluded from the sleeping decision.

Will slow the review

(point 3) Batch-capacity abort on the first step. The first slot_audio_step emits the whole prefix at once - n_prefix = 1 + 32 + n_delay rows (tools/mtmd/mtmd.cpp:3108, :3120) - and handle_last_sampled_token adds them unconditionally (:562-577). add_embd returns false when tokens.size() >= n_tokens_alloc (:182), which lands in GGML_ASSERT(add_ok && "batch must be large enough...") (:603) - a process abort, not a graceful skip. With a small --batch, or several realtime sessions whose first steps land in the same round (e.g. --parallel 50 x ~40 rows > 2048-token capacity from batch.init, :1513), the server can be crashed remotely. The prompt path has a graceful "batch is full, skip remaining slots" path; the realtime rows need the same: defer the slot when remaining capacity is below n_rows.

(point 4) Unbounded audio backlog. input_audio_buffer.append accepts frames up to 16 MB and mtmd_audio_stream_push appends to s->buf with no cap (tools/mtmd/mtmd.cpp:3133). A client pushing audio faster than the decode loop consumes it grows server memory without bound - a single connection can OOM the server. Consider a cap on pending samples (error or drop when backlog exceeds a few seconds of audio).

(point 5) No tests. A whole new public websocket API lands with zero test coverage. The e2e suite in tools/server/tests is the natural place for a smoke test (connect, append a small PCM buffer, expect session.created + at least one delta/completed); if the Voxtral RT GGUF is too big for CI, a minimal manual-verification note in the PR would help reviewers.

Nits

(point 6) Auth parity: the ws handler only accepts Authorization: Bearer (:5795), while the middleware it replaces also accepts X-Api-Key and a key without the Bearer prefix. A client configured for one of those forms gets rejected on ws but accepted everywhere else. Also std::transform(..., ::tolower) on plain char is UB for bytes >= 0x80; use the unsigned char lambda form already used in server-tools.cpp:444.

(point 7) Spurious empty completed event: on is_stop the handler always calls send_completed() (:5945), even when a commit already completed the previous item - the client then gets a conversation.item.input_audio_transcription.completed with an empty transcript for the fresh item. Skip it when item_text is empty.

(point 8) pcm.size() / 2 silently drops a trailing odd byte on append (:5891); an error event would be kinder to buggy clients.

(point 9) Session-length limit is undocumented: with ctx shift disabled for multimodal, the session stops with STOP_TYPE_LIMIT once the KV fills (:2050), i.e. after roughly n_ctx x 80 ms of audio. Worth a sentence in tools/server/README.md so clients know to expect termination and reconnect.

Design-wise the approach is reasonable: reusing the control-task path for audio transport, per-connection reader thread with a mutex-guarded ws_conn::send, and the router ws proxy all fit the existing structure. The main things to address before merge are the first-append race and the sleep interaction.

This review was generated automatically by pi coding agent using zai-org/GLM-5.3. It may contain mistakes. Maintainers make the final call.

@ServeurpersoCom
ServeurpersoCom self-requested a review October 17, 2026 13:15
@ngxson

ngxson commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

@ServeurpersoCom let's prioritize reviewing this and maybe merge it today or tmr, wdyt?

@ServeurpersoCom

Copy link
Copy Markdown
Contributor

@ServeurpersoCom let's prioritize reviewing this and maybe merge it today or tmr, wdyt?

Two first thing, Then, I dig deeper by conducting tests and comparisons using my minimal WebSocket protocol, a stripped-down version of the OpenAI standard from my s2s.cpp voice assistant.

  • In router mode with --api-key the websocket is fully unauthenticated (middleware skipped for ws paths, proxy_ws checks nothing, children run keyless) so anyone can autoload any model; httplib already runs pre_routing before the upgrade precisely to allow a 401 at handshake, so dropping ws_paths and the handler auth block fixes it with less code.

  • The audio stream is created in update_slots, but process_new_tasks drains the whole queue first, so on a busy server the first append lands in the same drain as the RT task and fails with "not a realtime transcription slot", the reader then breaks silently and the session hangs; creating the stream at slot launch and sending session.created once the slot is live would cover this and the deferred-task case.

@ServeurpersoCom

ServeurpersoCom commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Ran it in a container with an RTX PRO 6000, using the Voxtral RT Q8_0 GGUF and a small websocket client. The happy path is really nice, clean transcript and way faster than real time. Both points above reproduce: the router served a full transcript to a client with no key, and the first append race hits even on an idle server with a local client that appends right after session.created.

Testing also found a third blocking one: an open realtime session freezes the other slots. A realtime slot waiting for audio is still in GENERATING, so it falls through the prompt loop and grabs slot_batched. Any request on a higher slot id then fails can_batch_with and just sits there for the whole session. Easy to hit: open a session on slot 0, send nothing yet, and a plain /completion on slot 1 never starts.

I'll push commits on the branch fixing these three, so we can keep the merge on track for tonight.

A few OAI compat points too, checked against the current Realtime docs:

  • OAI clients always send 24 kHz PCM, it's the only rate the Realtime API accepts. Right now that gets read at the model rate, so it still transcribes but worse, and on a live mic the latency keeps drifting. Resampling to the model rate on the server side would make any OAI client just work. In s2s.cpp we do exactly that, with a resampler that keeps its state across appends, otherwise you get a little seam on every chunk.
  • input_audio_buffer.committed has the ids swapped. In the spec, item_id is the item made from the audio you just committed, and previous_item_id the one before.
  • completed is sent right at commit time, before the words the flush releases are decoded, so they end up in the next item. Sending it once the flush has drained would match what clients expect.
  • The session object uses the old beta shape. The GA one is type: "transcription" with audio.input.format set to audio/pcm at 24000.

httplib runs the pre-routing handler before the websocket upgrade, so
the API key middleware answers 401 at handshake like any other route.
This covers the router proxy, whose children run keyless, and accepts
the same key forms as HTTP (Bearer, raw key, X-Api-Key).
A realtime slot waiting for audio is GENERATING with nothing to decode,
so it fell through the prompt loop and took slot_batched. A request on
a higher slot then failed can_batch_with on the task type and starved
for the whole session. Realtime slots have no prompt: the loop sets
them up once and always leaves them.
@ngxson

ngxson commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

nice, thanks. let's merged once the CI is ok

@ServeurpersoCom

Copy link
Copy Markdown
Contributor

A few commits are still missing; as soon as I’m done, I’ll add my approval!

The audio stream was created in the prompt loop, which skips a slot
that cannot batch with the one generating, and session.created went out
before the task was posted: early appends failed and the reader quit,
leaving the socket open and the slot held until the HTTP timeout.

The slot now sets up its stream at launch and signals it with the begin
result, session.created waits for it, and a reader that stops closes
the socket so the session ends with it.
@ServeurpersoCom

Copy link
Copy Markdown
Contributor

Pushed three commits fixing the blocking issues: the API key middleware now guards the websocket (401 at handshake, router included), realtime slots stay out of the prompt loop so an open session no longer freezes the other slots, and the session only starts once its slot is live, so early appends work even under load or when the task is queued, and a client that leaves frees its slot right away.

Tested in the same container: nominal, immediate appends on an idle and on a busy server, a queued session with -np 1, a plain completion next to a waiting session, and auth in single and router mode.

What's left is :

OAI compat polish, nothing blocking technically: 24 kHz input with server side resampling, the swapped ids in input_audio_buffer.committed, sending completed once the flush has drained, and the GA session shape

I’m looking at what’s worth commit, and what we’ll leave for a follow-up.

Per the Realtime spec, item_id is the item made from the committed audio
and previous_item_id the one committed before it, null for the first.
committed now comes before the transcript that completes that item, and
both go out with the item switch under one lock, so a delta cannot land
between them.
The session object follows the GA Realtime schema: type transcription,
with the input format, transcription model, turn detection and noise
reduction under audio.input. The rate stays the model sample rate, which
is what the client must send for now.

@ServeurpersoCom ServeurpersoCom left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM! Everything above is fixed and tested in the same container: auth in single and router mode, realtime slots alongside regular requests, early and queued sessions, committed ids, and the GA session shape.

Possible follow-ups, none blocking:

  • 24 kHz input with server side resampling, so any OAI client works without knowing the model rate (separate PR).
  • Send completed once the commit flush has drained, so the words the commit releases stay in the committed item instead of the next one.

@ngxson
ngxson merged commit 1242be6 into master Oct 20, 2026
13 checks passed
auRose94 pushed a commit to auRose94/llama.cpp that referenced this pull request Oct 28, 2026
* server: add realtime transcription API

* drop transcription_sessions

* proxy ws

* add docs

* server: let the API key middleware guard the websocket

httplib runs the pre-routing handler before the websocket upgrade, so
the API key middleware answers 401 at handshake like any other route.
This covers the router proxy, whose children run keyless, and accepts
the same key forms as HTTP (Bearer, raw key, X-Api-Key).

* server: keep realtime slots out of the prompt loop

A realtime slot waiting for audio is GENERATING with nothing to decode,
so it fell through the prompt loop and took slot_batched. A request on
a higher slot then failed can_batch_with on the task type and starved
for the whole session. Realtime slots have no prompt: the loop sets
them up once and always leaves them.

* server: start the realtime session once its slot is live

The audio stream was created in the prompt loop, which skips a slot
that cannot batch with the one generating, and session.created went out
before the task was posted: early appends failed and the reader quit,
leaving the socket open and the slot held until the HTTP timeout.

The slot now sets up its stream at launch and signals it with the begin
result, session.created waits for it, and a reader that stops closes
the socket so the session ends with it.

* server: name the committed item in input_audio_buffer.committed

Per the Realtime spec, item_id is the item made from the committed audio
and previous_item_id the one committed before it, null for the first.
committed now comes before the transcript that completes that item, and
both go out with the item switch under one lock, so a delta cannot land
between them.

* server: use the GA transcription session shape

The session object follows the GA Realtime schema: type transcription,
with the input format, transcription model, turn detection and noise
reduction under audio.input. The rate stays the model sample rate, which
is what the client must send for now.

---------

Co-authored-by: Pascal <admin@serveurperso.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation server

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants