Voice in the Browser
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Written on 2026-10-01 against the browser APIs as documented by MDN and the W3C (Media Capture and Streams, WebRTC, Web Audio
AudioWorklet, Web Speech), aiortc 1.15.0 under Pipecat’s SmallWebRTC transport, pipecat-ai 1.12.0, LiveKit Agents 1.8.3
with the LiveKit JavaScript client, the OpenAI Realtime API, the ElevenLabs Agents SDKs, FastAPI 0.142.2 on Python 3.12, and
Spring AI 2.0.1 on Spring Boot 4.1.x with Java 21 (see the version
baseline). Nothing here was run: real-time audio needs a microphone, a browser and, for the hosted services, paid accounts,
so every snippet was written from the official documentation linked after it and is illustrative, not compiled. The
Pipecat /api/offer body shape in particular follows the project’s published examples and should be checked against the
pipecat-examples repository before use, and the behaviour of iOS Safari and background tabs is described from vendor and
MDN documentation, not from device testing.
The browser is where most DocsAssistant voice sessions start. Whatever runs behind it (a realtime API, a hosted agent, a Pipecat or LiveKit worker, or a hand-built pipeline), the page has the same four jobs: capture the microphone with echo control, move audio to the agent with low delay, play the reply without gaps and stop it the moment the user speaks, and obtain a credential without shipping a vendor key to the client. This page covers those jobs once; the transport-specific details of each platform are on Realtime APIs, ElevenLabs Agents, Pipecat and LiveKit Agents.
Capturing the microphone
navigator.mediaDevices.getUserMedia() asks the user for the microphone and returns a MediaStream. It works only in a
secure context (HTTPS or localhost) and the permission prompt must be triggered by something the user did, so the call
belongs in the click handler of the "Ask DocsAssistant" button, never on page load. The audio constraints that matter for a
voice agent are echoCancellation, noiseSuppression and autoGainControl. Echo cancellation is the one that cannot be
skipped: without it the agent’s own voice, played from the loudspeaker, re-enters the microphone and the agent interrupts
itself (the failure analysed on Turn-Taking and Interruptions).
export async function openMicrophone() {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
sampleRate: { ideal: 48000 }, // a hint: the browser may give another rate
},
video: false,
});
const [track] = stream.getAudioTracks();
const applied = track.getSettings(); // what the browser actually granted
console.log("echoCancellation:", applied.echoCancellation,
"noiseSuppression:", applied.noiseSuppression);
return stream;
}
Source: MDN: MediaDevices.getUserMedia() and
MDN: MediaTrackConstraints.echoCancellation.
Constraints given as plain values are ideal requests, not guarantees; read track.getSettings() to see what was applied
and show a headset hint when echo cancellation is off. Browsers apply these as part of their WebRTC audio processing, so a
stream captured this way and sent over a peer connection is already cleaned; a stream read through an AudioWorklet for
a WebSocket (below) is the same stream and keeps the processing. Server-side noise suppression, described on
Speech to Text, is a second layer and not a substitute.
Transport choice: WebRTC or WebSocket
| WebRTC | WebSocket with raw PCM | |
|---|---|---|
Transport |
UDP (SRTP, Opus), ICE negotiates the path; falls back to TURN over TCP/TLS. |
One TCP connection, so head-of-line blocking after a packet loss. |
Browser audio work |
Done by the browser: Opus encoding, echo cancellation, packet-loss concealment, jitter buffer, clock drift. |
Done by you: capture, resampling, framing, playback scheduling, jitter buffer. |
Latency under loss |
Best; late packets are dropped or concealed. |
Stalls then bursts on a bad mobile network. |
Server complexity |
An SFU or a library such as |
A WebSocket route; every framework and proxy already supports it. |
Bandwidth |
Opus at about 24 to 32 kbit/s. |
16 kHz 16-bit mono PCM is 256 kbit/s unless you compress it. |
Fit |
Production voice in browsers and phones; what the hosted platforms use. |
Prototypes, corporate networks that block UDP, and hand-built pipelines. |
The rule used on the rest of the sub-section: use WebRTC whenever the backend offers it, and use the WebSocket path when you own the whole pipeline (see Building a Voice Pipeline by Hand) or when a network blocks UDP. The data-channel side of WebRTC, useful for sending transcripts and tool events over the same connection, is covered on WebRTC Data Channels.
WebRTC to Pipecat SmallWebRTC
Pipecat’s SmallWebRTC transport runs a peer connection in your Python process using aiortc, so there is no media server
to operate. The browser creates an RTCPeerConnection, adds the microphone track, sends the SDP offer to an HTTP endpoint
of your server (the examples use /api/offer), and applies the SDP answer that comes back. The server reply carries a
pc_id that identifies the connection for later renegotiation.
export async function connectToPipecat(stream, audioElement) {
const pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
stream.getAudioTracks().forEach((track) => pc.addTrack(track, stream));
// the agent's voice arrives as a remote audio track
pc.ontrack = (event) => {
if (event.track.kind === "audio") {
audioElement.srcObject = event.streams[0];
audioElement.play().catch(() => {}); // allowed: we are inside a user gesture chain
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// wait for ICE gathering to finish so the offer carries all candidates
await new Promise((resolve) => {
if (pc.iceGatheringState === "complete") return resolve();
pc.addEventListener("icegatheringstatechange", () => {
if (pc.iceGatheringState === "complete") resolve();
});
});
const response = await fetch("/api/offer", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
sdp: pc.localDescription.sdp,
type: pc.localDescription.type,
}),
});
const answer = await response.json(); // { sdp, type, pc_id }
await pc.setRemoteDescription({ sdp: answer.sdp, type: answer.type });
return { pc, pcId: answer.pc_id };
}
Source: MDN: RTCPeerConnection for the browser calls and
Pipecat: SmallWebRTC transport for the transport; the exact
JSON fields of /api/offer follow the p2p-webrtc example in pipecat-examples
and were not confirmed against a running server. Waiting for complete ICE gathering is the simplest signalling (one request,
one response); trickle ICE, where candidates are sent as they appear, shortens connection time and uses a PATCH request in
the Pipecat examples. For production, the Pipecat client SDK (@pipecat-ai/client-js with the SmallWebRTC transport package)
wraps this and adds events for transcripts and bot speaking state.
WebRTC to LiveKit, OpenAI and ElevenLabs
The other platforms hide the peer connection behind an SDK or a single HTTP call. What changes between them is who issues the credential and where the SDP goes.
| Platform | Browser connects with | Your backend’s job |
|---|---|---|
LiveKit |
|
Sign a JWT access token with the room name and identity using the LiveKit API key and secret. |
OpenAI Realtime |
Create an |
Mint the client secret with |
ElevenLabs Agents |
|
For a private agent, fetch a conversation token from ElevenLabs with the API key and return it. |
Pipecat SmallWebRTC |
The offer shown above, to your own endpoint. |
Run the endpoint and authenticate it like any other API route. |
Whichever platform is used, the browser-side control loop is the same: show the connection state, mute by disabling the
audio track (track.enabled = false) rather than closing the connection, and close the peer connection and stop the tracks
(track.stop()) when the call ends so the browser releases the microphone indicator.
Minting tokens on the backend
A browser must never hold the vendor API key. The pattern on every platform above is a small authenticated endpoint of the DocsAssistant backend that exchanges the user’s session for a short-lived credential scoped to one call. The authentication dependency is the same one used on the text endpoints, so the access-control rules decide who may start a voice session.
import uuid
from fastapi import Depends, FastAPI
from livekit import api
from docsassistant.config import settings
from docsassistant.security import User, current_user
app = FastAPI()
@app.post("/api/voice/token")
async def voice_token(user: User = Depends(current_user)) -> dict:
room = f"docs-{uuid.uuid4().hex[:12]}"
token = (
api.AccessToken(settings.livekit_api_key, settings.livekit_api_secret)
.with_identity(user.id)
.with_grants(api.VideoGrants(room_join=True, room=room))
.to_jwt()
)
return {"url": settings.livekit_url, "room": room, "token": token}
Source: LiveKit: Authentication and access tokens (token creation with
AccessToken and VideoGrants). Dispatching the agent into the room is configured on the LiveKit side, as described on
LiveKit Agents. Keep the token lifetime short and generate a new room per call.
@RestController
class VoiceTokenController {
private final RestClient elevenLabs;
private final String agentId;
VoiceTokenController(RestClient.Builder builder,
@Value("${elevenlabs.api-key}") String apiKey,
@Value("${elevenlabs.agent-id}") String agentId) {
this.elevenLabs = builder.baseUrl("https://api.elevenlabs.io")
.defaultHeader("xi-api-key", apiKey)
.build();
this.agentId = agentId;
}
@PostMapping("/api/voice/token")
Map<String, Object> token(Authentication authentication) { // already authenticated by Spring Security
return elevenLabs.get()
.uri(uri -> uri.path("/v1/convai/conversation/token")
.queryParam("agent_id", agentId).build())
.retrieve()
.body(new ParameterizedTypeReference<>() {}); // {"token": "..."}
}
}
Source: ElevenLabs: JavaScript SDK (private agents and the
conversation token) with Spring Framework:
REST clients for RestClient. The same shape serves OpenAI: replace the call with POST /v1/realtime/client_secrets as
shown on Realtime APIs.
The WebSocket PCM alternative
When the backend is a hand-built pipeline that wants raw audio, the browser sends PCM frames over a WebSocket and plays back the PCM that returns. The server side of the socket is the same as any other (see WebSockets Next for a Java example); the work is in the browser.
Capture uses an AudioWorklet, which runs on the audio rendering thread and so is not delayed by the page’s main thread.
The processor receives 128-sample blocks of 32-bit floats at the context’s own rate. It resamples them to 16 kHz, converts
them to 16-bit integers and posts fixed-size frames of 512 samples (32 ms). That is the frame size the Silero VAD in
Building a Voice Pipeline by Hand requires at 16 kHz.
// pcm-capture.worklet.js -- runs in the AudioWorkletGlobalScope (sampleRate is the context's rate)
class PcmCapture extends AudioWorkletProcessor {
constructor({ processorOptions }) {
super();
this.targetRate = processorOptions.targetRate; // 16000
this.frame = new Int16Array(processorOptions.frameSamples); // 512 samples per message
this.filled = 0;
this.step = sampleRate / this.targetRate; // e.g. 48000 / 16000 = 3
this.position = 0; // fractional read position into the current block
this.last = 0; // last sample of the previous block (for position -1..0)
}
process(inputs) {
const channel = inputs[0][0]; // first input, first channel (Float32Array, 128 samples)
if (!channel) return true;
// Linear-interpolation resampler; position and the last sample carry over between blocks so there are no seams.
const at = (k) => (k < 0 ? this.last : channel[k]);
for (; this.position < channel.length - 1; this.position += this.step) {
const i = Math.floor(this.position);
const t = this.position - i;
const s = Math.max(-1, Math.min(1, at(i) * (1 - t) + at(i + 1) * t));
this.frame[this.filled++] = s < 0 ? s * 0x8000 : s * 0x7fff;
if (this.filled === this.frame.length) { // a full 512-sample frame: hand it to the main thread
this.port.postMessage(this.frame.buffer, [this.frame.buffer]);
this.frame = new Int16Array(this.frame.length);
this.filled = 0;
}
}
this.last = channel[channel.length - 1];
this.position -= channel.length;
return true; // keep the processor alive
}
}
registerProcessor("pcm-capture", PcmCapture);
// main.js
export async function startCapture(stream, socket) {
const context = new AudioContext(); // default (device) rate: works in every browser
await context.audioWorklet.addModule("/pcm-capture.worklet.js");
const source = context.createMediaStreamSource(stream);
const node = new AudioWorkletNode(context, "pcm-capture", {
processorOptions: { targetRate: 16000, frameSamples: 512 },
});
node.port.onmessage = (event) => {
if (socket.readyState === WebSocket.OPEN) socket.send(event.data); // 1024 bytes of PCM16 per message
};
source.connect(node);
return context;
}
Source: MDN: AudioWorkletProcessor,
MDN: Using AudioWorklet and
MDN: AudioContext() constructor. The context is
created at the default rate on purpose. Firefox refuses to connect a microphone MediaStreamAudioSourceNode to an
AudioContext created with a different sampleRate ("Connecting AudioNodes from AudioContexts with different sample-rate
is currently not supported"), so asking for { sampleRate: 16000 } works in Chromium but fails there. Resampling in the
worklet keeps one code path for all browsers. The linear interpolator is adequate for speech recognition; use a
windowed-sinc resampler if the audio is also recorded for quality. A server that needs a different frame size changes only
frameSamples. Sending the raw PCM bytes needs socket.binaryType = "arraybuffer" on the receiving side for the reply.
Playback is where WebSocket pipelines usually go wrong. Chunks arrive at irregular intervals, and playing each one as it lands produces clicks and gaps. A jitter buffer queues a small amount of audio before starting (typically 60 to 120 ms) and schedules each chunk to start exactly where the previous one ended, using the audio clock rather than timers.
export class PcmPlayer {
constructor(sampleRate = 24000, prebufferSeconds = 0.08) {
this.context = new AudioContext({ sampleRate });
this.prebuffer = prebufferSeconds;
this.nextTime = 0;
this.sources = new Set();
}
enqueue(arrayBuffer) {
const int16 = new Int16Array(arrayBuffer);
const buffer = this.context.createBuffer(1, int16.length, this.context.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < int16.length; i++) data[i] = int16[i] / 0x8000;
const source = this.context.createBufferSource();
source.buffer = buffer;
source.connect(this.context.destination);
const now = this.context.currentTime;
if (this.nextTime < now) this.nextTime = now + this.prebuffer; // underrun: re-prime the buffer
source.start(this.nextTime);
this.nextTime += buffer.duration;
this.sources.add(source);
source.onended = () => this.sources.delete(source);
}
// barge-in: drop everything that has not played yet
flush() {
for (const source of this.sources) { try { source.stop(); } catch {} }
this.sources.clear();
this.nextTime = 0;
}
}
Source: MDN: AudioBufferSourceNode and
MDN: BaseAudioContext.createBuffer(). The
flush() method is what the page calls when the server sends an interruption event; the server must stop generating at the
same moment, and the played-versus-sent bookkeeping that keeps the conversation history honest is described on
Turn-Taking and Interruptions. A larger prebuffer smooths a bad
network at the price of latency: the added delay is the prebuffer length , which belongs in the budget on
Voice Architectures next to the network and processing terms.
The Web Speech API
The Web Speech API offers SpeechRecognition (speech to text) and speechSynthesis (text to speech) with no server code,
which makes it tempting for a prototype. Its limits are the reason it is not used for the DocsAssistant product path:
-
Support varies.
SpeechRecognitionis exposed with awebkitprefix in Chromium-based browsers, behaves differently in Safari, and is not available everywhere; MDN marks it limited availability. -
Audio may go to a vendor. In Chromium-based browsers recognition is typically performed by a remote service, so audio leaves the device with no contract of yours behind it. The specification and MDN note that the service is implementation-defined; some browsers offer on-device recognition for specific languages through options that are still evolving.
-
No control over models or turn detection. There is no custom vocabulary of your choosing, no VAD tuning and no streaming into your own turn-taking logic beyond interim results.
-
Synthesis voices are whatever the operating system ships, and differ per device, so the DocsAssistant would sound different for every user.
A speech-to-text step you control, described on Speech to Text, or a realtime
API, is the production answer. speechSynthesis is still a reasonable fallback to read an error message aloud, and a
useful check that the loudspeaker path works.
Mobile browser caveats
-
Autoplay policy. Browsers block audio playback and keep an
AudioContextin thesuspendedstate until the page has had a user gesture. Create orresume()the context, and callaudioElement.play(), inside the button’s click handler. -
iOS Safari. All iOS browsers use WebKit, and each has its own history of WebRTC and Web Audio quirks: the microphone permission is requested per page, an
AudioContextmay be interrupted by calls and by the silent switch, and sample rates andAudioWorkletbehaviour have differed between versions. Test on a real device for every release you support, and keep a visible "tap to start" control rather than starting automatically. -
Background tabs and locked screens. Browsers throttle timers and may suspend audio and network activity for hidden tabs, and most mobile browsers stop microphone capture when the page is not visible. Treat a hidden page as a paused call: detect
document.visibilitychange, tell the user, and reconnect rather than assuming the session survived. -
Audio routing. Switching between earpiece, speaker and Bluetooth can change the sample rate and re-trigger echo cancellation; listen for
devicechangeand re-open the stream if the track ends. -
Data cost and battery. A voice call keeps the radio active; Opus over WebRTC is far cheaper than uncompressed PCM over a WebSocket.
For a call that must survive a locked screen, a native SDK (the ElevenLabs Swift and Kotlin SDKs, LiveKit’s mobile clients) or a real telephone call, covered on Telephony with Twilio, is the dependable route.
STUN and TURN
WebRTC peers sit behind NATs and firewalls. STUN servers tell a peer its public address so that a direct path can be tried. When no direct path exists (symmetric NATs, corporate firewalls that block UDP), a TURN server relays the media. ICE gathers host, server-reflexive (STUN) and relay (TURN) candidates and picks the best working pair. For a voice agent the server usually has a public address, so direct connectivity succeeds more often than for peer-to-peer calls, but the browser’s network is outside your control, and a deployment that works in the office and fails on a hotel network is a missing TURN server.
// credentials come from your backend, never hard-coded in the page
const { iceServers } = await (await fetch("/api/voice/ice", { credentials: "include" })).json();
// e.g. [
// { urls: "stun:turn.example.com:3478" },
// { urls: ["turn:turn.example.com:3478?transport=udp",
// "turns:turn.example.com:443?transport=tcp"],
// username: "1767225600:alice", credential: "base64-hmac" }
// ]
const pc = new RTCPeerConnection({ iceServers });
Source: MDN: RTCPeerConnection() constructor
(the iceServers option) and RFC 8489: STUN with
RFC 8656: TURN. A self-hosted coturn server supports time-limited credentials
derived from a shared secret, which the backend computes per request; offering TURN on TCP port 443 with TLS lets media
pass most proxies at the cost of extra latency. The SFU used by LiveKit and the hosted platforms ship their own TURN, so
this section matters most for SmallWebRTC and other self-hosted peers. Relayed media travels through your TURN server, so
account for its bandwidth.
Accessibility
A voice interface is an extra way to interact, not a replacement for the page. Keep the text conversation available,
show live captions of the transcript (for deaf and hard-of-hearing users and for noisy rooms), label the start and mute
buttons, expose the call state to assistive technology with an aria-live region, and do not rely on colour alone for the
listening and speaking indicators. The general rules and conformance levels are on Web
Accessibility.
Choosing a browser voice path
Use the platform’s own WebRTC SDK (LiveKit, ElevenLabs, OpenAI) when you chose that platform: the SDK handles the peer connection, and your code is the token endpoint. Use Pipecat SmallWebRTC when the pipeline is yours and you want a single Python process without a media server. Use the WebSocket PCM path only for hand-built pipelines or networks that block UDP, and accept that jitter buffering, resampling and barge-in flushing are then your code. Add a TURN server before the first user outside your office. Reserve the Web Speech API for prototypes and fallbacks, always start audio from a user gesture, test on real iOS and Android devices, and keep the text channel beside the voice one.