Voice interfaces are getting easier to add to the web, but wake-words have been the hard part. Cloud APIs mean latency, per-request billing, and audio leaving the user's device. On-device wake-words in the browser were awkward until WebAssembly SIMD128 shipped in every major browser.
You will have a working "Hey Assistant" detector in about five minutes. Everything runs on-device, no cloud, no per-request cost.
What you will need
- A modern browser (Chrome/Edge 91+, Firefox 89+, or Safari 16.4+). WASM SIMD128 is a hard requirement
- A microphone
- Any static file server on HTTPS or localhost. Browsers block
getUserMediaon plain HTTP - Node.js if you want npm install. Otherwise CDN works fine
The full download is ~275 KB (170 KB WASM runtime + ~100 KB model). That is smaller than most icon fonts.
Step 1: install the SDK
Two options.
via npm:
npm install @voxrt/wake-word-browser
via CDN (no build step):
import init, { WakeWordEngine } from
"https://unpkg.com/@voxrt/wake-word-browser@0.1.1/voxrt-wake-word-browser.js";
Use CDN to skip the bundler.
Step 2: HTML shell
Nothing fancy. A single button and a status line will do:
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Wake-word demo</title></head>
<body>
<button id="start">Start listening</button>
<p id="status">Idle</p>
<script type="module" src="./app.js"></script>
</body>
</html>
Serve this over HTTPS or localhost. Microphone access does not work on plain http:// except for localhost.
Step 3: initialize the engine and load the model
Create app.js:
import init, { WakeWordEngine } from
"https://unpkg.com/@voxrt/wake-word-browser@0.1.1/voxrt-wake-word-browser.js";
await init();
const modelBytes = new Uint8Array(await (await fetch(
"https://unpkg.com/@voxrt/wake-word-browser@0.1.1/voxrt_wake_word.vxrt"
)).arrayBuffer());
const engine = WakeWordEngine.fromBytes(modelBytes);
engine.threshold = 0.9;
That is the whole "load the model" step. The .vxrt file is the pre-trained "Hey Assistant" model at ~100 KB. init() bootstraps the WebAssembly runtime.
The threshold is in sigmoid space [0, 1]. Default is 0.9. Lower it and you get more sensitive detection (more false accepts). Raise it and you get fewer false accepts but might miss quieter or accented pronunciations. We will tune this later.
Step 4: capture microphone audio
Wake-word models need 16 kHz mono audio. Modern browsers give you higher sample rates by default, so we ask AudioContext for 16 kHz explicitly. Not all browsers honor the request, so we also check:
document.getElementById("start").onclick = async () => {
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
const audioContext = new AudioContext({sampleRate: 16000});
if (audioContext.sampleRate !== 16000) {
console.warn(`Got ${audioContext.sampleRate} Hz instead of 16000. Detection quality will drop. Resample or check the SDK docs.`);
}
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(512, 1, 1);
const mute = audioContext.createGain();
mute.gain.value = 0;
// detection loop goes here (Step 5)
source.connect(processor);
processor.connect(mute).connect(audioContext.destination);
document.getElementById("status").textContent = "Listening";
};
Two notes on the code above:
-
ScriptProcessorNodeis deprecated in favor ofAudioWorkletNodefor production. For a 5-minute demo,ScriptProcessoris simpler. Swap it out when you productionize - The muted
GainNodebetween processor and destination is a Web Audio quirk. Without connecting the processor to something,audioprocessnever fires. Setting gain to 0 avoids sending your microphone straight to the speakers (which would cause feedback)
Step 5: push audio frames and detect
The engine wants Int16 PCM samples in fixed-size chunks. Here is the detection loop:
const pcmBuffer = new Int16Array(512);
processor.addEventListener("audioprocess", (e) => {
const inputFloat = e.inputBuffer.getChannelData(0);
// convert Float32 [-1, 1] to Int16
for (let i = 0; i < inputFloat.length; i++) {
const s = Math.max(-1, Math.min(1, inputFloat[i]));
pcmBuffer[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
// push to engine, iterate detections
for (const detection of engine.pushPcmI16(pcmBuffer)) {
console.log(`Wake detected at ${detection.timestampSec.toFixed(3)}s, score ${detection.score.toFixed(4)}`);
document.getElementById("status").textContent = "Wake-word detected";
}
});
That is it. Open DevTools (F12, then Console), click the button, say "Hey Assistant", and you should see a log line. The detection.score is the sigmoid probability at the moment of detection.
What just happened
You loaded a ~100 KB model into WebAssembly, streamed 16 kHz mono audio through it in 512-sample frames (32 ms each), and got detections when the score crossed threshold.
The engine handles its own cooldown so you do not get spam detections on the same utterance. Default cooldown is 100 frames (about 1 second at 10 ms hop). You can tune with engine.cooldownFrames.
Two quick customizations
Prefer Float32 over Int16? Skip the conversion:
processor.addEventListener("audioprocess", (e) => {
const inputFloat = e.inputBuffer.getChannelData(0);
for (const detection of engine.pushPcmF32(inputFloat)) {
console.log(`Detected: ${detection.score.toFixed(4)}`);
}
});
Tuning threshold: Watch engine.currentScore() to see live confidence. If your users often speak quietly, lower the threshold to 0.85. If you get false triggers from radio or TV, raise it to 0.93.
setInterval(() => {
console.log(`Live score: ${engine.currentScore().toFixed(3)}`);
}, 100);
At the default threshold of 0.9, the model has precision 0.993 and recall 0.982 on the reference test split (11,656 utterances, ROC AUC 0.9966).
Full code (copy-paste starter)
Here is everything above, glued together:
import init, { WakeWordEngine } from
"https://unpkg.com/@voxrt/wake-word-browser@0.1.1/voxrt-wake-word-browser.js";
await init();
const modelBytes = new Uint8Array(await (await fetch(
"https://unpkg.com/@voxrt/wake-word-browser@0.1.1/voxrt_wake_word.vxrt"
)).arrayBuffer());
const engine = WakeWordEngine.fromBytes(modelBytes);
engine.threshold = 0.9;
document.getElementById("start").onclick = async () => {
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
const audioContext = new AudioContext({sampleRate: 16000});
if (audioContext.sampleRate !== 16000) {
console.warn(`Got ${audioContext.sampleRate} Hz instead of 16000. Detection quality will drop.`);
}
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(512, 1, 1);
const mute = audioContext.createGain();
mute.gain.value = 0;
const pcmBuffer = new Int16Array(512);
processor.addEventListener("audioprocess", (e) => {
const inputFloat = e.inputBuffer.getChannelData(0);
for (let i = 0; i < inputFloat.length; i++) {
const s = Math.max(-1, Math.min(1, inputFloat[i]));
pcmBuffer[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
for (const detection of engine.pushPcmI16(pcmBuffer)) {
console.log(`Wake at ${detection.timestampSec.toFixed(3)}s score ${detection.score.toFixed(4)}`);
document.getElementById("status").textContent = "Wake-word detected";
}
});
source.connect(processor);
processor.connect(mute).connect(audioContext.destination);
document.getElementById("status").textContent = "Listening";
};
Serve this alongside the HTML from Step 2 and you have a working wake-word demo.
Where to go from here
-
Custom phrase or another language: the free tier ships with "Hey Assistant" only. Custom phrases and additional languages are available on a paid tier. We train the model for you and swap the
.vxrtfile - Native mobile: same SDK exists for iOS (Swift) and Android (Kotlin). Wake-word runs at 1.5% RTF on iPhone A15, 2.1% on Snapdragon 662
- Linux and edge: available for Linux aarch64 with bindings for Python, Node.js, Go, and C. Holds at 5.3% RTF sustained on a $15 Raspberry Pi Zero 2 W
- Custom on-device pipeline: wake-word into ASR into your app logic works well for privacy-critical voice UIs
One note on licensing
The wrapper (Rust crate, wasm-bindgen bindings, examples) is Apache-2.0 so you can freely integrate it. The compiled WebAssembly runtime and the model weights are proprietary. Redistribution is allowed only as an unmodified part of the SDK package. In practical terms: install and use it in your product, no additional legal setup beyond reading the license.
Live demo: voxrt.com/wake-word-demo
Top comments (0)