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.

Detect speechcreateVad: start, end, confirmed, misfire events and the raw audio. Coming from vad-webChange one import. Same MicVAD API, no model files to copy. ReactuseVad, useMicVAD and useAuricle hooks. Full voice agentBarge-in, ducking and heard-text truncation with any OpenAI-compatible API.

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

OptionDefaultWhat it does
profile'speakers''headphones' uses lower thresholds while the agent talks, because there is no echo to guard against.
thresholds.positive0.5Probability a frame needs to count as voiced.
thresholds.negative0.35Below this a frame counts as silent. The gap between the two is hysteresis.
thresholds.hangoverMs240Silence needed before the segment ends. vad-web's default is 1,400.
thresholds.minSpeechMs100Segments 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:

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