Docs
One npm package. Three ways in, from a ten-line speech detector to a full interruptible voice agent. Everything runs in the browser; nothing is fetched at runtime.
Install
npm install auricle # core: createVad, Auricle, auricle/compat
npm install auricle-react # React hooks (peer: auricle, react >= 18)
No bundler? One script tag defines window.Auricle. The script build carries both detectors (1.4 MB gzipped); the ESM build above downloads only the detector you ask for.
<script src="https://cdn.jsdelivr.net/npm/auricle@0.1.0/dist/auricle.iife.js"></script>
<!-- or https://unpkg.com/auricle@0.1.0/dist/auricle.iife.js -->
Requirements: a browser with AudioWorklet and WebAssembly SIMD (Chrome 91+, Edge 91+, Safari 16.4+, Firefox 89+), a secure context (https or localhost) for the microphone, and a user gesture before start().
Detect speech
The detector reports graded events so your UI can react early and act late: a candidate after three voiced frames (about 100 ms), a confirmation after six of the last ten, and the finished segment as 16 kHz audio with 512 ms of pre-roll.
import { createVad } from 'auricle';
const vad = await createVad({
onSpeechStart: ({ tMs }) => ui.listening(), // candidate: reversible actions (show a meter, duck music)
onSpeechConfirmed: () => ui.speaking(), // confirmed: irreversible actions (stop the agent)
onSpeechEnd: (audio, span) => upload(audio), // Float32Array at 16 kHz, with pre-roll
onMisfire: () => ui.listening(), // a candidate that never confirmed
onFrame: (p) => meter.set(p), // speech probability every 32 ms
});
await vad.start(); // call from a click: the browser asks for the microphone
vad.pause(); vad.resume(); vad.setThresholds({ minSpeechMs: 200 }); vad.stop();
// onSegment gives the same audio with timing and a WAV helper:
createVad({ onSegment: (s) => upload(s.wav()) }); // s.audio, s.startMs, s.endMs, s.durationMs, s.wav()
vad.stats.sttShare; // share of the session that was sent to speech-to-text, 0 to 1
Every option, method, event and type has a JSDoc comment, so autocomplete and a language model reading the types get the full contract.
Options
| Option | Default | What it does |
|---|---|---|
profile | 'speakers' | 'headphones' uses lower thresholds while the agent talks, because there is no echo to guard against. |
thresholds.positive | 0.5 | Probability a frame needs to count as voiced. |
thresholds.negative | 0.35 | Below this a frame counts as silent. The gap between the two is hysteresis. |
thresholds.hangoverMs | 240 | Silence needed before the segment ends. vad-web's default is 1,400. |
thresholds.minSpeechMs | 100 | Segments shorter than this are dropped as misfires. Raise it for keyboards and clicks. |
Coming from vad-web
Change one import. The class, the method names and the callback names stay the same.
import { MicVAD, utils } from 'auricle/compat'; // was '@ricky0123/vad-web'
const vad = await MicVAD.new({
onSpeechStart: () => ui.listening(),
onSpeechRealStart: () => ui.speaking(),
onSpeechEnd: (audio) => upload(utils.encodeWAV(audio)),
onVADMisfire: () => ui.listening(),
});
vad.start();
Delete baseAssetPath, onnxWASMBasePath and any copied .onnx or .wasm files. redemptionMs maps to the hangover; positiveSpeechThreshold and negativeSpeechThreshold map to the thresholds above. The full mapping and the differences are in MIGRATING_FROM_VAD_WEB.md.
React
import { useVad, useMicVAD, useAuricle } from 'auricle-react';
const { listening, speaking, probability, segments, toggle } = useVad({ onSpeechEnd: upload });
const mic = useMicVAD({ onSpeechEnd: upload }); // the @ricky0123/vad-react shape
const { state, turns, agentText, heardText, start, interrupt } = useAuricle({ backend });
Full voice agent
The Auricle class runs the conversation against any OpenAI-compatible endpoint: speech-to-text, chat completions over SSE, and text-to-speech. The listener ducks the agent's audio within 30 ms of a speech candidate, stops it within 15 ms of confirmation, and cuts the agent's message history to the words the user actually heard, so the model never argues with sentences nobody listened to.
import { Auricle, OpenAICompat } from 'auricle';
const auricle = await Auricle.create({
backend: new OpenAICompat({
baseURL: 'https://api.groq.com/openai/v1',
apiKey,
models: { stt: 'whisper-large-v3-turbo', llm: 'openai/gpt-oss-20b', tts: 'canopylabs/orpheus-v1-english' },
voice: 'autumn',
}),
profile: 'speakers', // or 'headphones'
});
auricle.on('state', (s) => ui.set(s)); // listening | user_speaking | thinking | agent_speaking
auricle.on('turn', (t) => log(t.clean, t.verbatim));
auricle.on('interrupted', (e) => log(e.heardText, e.stopLatencyMs));
auricle.on('metrics', (m) => log(m.endToFirstAudioMs));
await auricle.start();
Key handling: the demo keeps your key in the tab only. In production put the key on your server and point baseURL at a proxy that adds it.
What it saves, measured
Three things cost money in a voice agent: audio sent to speech-to-text, text sent to text-to-speech, and the time a user waits. Auricle counts the first two per session from its own events, so the number is yours, not ours.
import { formatStats } from 'auricle';
const s = auricle.stats; // or vad.stats, or useVad().stats
s.speechMs // audio that left the browser for STT (only finished speech segments are sent)
s.sessionMs // how long the microphone was open
s.sttShare // speechMs / sessionMs: an always-on streaming STT connection would bill 1.0
s.ttsCharsGenerated, s.ttsCharsAvoided // characters spoken vs. characters the model wrote that nobody heard
s.interruptions, s.stopLatencyMedianMs // how often the user cut in, and how fast the audio stopped
formatStats(s) // "session 2:14 · sent to STT 0:31 (23%) · TTS chars 812, avoided 1,040 · 2 interruptions, median stop 41 ms"
What that means in practice, stated carefully:
- Speech-to-text. Only finished speech segments are uploaded. A session where the user talks for a quarter of the time sends a quarter of the audio a streaming connection would.
sttShareis that fraction, measured. Segments whose loudest 100 ms stays under -45 dBFS are never sent, so breathing and room tone do not become transcription requests. - Text-to-speech and model output. When the user interrupts, generation is aborted and the agent's history is cut to the words they heard.
ttsCharsAvoidedis the text that was never synthesised or never played. With a per-character TTS price that is the saved spend; the model also stops producing tokens nobody will hear. - Time. Turn end is detected 324 ms after the user stops, against 1,432 ms with vad-web's defaults (measured), and speech-to-text starts during the hangover rather than after it.
- What we do not claim. No dollar figure. Vendor rates differ and change monthly; multiply the counters by yours. The counters are per browser session; aggregate them server-side if you want fleet numbers.
How it runs
mic ──► capture worklet (Rust: resample, 512-sample frames) ──► brain worker (Rust: VAD, turn machine, arbiter)
│ duck / unduck / stop
▼
speakers ◄── playback worklet (Rust: ring buffer, duck, fade, played counter) ◄── TTS chunks
main thread: Auricle class ── STT, chat, TTS over fetch ── heard-text truncation
The decision to duck or stop never touches the main thread or the network. The detector runs in a worker; the audio stays in the worklets. Full type reference: web/src/vad.ts, web/src/compat.ts, web/src/auricle.ts and react/src in the repository.
More
- Benchmarks: every number with the script that reproduces it.
- Auricle v1 model card: architecture, data, caveats, languages.
- COMPARISON.md: the long version of the comparison with vad-web, Silero VAD and TEN VAD.
- auricle-voice/auricle-v1 on Hugging Face: weights, layout and ONNX.
- llms.txt and llms-full.txt: this site for language models.