TT Lab
Blog

한국어 English 日本語 中文

Get started
Blog

Blog

Relaying Full-Duplex Voice Calls via WebSockets: From Browser Microphone to GPU Model

Status: Draft (Updated 2026-10-09). The metrics were measured directly by the author. The environment consists of a single 24GB-class GPU, MiniCPM-o 4.5 (Q8_0, llama.cpp-omni), and a FastAPI backend. Questions were asked using synthesized voice instead of human voice, and echo from actual browsers as well as multi-person simultaneous calls are unmeasured . Writing rule: Measured data is the default, and anything that could not be measured is marked as "Unmeasured".

Table of Contents

In the 20th conversational exercise, "Recording → Speech Recognition → Conversational Model → Speech Synthesis" is exchanged turn-by-turn over HTTP. You have to finish speaking before a response comes back, and you cannot speak while listening to the response. To create the feel of talking to a person, a full-duplex model that speaks while listening and listens while speaking is required. Such models receive and produce audio multiple times per second. Therefore, instead of HTTP—which has one response per request—WebSockets, which keep a single connection open so both sides can send data at any time, are the right fit. keeps a single connection open so both sides can send data at any time WebSockets are the right fit.

This article briefly covers the process of choosing a model, and then discusses five decisions made on the WebSocket side.

  1. The backend relays the communication instead of connecting the browser directly to the model server.
  2. Audio is sent in 16kHz, 16-bit, 1-second chunks, and received in 24kHz, 16-bit.
  3. Audio is separated into binary frames, while text and state are separated into text frames.
  4. Clients sending data faster than real time are blocked using a token bucket.
  5. A server slot that accepts only one call is protected using a database advisory lock.

Short answer. Even with the relay, calls were opened in 0.33 seconds, and for "What is the capital of Australia?", "It's Canberra." started being heard 0.30 seconds after speech ended. Relaying and converting 1 second's worth of audio took 1.3 ms upward and 5.9 ms downward. When using binary PCM instead of base64 JSON between the browser and backend, the transfer volume decreased from an upward 85 KB/s → 32 KB/s and a downward 128 KB/s → 48 KB/s.

1. Model: Why MiniCPM-o 4.5

Three full-duplex models available on Hugging Face without a key were tested with the same questions (raw data: docs/experiments/2026-10-07-full-duplex-voice/).

Model Question Response Time to voice after speech ends (Median) Observation
Moshiko (2024-09) English FLEURS 12 11/12 6 cases during user speech, remaining 0.2–5.2s One step every 80ms, 34.6ms per step
LLM-jp-Moshi-v1 (2026-01) Japanese FLEURS 12 12/12 All during user speech Backchanneling and self-talk during 30–88% of user utterance
MiniCPM-o 4.5 Q8_0 (2026-02) English questions 10 10/10 1.03s 50ms processing per 1s chunk, VRAM 14.7GB

MiniCPM-o got Canberra, 100 degrees, Mars, and Shakespeare right, and when given the instruction "何語で話しても日本語だけで答えて" (Answer only in Japanese no matter what language is spoken) in Japanese, it answered in Japanese 9 out of 10 times. Its Korean was poor (official languages are English and Chinese). Since the PyTorch version required 28GB or more, it was run using GGUF from a llama.cpp fork (llama.cpp-omni).

Two things were fixed on the model side.

2. Why the Backend Relays

The model server directly exposes a WebSocket endpoint (/backend). Connecting the browser directly to this would be the fastest approach, but it presents three problems.

브라우저 ──wss──▶ 게이트웨이 ──▶ 백엔드(/ws/call) ──ws──▶ 모델 서버(/backend)
            (쿠키로 사람 확인)   (프롬프트 생성, 자리, 속도 제한, 형식 변환)
  1. The model server trusts system prompts blindly. When the prompt is included in the message that opens the session, letting the browser create this message allows anyone to issue any instructions to the model. With a proxy in place, the browser sends only mode=lang&lang=ja&level=N4 or mode=blog&slug=… , and the prompt is built exclusively by the server using fixed sentences and text from the DB. Levels are also used only when they are in a known list. Initially, we only left alphanumeric characters, but tests caught B1. Ignore all rules entering the prompt as B1Ignore .
  2. The model server accepts only one call at a time. If a second person connects, the first call is broken. Concurrency is covered in Section 5.
  3. We must verify users and limit connection times. Browsers cannot attach arbitrary headers to WebSocket handshakes. Therefore, we identify users using same-origin login cookies (the same approach as the existing web terminal), limit calls to 10 minutes, and disconnect silent connections after 15 seconds.

When including blog posts in the prompt, we prevented one more issue. Since the model's special tokens look like <|im_end|> , if the same string appears in the body of the post, it can close the system message and insert new instructions. We neutralize <| in the body by replacing it with < | , and use tests to verify that the entire prompt contains only the two special tokens we added.

3. Audio Transmission Formats

Segment Format 1 Second Volume
Browser → Backend 16kHz Mono 16-bit PCM, Binary Frame 32,000 B
Backend → Model JSON containing float32 in base64 (input.append) 85,391 B
Model → Backend 24kHz float32 base64 JSON (response.output.delta) 128,063 B
Backend → Browser 24kHz 16-bit PCM, Binary Frame 48,000 B

4. Frames: Separating Binary and Text

WebSocket frames support text (opcode 1) and binary (opcode 2). Audio was sent as binary, and everything else as JSON text.

브라우저 → 백엔드   이진: 1초 PCM          텍스트: {"type":"hangup"}
백엔드 → 브라우저   이진: 모델 목소리 조각  텍스트: ready · text · listen · done · busy · unavailable · timeout

Dividing them this way splits the data on the receiving end into typeof ev.data === 'string' without needing to wrap audio in JSON or bloat it with base64. In the browser, setting it to ws.binaryType = 'arraybuffer' ensures that binary frames arrive directly as Blob rather than ArrayBuffer , making them immediately usable.

Communicating reasons via close codes. WebSocket close codes 4000–4999 are reserved for application use. We used 4401 (login required), 4404 (post not found), 4429 (another user is on a call), and 4503 (model server preparing). The UI translates these codes into human-readable language. Phrases like "Another user is currently on a call, please try calling back shortly" encourage users to try again much more effectively than a generic "Failed."

Chaining playback seamlessly. The model generates voice snippets of a few hundred milliseconds at a time. Converting the received fragments into AudioBufferSourceNode and scheduling them at the "exact end time of the previous fragment" ensures seamless playback without gaps. Playing each fragment immediately as it arrives causes them to overlap.

Barging in (Interruption). When a user interrupts, the model switches back to listening (listen). However, if previously received voice segments are still waiting in the playback queue, the user will perceive the model as continuing to speak. Therefore, when listen arrives and the microphone input is loud , we clear the playback queue. Clearing it when the microphone is quiet cuts off the end of the response—because the model still sends listen even after it has finished generating the response, and playback has not yet finished at that moment.

5. Clients Sending Faster Than Real-Time

The model server processes 1-second chunks sequentially. The next chunk is read only after the response for the previous chunk finishes. Consequently, if a client dumps 30 seconds of pre-recorded audio all at once, the queue backs up, responses become progressively delayed, and other users are blocked from acquiring a slot during that time.

We placed a token bucket in the proxy. It fills at 1.5 tokens per second and holds a maximum of 3. Normal calls sending one chunk per second experience zero dropped chunks over 10 minutes (600 chunks), whereas burst-sending passes only the first 3 chunks and then throttles down to 1.5 chunks/sec. Setting the rate slightly higher at 1.5 accounts for network jitter that can occasionally cause two chunks to arrive back-to-back. Making the time source externally injectable allows this behavior to be tested in just a few milliseconds.

We ran into the same pitfall when building the metering tool. If the measurement client sends chunks ahead of time, the time from "speech end to voice response" appears shorter than it actually is. Therefore, chunks must be sent the exact moment they finish recordingIt was configured to be sent only (k seconds after start).

6. Holding a spot between two web pods

The model server accepts only one call, but there are two backend pods. Variables inside the process (asyncio.Lock) do not know about calls in other pods.

Conversation practice in part 20 holds its spot using a partial unique index in the DB. Since HTTP requests go back and forth per turn with no connection in between, the spot is left as a row and expired if there is no activity for 3 minutes. Calls are different. Since the WebSocket remains open throughout the call, you only need to hold the spot while the connection is alive .

That is why we used session-level advisory locks in Postgres.

seat = db()                                   # 통화마다 연결 하나
got = seat.execute("select pg_try_advisory_lock(%s)", (SEAT_KEY,))
if not got: ...                               # 4429 로 닫는다
try:
    await relay(ws, prompt)
finally:
    seat.close()                              # 연결이 닫히면 잠금도 풀린다

7. Things that drop connections

8. End-to-end measurements

We ran the relay router as a fake DB, attached it to the model server, and sent synthesized voice questions in 1-second real-time intervals (scripts/e2e_relay.py).

Question Call Opened Voice After Speech Ends Answer
What is the capital city of Australia? 0.33s 0.30s It's Canberra.
日本の首都はどこですか? — 0.52s 東京です。
(Blog Part 25) Did the 10% probabilistic sampling leave any slow requests behind? — 2.52s Yes, it left 10%. (Incorrect)

This measurement does not include the gateway and TLS. After deployment, we re-measured the production path segment by segment (2026-10-09).

Segment Measured Value (20 runs, median)
Production backend pod → Call server, open session (session.created until) 0.33s
Opening WebSocket to backend inside cluster (plaintext) 6.5ms (4.3–18.3)
Opening WebSocket from outside via gateway (TLS/Envoy) 36ms (21–72)

The portion added to the open time by the gateway and TLS was about 30ms. Most of the time taken to open a call is the 0.33 seconds it takes for the model server to prepare the session. There are no measurements of an entire logged-in call (up to the voice after speech ends) through the production path — we did not create a test account in production (Unmeasured). Therefore, the WebSocket opening segment was measured using a flow where it opens without login and the server closes it with 4401.

At this point, one thing became clear. When measured with a Python websockets client, "until closure finishes" had a median of 1,045ms on the production path, compared to 6.9ms inside the cluster. When we separately measured the timestamp when the close frame arrived using aiohttp , it was also 36ms on the production path. The server's close signal arrives immediately, and the delay is the time the client spends waiting for the TCP connection to tear down after receiving the close frame. This means that if you tie the timing of displaying "Connection lost" on the screen to TCP termination, it could be delayed by about 1 second on paths going through a proxy.

9. Limitations and unmeasured items

Potential interview questions

References

Sign in to like

Comments

No comments yet.

Sign in to leave a comment