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.
cd examples/webbun run start # http://localhost:5173The live demo is this example bundled for production: ottervoice.vercel.app
Current example paths
Section titled “Current example paths”| 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.
Minimal wiring
Section titled “Minimal wiring”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.
VAD defaults and starting profiles
Section titled “VAD defaults and starting profiles”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.
Browser checklist
Section titled “Browser checklist”- 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.