Building real-time voice interfaces for AI agents in the browser seems straightforward on paper. You capture the microphone with getUserMedia, send the raw PCM audio to a WebSocket or WebRTC connection, receive the AI response, and play it back.
But once you run this pipeline in the real world without headphones, the system collapses. The microphone captures the speaker output, feeds it back to the AI model, and creates a feedback loop. The AI agent ends up interrupting itself, hearing its own voice, or generating howling acoustic feedback.
To prevent this, you must rely on Acoustic Echo Cancellation (AEC). While browsers have built-in AEC engines, routing audio through the Web Audio API or handling custom streaming formats often bypasses or breaks these engines.
How Browser Echo Cancellation Fails
To cancel an echo, the browser needs two signals: the microphone input (the near-end signal) and the speaker output (the far-end or reference signal). The AEC algorithm aligns these signals in time and subtracts the reference signal from the microphone input.
When you use a standard WebRTC connection with a <video> or <audio> tag playing the remote stream, the browser manages the pipeline. It knows exactly what audio is going to the speakers and can subtract it from the microphone stream.
The pipeline breaks when you introduce the Web Audio API. If you play the AI response by decoding raw audio chunks and writing them to an AudioContext destination (audioContext.destination), the browser's media engine often fails to register this playback as the reference signal.
Without a reference signal, the echo canceller cannot filter out the speaker output. Chrome, Safari, and Firefox all handle this relationship differently, leading to unpredictable behavior across devices.
The Web Audio Routing Bypass
The most common mistake is routing the playback stream directly to the default AudioContext destination.
// This setup often bypasses the browser's AEC engine
const audioContext = new AudioContext();
const playRawPCM = (pcmData) => {
const buffer = audioContext.createBuffer(1, pcmData.length, 16000);
buffer.getChannelData(0).set(pcmData);
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(audioContext.destination); // Direct connection breaks AEC reference
source.start();
};When you connect nodes directly to audioContext.destination, the browser treats the output as arbitrary application audio rather than a communication stream.
To fix this, you must route the playback audio through an HTML5 <audio> element. This forces the browser's audio engine to register the stream as a voice communication channel, which enables the system-level hardware echo canceller.
Instead of connecting your final Web Audio node to audioContext.destination, connect it to a MediaStreamAudioDestinationNode. Then, set the resulting stream as the srcObject of an HTML5 <audio> element.
const audioContext = new AudioContext({ sampleRate: 16000 });
const mediaStreamDest = audioContext.createMediaStreamDestination();
// Connect your synthesis or playback nodes here
const playbackNode = audioContext.createBufferSource();
playbackNode.connect(mediaStreamDest);
// Route through an audio element to preserve AEC
const audioElement = new Audio();
audioElement.srcObject = mediaStreamDest.stream;
audioElement.play().catch(err => console.error("Playback failed", err));This routing hack ensures the browser treats the output as a media stream, allowing the AEC algorithm to capture the reference signal.
Configuring getUserMedia Constraints for Voice AI
Standard communication apps prioritize noise-free audio over raw signal integrity. For voice AI, you need the opposite. Voice Activity Detection (VAD) algorithms and Speech-to-Text (STT) models perform best with an unaltered signal.
When requesting the microphone stream, configure the constraints to disable aggressive browser processing while keeping echo cancellation active.
const constraints = {
audio: {
echoCancellation: true,
noiseSuppression: false,
autoGainControl: false,
channelCount: 1,
sampleRate: 16000,
latency: { ideal: 0.005 }
}
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);Why Disable Auto Gain Control?
Auto Gain Control (AGC) normalizes the input volume. When the user stops speaking, AGC boosts the gain to capture quiet sounds. This amplifies background hiss, room reflection, and system noise. VAD models often mistake this boosted background noise for user speech, causing the AI agent to trigger falsely.
Why Disable Noise Suppression?
Browser-level noise suppression algorithms use aggressive spectral subtraction. While this makes speech sound cleaner to human ears, it often clips the start and end of words. Consonants like "s", "f", and "t" get scrubbed away as noise. This distortion degrades the accuracy of transcription models like Whisper. Handle noise filtering on the server or run a lightweight WebAssembly filter (like RNNoise) in an AudioWorklet where you control the threshold. If adding external libraries, ensure you apply frontend bundle optimization to eliminate unused code.
The Latency Alignment Problem
AEC algorithms use an adaptive filter that requires precise time alignment between the speaker output and the microphone input. If the delay between playback and capture varies, the filter cannot lock onto the echo.
Web Audio processing pipelines introduce variable latency. If you process microphone input on the main thread using legacy nodes like ScriptProcessorNode, garbage collection pauses will introduce jitter. This jitter breaks the time alignment of the AEC engine. Just as you offload heavy computations with Web Workers to keep the UI responsive, real-time audio requires dedicated thread isolation.
Always use AudioWorklet for real-time capture and processing. The worklet runs on a dedicated audio thread, ensuring a constant buffer size and predictable latency.
Here is a minimal AudioWorkletProcessor designed to capture microphone input and post it to the main thread without blocking the audio pipeline:
// mic-processor.js
class MicProcessor extends AudioWorkletProcessor {
constructor() {
super();
}
process(inputs, outputs, parameters) {
const input = inputs[0];
if (input && input.length > 0) {
const channelData = input[0];
// Send raw float32 PCM data to the main thread
this.port.postMessage(channelData);
}
return true;
}
}
registerProcessor('mic-processor', MicProcessor);iOS Safari Hardware Route Changes
iOS Safari introduces severe audio routing issues. When a user connects Bluetooth headphones (like AirPods) or plugs in a wired headset mid-session, the system hardware sample rate changes.
Bluetooth profiles often switch from a high-fidelity playback rate (44.1kHz or 48kHz) to a low-bandwidth bidirectional communications profile (often 16kHz or 8kHz) when the microphone is activated.
If your AudioContext is locked to a fixed sample rate, this hardware transition will crash the audio pipeline. The context state will switch to suspended or start rendering silent buffers.
To handle these transitions, listen to the devicechange event on navigator.mediaDevices and monitor the state changes of your AudioContext.
class AudioPipeline {
constructor() {
this.ctx = null;
this.micStream = null;
this.setupListeners();
}
setupListeners() {
navigator.mediaDevices.addEventListener('devicechange', () => this.handleRouteChange());
}
async init() {
// Initialize context without forcing a sample rate on iOS
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
// If browser switches rate, handle it
this.ctx.addEventListener('statechange', () => {
if (this.ctx.state === 'suspended') {
this.ctx.resume();
}
});
}
async handleRouteChange() {
if (!this.ctx) return;
// Close the existing context and recreate it to adapt to the new hardware rate
await this.ctx.close();
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
// Re-initialize your nodes and worklets here
console.log(`Re-initialized audio at new sample rate: ${this.ctx.sampleRate}Hz`);
}
}The iOS Mute Switch Trap
By default, iOS Safari silences Web Audio if the physical ring/silent switch on the side of the iPhone is set to silent. Standard HTML5 audio elements can bypass this switch if they are triggered by a user action, but Web Audio nodes will remain muted.
By routing your Web Audio output through an HTML5 <audio> element (as shown in the AEC bypass fix), you solve both the echo cancellation issue and the hardware mute switch issue simultaneously. The browser treats the <audio> element as media playback, which bypasses the silent switch restriction.
Complete Voice AI Audio Pipeline Implementation
This production-ready class handles device initialization, constraint enforcement, safe routing to preserve browser AEC, and recovery from hardware route changes.
class VoiceAIPipeline {
constructor(onAudioData) {
this.onAudioData = onAudioData;
this.audioCtx = null;
this.micStream = null;
this.micSource = null;
this.workletNode = null;
this.playbackElement = null;
this.playbackDestination = null;
this.handleDeviceChange = this.handleDeviceChange.bind(this);
}
async start() {
try {
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// Request mic with custom voice AI constraints
this.micStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: false,
autoGainControl: false,
channelCount: 1
}
});
// Load processor worklet
await this.audioCtx.audioWorklet.addModule('mic-processor.js');
this.micSource = this.audioCtx.createMediaStreamSource(this.micStream);
this.workletNode = new AudioWorkletNode(this.audioCtx, 'mic-processor');
this.workletNode.port.onmessage = (event) => {
this.onAudioData(event.data);
};
this.micSource.connect(this.workletNode);
// Do not connect workletNode to audioCtx.destination to avoid looping input to output
// Setup output routing to preserve AEC
this.playbackDestination = this.audioCtx.createMediaStreamDestination();
this.playbackElement = new Audio();
this.playbackElement.srcObject = this.playbackDestination.stream;
this.playbackElement.play().catch(err => {
console.warn("Playback pending user interaction:", err);
});
navigator.mediaDevices.addEventListener('devicechange', this.handleDeviceChange);
} catch (err) {
console.error("Failed to start voice pipeline:", err);
this.stop();
throw err;
}
}
playIncomingChunk(float32Array) {
if (!this.audioCtx || this.audioCtx.state === 'suspended') return;
const buffer = this.audioCtx.createBuffer(1, float32Array.length, this.audioCtx.sampleRate);
buffer.getChannelData(0).set(float32Array);
const source = this.audioCtx.createBufferSource();
source.buffer = buffer;
// Connect to the media stream destination, not the raw context destination
source.connect(this.playbackDestination);
source.start();
}
async handleDeviceChange() {
console.log("Audio device change detected. Resetting pipeline...");
this.stop();
// Yield to let the OS finalize routing before restarting
await new Promise(resolve => setTimeout(resolve, 500));
await this.start();
}
stop() {
navigator.mediaDevices.removeEventListener('devicechange', this.handleDeviceChange);
if (this.workletNode) {
this.workletNode.disconnect();
this.workletNode = null;
}
if (this.micSource) {
this.micSource.disconnect();
this.micSource = null;
}
if (this.micStream) {
this.micStream.getTracks().forEach(track => track.stop());
this.micStream = null;
}
if (this.playbackElement) {
this.playbackElement.pause();
this.playbackElement.srcObject = null;
this.playbackElement = null;
}
if (this.audioCtx) {
this.audioCtx.close();
this.audioCtx = null;
}
}
}Production Checklist
To verify your implementation before deployment, test against these scenarios:
- Headphone unplug test: Play audio through the speakers, speak into the microphone, and verify that the output audio does not leak back into your input stream.
- iOS silent switch test: Flip the hardware silent switch on an iPhone. Ensure the AI response can still be heard.
- Bluetooth transition test: Connect AirPods mid-conversation. Verify the pipeline recovers and does not freeze or play back audio at the wrong speed.
- VAD noise floor test: Check the input amplitude when the user is silent. If the volume spikes during silence, AGC is still active. Double-check your constraints.
By routing your playback through an HTML5 media element and avoiding direct Web Audio destination connections, you keep the browser's hardware AEC active and prevent echo loops in real-time voice applications.
[Web Development, Browser APIs, Frontend]



