Skip to content

Web browser

Example: examples/web

The full example switches between two server paths in the UI: a native Audio LLM and an ASR → LLM → TTS cascade. Both are one audio-turn provider in the browser, so they share the same Session, events, playback, and barge-in lifecycle. For native audio-in/audio-out only, start with the smaller examples/web-audio-llm-only; it configures no text LLM or TTS provider.

Terminal window
cd examples/web
bun run start # http://localhost:5173

The live demo is this example bundled for production: ottervoice.vercel.app

UI choice Server implementation Input caption Web search
Native · GPT Audio Mini OpenRouter openai/gpt-audio-mini Parallel Qwen ASR Unavailable
Native · Gemini Live Google gemini-3.1-flash-live-preview Parallel Qwen ASR Optional Google Search Grounding
Cascade Qwen ASR → Gemini Flash Lite → MiniMax Speech Same composite response Optional OpenRouter search route

Models, prompts, voices, search tools, and budgets stay server-side. The UI’s “Input / output text” switch controls caption visibility only; it does not disable transcription work required by the server path.

import { createOtterVoiceSession } from '@ottervoice/core';
import { createOpenRouterGatewayVoiceTurn } from '@ottervoice/provider-openrouter';
import { createWebRuntime, prepareBrowserAudio } from '@ottervoice/runtime-web';
const runtime = createWebRuntime({
mimeType: 'audio/webm;codecs=opus',
timesliceMs: 100,
volumePollMs: 50,
bargeInPreRollMs: 500,
});
// The browser sees one application route, never model/prompt/voice/provider keys.
const voiceTurn = createOpenRouterGatewayVoiceTurn({
baseUrl: '/api/voice/asr-llm-tts',
requireDoneSentinel: true,
prepareAudio: (audio, format) => prepareBrowserAudio(audio, format, {
sampleRate: 16_000,
maxDurationMs: 60_000,
}),
});
const session = createOtterVoiceSession({
mode: 'full_duplex',
// This is the client/provider contract; the server may still run a cascade.
audioLlmStartTiming: 'after_audio',
runtime,
// voiceTurn.transcribesInput === true, so no separate ASR or text LLM is needed.
providers: { audioLlm: voiceTurn },
turnDetection: {
strategy: 'volume',
minSpeechMs: 180,
silenceTimeoutMs: 450,
volumeThreshold: 0.025,
},
policy: {
autoStartListening: true,
allowInterruption: true,
},
});
await session.start();

On the server, createOpenRouterGateway() locks model, system prompt, voice, temperature, token ceiling, and reasoning across the three policy stages of the composite asr_llm_tts profile. Its mandatory authorize hook must verify user and conversation ownership. The native Audio LLM path uses createOpenRouterGatewayAudioLLM; pair it with createOpenRouterGatewayASR when it does not return an authoritative input transcript. Do not set audioLlmSystemPrompt on a browser Session; that field is for Sessions running entirely in trusted Node/server code.

The client has one audio-turn contract regardless of the number of models behind the gateway. createOpenRouterGatewayVoiceTurn wraps server composition as an AudioLLMProvider and uses transcribesInput: true to deliver the input transcript from the same response, avoiding a second ASR request.

Core defaults are hybrid, minSpeechMs: 500, silenceTimeoutMs: 1200, maxTurnMs: 120000, and volumeThreshold: 0.02. They are conservative cross-runtime defaults. For product tuning, start with this matrix and validate against your devices and acoustic environment:

Browser Strategy minSpeechMs silenceTimeoutMs volumeThreshold Capture note
Desktop Chrome hybrid 180 650 0.02–0.03 100 ms timeslices; 500 ms barge-in pre-roll
Android Chrome hybrid 220 850 0.025–0.04 100 ms timeslices; 600 ms pre-roll; cap turns at 60–90 s
Safari (macOS/iOS) hybrid 250 900 0.025–0.04 Let MediaRecorder choose a supported MIME when WebM is unavailable

Raise volumeThreshold when background noise creates false starts; lower it when quiet speakers are missed. Tune normal VAD and interruptionDetection independently—barge-in should be stricter. Never copy a threshold to production without testing built-in microphones, headsets, speakerphone echo, and permission/autoplay recovery.

  • Serve over HTTPS or localhost
  • Request the mic (and prefer audioOutput.unlock?.()) from a user gesture
  • Autoplay unlock failure must not block ASR
  • Call await session.dispose() when leaving the page

For model locking, storage, deletion, safe logging, and retries, continue with Production integration.

See @ottervoice/runtime-web for options.