mirror of
https://github.com/dscripka/openWakeWord.git
synced 2026-08-27 18:17:20 -04:00
Merge 1c76cd9b66 into 368c03716d
This commit is contained in:
commit
04c4a5baf4
18 changed files with 1931 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -142,3 +142,4 @@ examples/
|
|||
|
||||
# archive files
|
||||
archive/
|
||||
node_modules/
|
||||
|
|
|
|||
3
web/.gitignore
vendored
Normal file
3
web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
models/
|
||||
*.onnx
|
||||
199
web/LICENSE
Normal file
199
web/LICENSE
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
269
web/README.md
Normal file
269
web/README.md
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
# openWakeWord — native browser port
|
||||
|
||||
A native **JavaScript / browser** port of [openWakeWord](../README.md). Wake word
|
||||
detection runs **fully client-side** in the browser using
|
||||
[ONNX Runtime Web](https://onnxruntime.ai/docs/get-started/with-javascript/web.html) —
|
||||
no Python server, no websocket streaming.
|
||||
|
||||
It loads the exact same ONNX models as the Python package (melspectrogram,
|
||||
speech embedding, and the pre-trained wake word models) and faithfully
|
||||
reimplements the streaming feature pipeline (`AudioFeatures`) and prediction
|
||||
logic (`Model.predict`) in JavaScript.
|
||||
|
||||
> Looking for the server-based approach instead? See
|
||||
> `[examples/web](../examples/web)` for streaming microphone audio over a
|
||||
> websocket into openWakeWord running in a Python backend.
|
||||
|
||||
|
||||
|
||||
## Pipeline
|
||||
|
||||
```
|
||||
mic (16 kHz, 16-bit PCM)
|
||||
└─ melspectrogram.onnx → mel features (32 bins), transformed x/10 + 2
|
||||
└─ embedding_model.onnx → 96-dim speech embeddings (76-frame windows, step 8)
|
||||
└─ <wakeword>.onnx → detection score 0..1
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install # installs onnxruntime-web
|
||||
npm run download-models # fetches the ONNX models into ./models
|
||||
npm run serve # or any static file server
|
||||
# open http://localhost:8080/demo/
|
||||
```
|
||||
|
||||
Then open the page, click **Start Listening**, and say *"hey jarvis"*,
|
||||
*"alexa"*, *"hey mycroft"*, or *"hey rhasspy"*.
|
||||
|
||||
> The demo loads `onnxruntime-web` from a CDN via an
|
||||
> [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap),
|
||||
> so it works without a bundler. A static server is still required because ES
|
||||
> modules and the AudioWorklet cannot be loaded from `file://`.
|
||||
|
||||
|
||||
|
||||
### Two hard requirements (browser rules)
|
||||
|
||||
- **Must be served from** `http://localhost` **or** `https://`**.** ES modules,
|
||||
`AudioWorklet`, and microphone access all require a real, secure origin, so
|
||||
opening the file directly via `file://` will not work.
|
||||
- **Model files must be same-origin** (or served with CORS headers). The
|
||||
upstream GitHub release assets do **not** send `Access-Control-Allow-Origin`,
|
||||
so you cannot point `baseUrl` directly at the GitHub release — host the
|
||||
`.onnx` files yourself (which is what `npm run download-models` sets up).
|
||||
|
||||
|
||||
|
||||
## Library usage
|
||||
|
||||
```bash
|
||||
npm install openwakeword-web
|
||||
```
|
||||
|
||||
```js
|
||||
import { OpenWakeWord } from "openwakeword-web";
|
||||
import { Microphone } from "openwakeword-web/microphone";
|
||||
|
||||
const oww = await OpenWakeWord.create({
|
||||
baseUrl: "./models/",
|
||||
wakewordModels: ["hey_jarvis", "alexa"], // or omit for all pre-trained models
|
||||
threshold: 0.5,
|
||||
onDetection: ({ label, score }) => {
|
||||
console.log(`Wake word detected: ${label} (score ${score.toFixed(2)})`);
|
||||
},
|
||||
});
|
||||
|
||||
const mic = new Microphone(async (frame) => {
|
||||
// frame is an Int16Array of 1280 samples (80 ms @ 16 kHz)
|
||||
await oww.predict(frame); // onDetection fires automatically when threshold is met
|
||||
});
|
||||
|
||||
await mic.start();
|
||||
```
|
||||
|
||||
`predict()` accepts any `Int16Array` of 16 kHz PCM audio (ideally multiples of
|
||||
1280 samples) and returns a `{ label: score }` map, matching the Python API.
|
||||
The `onDetection` callback fires inside `predict()` for every label whose score
|
||||
meets `threshold` — so you don't have to poll the returned scores yourself.
|
||||
|
||||
Both `threshold` and `onDetection` can be updated at runtime:
|
||||
|
||||
```js
|
||||
oww.threshold = 0.75;
|
||||
oww.onDetection = ({ label }) => triggerAssistant(label);
|
||||
```
|
||||
|
||||
If you prefer the polling style, simply omit `onDetection` and inspect the
|
||||
scores returned by `predict()` directly:
|
||||
|
||||
```js
|
||||
const scores = await oww.predict(frame);
|
||||
if (scores["hey_jarvis"] >= 0.5) console.log("detected hey jarvis!");
|
||||
```
|
||||
|
||||
> **Bundlers:** `Microphone` loads its AudioWorklet via
|
||||
> `new URL("./mic-worklet.js", import.meta.url)`, which Vite and webpack 5
|
||||
> handle automatically. If your bundler does not emit the worklet asset, copy
|
||||
> `mic-worklet.js` somewhere same-origin and pass it explicitly:
|
||||
> `new Microphone(onFrame, { workletUrl: "/mic-worklet.js" })`.
|
||||
|
||||
> TypeScript type definitions (`.d.ts`) ship with the package, so the API is
|
||||
> fully typed out of the box.
|
||||
|
||||
|
||||
|
||||
### Usage in React (Vite)
|
||||
|
||||
Place the `.onnx` model files in `public/assets/models/` so Vite serves them
|
||||
as static assets, then use a `useEffect` hook to own the lifecycle:
|
||||
|
||||
```tsx
|
||||
// src/hooks/useWakeWord.ts
|
||||
import { useEffect, useRef } from "react";
|
||||
import { OpenWakeWord, configureOrt } from "openwakeword-web";
|
||||
import { Microphone } from "openwakeword-web/microphone";
|
||||
|
||||
export function useWakeWord(
|
||||
onDetection: (label: string, score: number) => void,
|
||||
wakewordModels = ["hey_jarvis", "alexa"],
|
||||
threshold = 0.5,
|
||||
) {
|
||||
// Keep a stable ref so the callback never causes re-initialisation.
|
||||
const onDetectionRef = useRef(onDetection);
|
||||
onDetectionRef.current = onDetection;
|
||||
|
||||
useEffect(() => {
|
||||
let oww: OpenWakeWord | null = null;
|
||||
let mic: Microphone | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
configureOrt({ numThreads: 1 }); // avoid COOP/COEP requirement
|
||||
|
||||
OpenWakeWord.create({
|
||||
baseUrl: "/assets/models/",
|
||||
wakewordModels,
|
||||
threshold,
|
||||
onDetection: ({ label, score }) => onDetectionRef.current(label, score),
|
||||
}).then((instance) => {
|
||||
if (cancelled) return;
|
||||
oww = instance;
|
||||
mic = new Microphone(async (frame) => {
|
||||
await oww!.predict(frame);
|
||||
});
|
||||
mic.start();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mic?.stop();
|
||||
oww?.reset();
|
||||
};
|
||||
}, [threshold, ...wakewordModels]); // re-init if config changes
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// src/App.tsx
|
||||
import { useCallback } from "react";
|
||||
import { useWakeWord } from "./hooks/useWakeWord";
|
||||
|
||||
export default function App() {
|
||||
const handleDetection = useCallback((label: string, score: number) => {
|
||||
console.log(`Detected: ${label} (${score.toFixed(2)})`);
|
||||
}, []);
|
||||
|
||||
useWakeWord(handleDetection, ["hey_jarvis", "alexa"], 0.5);
|
||||
|
||||
return <div>Listening for wake words…</div>;
|
||||
}
|
||||
```
|
||||
|
||||
**Model files** — copy the `.onnx` files into `public/assets/models/`:
|
||||
|
||||
```
|
||||
public/
|
||||
assets/
|
||||
models/
|
||||
melspectrogram.onnx
|
||||
embedding_model.onnx
|
||||
hey_jarvis_v0.1.onnx
|
||||
alexa_v0.1.onnx
|
||||
```
|
||||
|
||||
You can copy them from the `web/models/` directory after running
|
||||
`npm run download-models` in this package, or point `baseUrl` at any
|
||||
same-origin path that serves the files with correct CORS headers.
|
||||
|
||||
|
||||
|
||||
### Custom models
|
||||
|
||||
```js
|
||||
const oww = await OpenWakeWord.create({
|
||||
wakewordModels: [
|
||||
"alexa", // pre-trained, by name
|
||||
{ name: "my_word", url: "/models/my_word.onnx" }, // your own trained model
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Using your own ONNX runtime / wasm hosting
|
||||
|
||||
```js
|
||||
import { configureOrt } from "./src/openwakeword.js";
|
||||
configureOrt({ wasmPaths: "/ort/", numThreads: 1 });
|
||||
```
|
||||
|
||||
Multi-threaded wasm requires the page to be cross-origin isolated
|
||||
(`COOP`/`COEP` headers). The demo uses `numThreads: 1` to avoid that
|
||||
requirement.
|
||||
|
||||
## Layout
|
||||
|
||||
|
||||
| Path | Purpose |
|
||||
| ----------------------------- | ------------------------------------------------------- |
|
||||
| `src/openwakeword.js` | Main `OpenWakeWord` class — model loading + `predict()` |
|
||||
| `src/audio-features.js` | Streaming melspectrogram + embedding pipeline |
|
||||
| `src/models.js` | Pre-trained model registry + class mappings |
|
||||
| `src/microphone.js` | Mic capture helper (16 kHz `AudioContext` + worklet) |
|
||||
| `src/mic-worklet.js` | AudioWorklet: float → 16-bit PCM framing |
|
||||
| `demo/index.html` | Live detection demo UI (uses the `src/` modules) |
|
||||
| `scripts/download-models.mjs` | Downloads the ONNX models from the GitHub release |
|
||||
| `test/verify.mjs` | Node script that runs the port on the repo's test clips |
|
||||
|
||||
|
||||
|
||||
|
||||
## Verification
|
||||
|
||||
`test/verify.mjs` streams the repository's test clips
|
||||
(`tests/data/alexa_test.wav`, `tests/data/hey_mycroft_test.wav`) through the
|
||||
port under Node and checks the correct wake word fires:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm test
|
||||
# alexa_test.wav → alexa = 1.000 PASS
|
||||
# hey_mycroft_test.wav → hey_mycroft = 1.000 PASS
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Browser support
|
||||
|
||||
Requires `AudioWorklet`, ES modules, and `AudioContext({ sampleRate: 16000 })`
|
||||
— supported by current Chrome, Edge, Firefox, and Safari. A secure context
|
||||
(`https://` or `localhost`) is required for microphone access.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0, same as openWakeWord. The bundled model files are downloaded from
|
||||
the upstream openWakeWord releases and retain their original licenses.
|
||||
262
web/demo/index.html
Normal file
262
web/demo/index.html
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>openWakeWord — native browser demo</title>
|
||||
|
||||
<!-- Map the bare "onnxruntime-web" import to the CDN ESM build so the
|
||||
library works without a bundler. Swap for a local copy if desired. -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"onnxruntime-web": "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/ort.mjs"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--card: #161b22;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--accent: #2f81f7;
|
||||
--green: #3fb950;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.wrap { max-width: 760px; margin: 0 auto; padding: 32px 20px 64px; }
|
||||
h1 { font-size: 28px; margin: 0 0 4px; }
|
||||
.sub { color: var(--muted); margin: 0 0 24px; }
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
button {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 12px 22px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
transition: opacity 0.2s, background 0.2s;
|
||||
}
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
button.listening { background: var(--green); }
|
||||
.row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||||
.status { color: var(--muted); font-size: 14px; }
|
||||
label.opt { display: inline-flex; align-items: center; gap: 6px; margin-right: 14px; font-size: 14px; }
|
||||
.models { display: grid; gap: 14px; }
|
||||
.model {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr 56px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.model .name { font-weight: 600; }
|
||||
.model .detected { color: var(--green); font-weight: 700; }
|
||||
.bar {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
background: #21262d;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar > span {
|
||||
position: absolute; left: 0; top: 0; bottom: 0;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, #2f81f7, #3fb950);
|
||||
transition: width 0.08s linear;
|
||||
}
|
||||
.bar.fired > span { background: var(--green); }
|
||||
.score { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); font-size: 13px; }
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--green); color: #04130a; font-weight: 700;
|
||||
padding: 14px 24px; border-radius: 10px; opacity: 0;
|
||||
transition: opacity 0.3s; pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
code { background: #21262d; padding: 2px 6px; border-radius: 5px; font-size: 13px; }
|
||||
.hint { font-size: 13px; color: var(--muted); margin-top: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>openWakeWord</h1>
|
||||
<p class="sub">Native browser port — wake word detection running fully client-side with ONNX Runtime Web.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<button id="startBtn" disabled>Loading models…</button>
|
||||
<span class="status" id="status">Initializing…</span>
|
||||
</div>
|
||||
<div class="hint">
|
||||
Threshold:
|
||||
<label class="opt"><input type="range" id="threshold" min="0" max="1" step="0.01" value="0.5" /> <span id="thVal">0.50</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="models" id="models"></div>
|
||||
<p class="hint">Try saying <code>"hey jarvis"</code>, <code>"alexa"</code>, <code>"hey mycroft"</code>, or <code>"hey rhasspy"</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script type="module">
|
||||
import { OpenWakeWord, configureOrt } from "../src/openwakeword.js";
|
||||
import { Microphone } from "../src/microphone.js";
|
||||
|
||||
const startBtn = document.getElementById("startBtn");
|
||||
const statusEl = document.getElementById("status");
|
||||
const modelsEl = document.getElementById("models");
|
||||
const toastEl = document.getElementById("toast");
|
||||
const thresholdEl = document.getElementById("threshold");
|
||||
const thValEl = document.getElementById("thVal");
|
||||
|
||||
thresholdEl.addEventListener("input", () => {
|
||||
thValEl.textContent = (+thresholdEl.value).toFixed(2);
|
||||
});
|
||||
|
||||
// Use single-threaded wasm so the demo works without COOP/COEP headers.
|
||||
configureOrt({
|
||||
wasmPaths: "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/",
|
||||
numThreads: 1,
|
||||
});
|
||||
|
||||
// Which wake word models to load in the demo.
|
||||
const WAKEWORDS = ["alexa", "hey_mycroft", "hey_jarvis", "hey_rhasspy"];
|
||||
|
||||
const rows = {};
|
||||
function buildRows(labels) {
|
||||
modelsEl.innerHTML = "";
|
||||
for (const label of labels) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "model";
|
||||
el.innerHTML = `
|
||||
<div class="name">${label}</div>
|
||||
<div class="bar"><span></span></div>
|
||||
<div class="score">0.00</div>`;
|
||||
modelsEl.appendChild(el);
|
||||
rows[label] = {
|
||||
bar: el.querySelector(".bar"),
|
||||
fill: el.querySelector(".bar > span"),
|
||||
score: el.querySelector(".score"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let lastToast = 0;
|
||||
function showToast(text) {
|
||||
toastEl.textContent = `Detected: ${text}`;
|
||||
toastEl.classList.add("show");
|
||||
clearTimeout(lastToast);
|
||||
lastToast = setTimeout(() => toastEl.classList.remove("show"), 1500);
|
||||
}
|
||||
|
||||
let oww, mic;
|
||||
let busy = false;
|
||||
const queue = [];
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
oww = await OpenWakeWord.create({
|
||||
baseUrl: "../models/",
|
||||
wakewordModels: WAKEWORDS,
|
||||
});
|
||||
buildRows(oww.modelNames);
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = "Start Listening";
|
||||
statusEl.textContent = "Models loaded. Click to start.";
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
statusEl.textContent =
|
||||
"Failed to load models. Run `npm run download-models` in /web first. " +
|
||||
err.message;
|
||||
}
|
||||
}
|
||||
|
||||
let frameCount = 0;
|
||||
function rms(frame) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < frame.length; i++) sum += frame[i] * frame[i];
|
||||
return Math.sqrt(sum / frame.length) / 32768; // 0..1
|
||||
}
|
||||
|
||||
async function drain() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
while (queue.length) {
|
||||
const frame = queue.shift();
|
||||
frameCount++;
|
||||
const level = Math.round(rms(frame) * 100);
|
||||
statusEl.textContent = `Listening @ ${mic.sampleRate} Hz · frames ${frameCount} · mic level ${level}%`;
|
||||
let scores;
|
||||
try {
|
||||
scores = await oww.predict(frame);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
continue;
|
||||
}
|
||||
const th = +thresholdEl.value;
|
||||
for (const [label, score] of Object.entries(scores)) {
|
||||
const r = rows[label];
|
||||
if (!r) continue;
|
||||
r.fill.style.width = `${Math.min(100, score * 100)}%`;
|
||||
r.score.textContent = score.toFixed(2);
|
||||
if (score >= th) {
|
||||
r.bar.classList.add("fired");
|
||||
showToast(label);
|
||||
setTimeout(() => r.bar.classList.remove("fired"), 600);
|
||||
}
|
||||
}
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
|
||||
let listening = false;
|
||||
startBtn.addEventListener("click", async () => {
|
||||
if (!listening) {
|
||||
mic = new Microphone((frame) => {
|
||||
queue.push(frame);
|
||||
drain();
|
||||
});
|
||||
try {
|
||||
await mic.start();
|
||||
} catch (e) {
|
||||
statusEl.textContent = "Microphone access denied: " + e.message;
|
||||
return;
|
||||
}
|
||||
listening = true;
|
||||
startBtn.classList.add("listening");
|
||||
startBtn.textContent = "Stop";
|
||||
statusEl.textContent = `Listening @ ${mic.sampleRate} Hz…`;
|
||||
} else {
|
||||
await mic.stop();
|
||||
await oww.reset();
|
||||
listening = false;
|
||||
startBtn.classList.remove("listening");
|
||||
startBtn.textContent = "Start Listening";
|
||||
statusEl.textContent = "Stopped.";
|
||||
}
|
||||
});
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
158
web/package-lock.json
generated
Normal file
158
web/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
{
|
||||
"name": "openwakeword-web",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openwakeword-web",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"onnxruntime-web": "^1.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/base64": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
|
||||
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
|
||||
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/float": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/pool": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
|
||||
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz",
|
||||
"integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/flatbuffers": {
|
||||
"version": "25.9.23",
|
||||
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
|
||||
"integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/guid-typescript": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
|
||||
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/onnxruntime-common": {
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.27.0.tgz",
|
||||
"integrity": "sha512-3KxL5wIVqa8Ex08jxSzncm9CMgw8CjOFyOQ7SxvG9o0cVLlhTNKXyIQuTbtX4tGPJEf73OER2xrjt4HJSBL4ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/onnxruntime-web": {
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.27.0.tgz",
|
||||
"integrity": "sha512-ogDLsqIozHZwifPuN37OproAo0byX6t43/bP8GzeZWBWD6MOGExswFAx3up4NS/vvWBOg2u2PXomDt3rMmdQSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flatbuffers": "^25.1.24",
|
||||
"guid-typescript": "^1.0.9",
|
||||
"long": "^5.2.3",
|
||||
"onnxruntime-common": "1.27.0",
|
||||
"platform": "^1.3.6",
|
||||
"protobufjs": "^7.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/platform": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
|
||||
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.6.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
|
||||
"integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.5",
|
||||
"@protobufjs/eventemitter": "^1.1.1",
|
||||
"@protobufjs/fetch": "^1.1.1",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.1",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
65
web/package.json
Normal file
65
web/package.json
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"name": "openwakeword-web",
|
||||
"version": "0.1.0",
|
||||
"description": "Native browser (JavaScript) port of openWakeWord. Runs wake word detection fully client-side using ONNX Runtime Web.",
|
||||
"type": "module",
|
||||
"main": "src/openwakeword.js",
|
||||
"types": "src/openwakeword.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/openwakeword.d.ts",
|
||||
"import": "./src/openwakeword.js"
|
||||
},
|
||||
"./microphone": {
|
||||
"types": "./src/microphone.d.ts",
|
||||
"import": "./src/microphone.js"
|
||||
},
|
||||
"./audio-features": {
|
||||
"types": "./src/audio-features.d.ts",
|
||||
"import": "./src/audio-features.js"
|
||||
},
|
||||
"./models": {
|
||||
"types": "./src/models.d.ts",
|
||||
"import": "./src/models.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"download-models": "node ./scripts/download-models.mjs",
|
||||
"serve": "python3 -m http.server 8080",
|
||||
"test": "node ./test/verify.mjs"
|
||||
},
|
||||
"keywords": [
|
||||
"wakeword",
|
||||
"wake-word",
|
||||
"openwakeword",
|
||||
"speech",
|
||||
"voice",
|
||||
"onnx",
|
||||
"onnxruntime-web",
|
||||
"browser",
|
||||
"webaudio",
|
||||
"audioworklet"
|
||||
],
|
||||
"author": "Kousthub Raja (https://github.com/kousthubraja)",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://github.com/dscripka/openWakeWord/tree/main/web#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dscripka/openWakeWord.git",
|
||||
"directory": "web"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/dscripka/openWakeWord/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"onnxruntime-web": "^1.27.0"
|
||||
}
|
||||
}
|
||||
59
web/scripts/download-models.mjs
Normal file
59
web/scripts/download-models.mjs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env node
|
||||
// Downloads the ONNX versions of the openWakeWord models into ./models so the
|
||||
// browser port can run fully client-side. These are the exact same model files
|
||||
// used by the Python package (the .onnx assets from the v0.5.1 GitHub release).
|
||||
|
||||
import { mkdir, writeFile, access } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const BASE =
|
||||
"https://github.com/dscripka/openWakeWord/releases/download/v0.5.1";
|
||||
|
||||
// Feature models (always required) + the pre-trained wake word models.
|
||||
const MODELS = [
|
||||
"melspectrogram.onnx",
|
||||
"embedding_model.onnx",
|
||||
"silero_vad.onnx",
|
||||
"alexa_v0.1.onnx",
|
||||
"hey_mycroft_v0.1.onnx",
|
||||
"hey_jarvis_v0.1.onnx",
|
||||
"hey_rhasspy_v0.1.onnx",
|
||||
"timer_v0.1.onnx",
|
||||
"weather_v0.1.onnx",
|
||||
];
|
||||
|
||||
// Models live in web/models; this script lives in web/scripts.
|
||||
const outDir = join(dirname(fileURLToPath(import.meta.url)), "..", "models");
|
||||
|
||||
async function exists(p) {
|
||||
try {
|
||||
await access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function download(name) {
|
||||
const dest = join(outDir, name);
|
||||
if (await exists(dest)) {
|
||||
console.log(`✓ ${name} (already present)`);
|
||||
return;
|
||||
}
|
||||
const url = `${BASE}/${name}`;
|
||||
process.stdout.write(`↓ ${name} ... `);
|
||||
const res = await fetch(url, { redirect: "follow" });
|
||||
if (!res.ok) {
|
||||
throw new Error(`failed to download ${url} (HTTP ${res.status})`);
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await writeFile(dest, buf);
|
||||
console.log(`done (${(buf.length / 1024).toFixed(0)} KB)`);
|
||||
}
|
||||
|
||||
await mkdir(outDir, { recursive: true });
|
||||
for (const m of MODELS) {
|
||||
await download(m);
|
||||
}
|
||||
console.log(`\nModels saved to ${outDir}`);
|
||||
40
web/src/audio-features.d.ts
vendored
Normal file
40
web/src/audio-features.d.ts
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { InferenceSession } from "onnxruntime-web";
|
||||
|
||||
export const MEL_BINS: number;
|
||||
export const EMBED_DIM: number;
|
||||
export const WINDOW_SIZE: number;
|
||||
export const STEP_SIZE: number;
|
||||
export const CHUNK: number;
|
||||
|
||||
/** Tensor-ready features: a flat Float32Array plus its dimensions. */
|
||||
export interface Features {
|
||||
data: Float32Array;
|
||||
dims: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming melspectrogram + speech-embedding feature pipeline. Faithful port
|
||||
* of `openwakeword.utils.AudioFeatures`. All audio is 16-bit PCM @ 16 kHz.
|
||||
*/
|
||||
export class AudioFeatures {
|
||||
constructor(
|
||||
melspecSession: InferenceSession,
|
||||
embeddingSession: InferenceSession,
|
||||
opts?: { sampleRate?: number }
|
||||
);
|
||||
|
||||
/** Reset all internal streaming buffers. Call `warmup()` afterwards. */
|
||||
reset(skipWarmup?: boolean): void;
|
||||
|
||||
/** Seed the feature buffer with embeddings of ~4 s of audio. Await once after construction/reset. */
|
||||
warmup(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Feed 16-bit PCM @ 16 kHz audio (ideally multiples of 1280 samples).
|
||||
* @returns the number of samples processed this call.
|
||||
*/
|
||||
streamingFeatures(x: Int16Array): Promise<number>;
|
||||
|
||||
/** Most recent feature frames as a tensor-ready `{ data, dims }`. */
|
||||
getFeatures(nFeatureFrames?: number, startNdx?: number): Features;
|
||||
}
|
||||
249
web/src/audio-features.js
Normal file
249
web/src/audio-features.js
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import * as ort from "onnxruntime-web";
|
||||
|
||||
// Faithful JavaScript port of `openwakeword.utils.AudioFeatures`.
|
||||
//
|
||||
// Implements the streaming melspectrogram + Google speech_embedding feature
|
||||
// pipeline. All audio is 16-bit PCM @ 16 kHz, exactly like the Python library.
|
||||
//
|
||||
// raw int16 audio -> melspectrogram model -> (frames x 32) mel features
|
||||
// -> embedding model -> (frames x 96) embeddings
|
||||
//
|
||||
// The embeddings are what the wake word models consume.
|
||||
|
||||
const MEL_BINS = 32;
|
||||
const EMBED_DIM = 96;
|
||||
const WINDOW_SIZE = 76; // mel frames per embedding window
|
||||
const STEP_SIZE = 8; // mel frames produced per 1280-sample (80 ms) chunk
|
||||
const CHUNK = 1280; // samples per processing step (80 ms @ 16 kHz)
|
||||
|
||||
export class AudioFeatures {
|
||||
/**
|
||||
* @param {ort.InferenceSession} melspecSession
|
||||
* @param {ort.InferenceSession} embeddingSession
|
||||
*/
|
||||
constructor(melspecSession, embeddingSession, { sampleRate = 16000 } = {}) {
|
||||
this.melspecSession = melspecSession;
|
||||
this.embeddingSession = embeddingSession;
|
||||
this.sampleRate = sampleRate;
|
||||
|
||||
this.melspecInputName = melspecSession.inputNames[0]; // "input"
|
||||
this.embeddingInputName = embeddingSession.inputNames[0]; // "input_1"
|
||||
|
||||
this.rawDataMaxLen = sampleRate * 10;
|
||||
this.melspectrogramMaxLen = 10 * 97; // ~10 s of mel frames
|
||||
this.featureBufferMaxLen = 120; // ~10 s of embedding history
|
||||
|
||||
this.reset(/* skipWarmup */ true);
|
||||
}
|
||||
|
||||
/** Reset all internal streaming buffers. */
|
||||
reset(skipWarmup = false) {
|
||||
this.rawDataBuffer = []; // numbers (int16 samples)
|
||||
this.rawDataRemainder = new Int16Array(0);
|
||||
this.accumulatedSamples = 0;
|
||||
// Mel buffer is seeded with ones, matching np.ones((76, 32)).
|
||||
this.melBuffer = [];
|
||||
for (let i = 0; i < WINDOW_SIZE; i++) {
|
||||
this.melBuffer.push(new Float32Array(MEL_BINS).fill(1));
|
||||
}
|
||||
// Feature buffer is seeded later by warmup() (needs async model calls).
|
||||
this.featureBuffer = [];
|
||||
if (!skipWarmup) {
|
||||
// synchronous reset cannot run the models; callers should await warmup()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the feature buffer with the embeddings of 4 s of (random) audio, as
|
||||
* the Python implementation does on init/reset. Must be awaited once after
|
||||
* construction (and after reset()).
|
||||
*/
|
||||
async warmup() {
|
||||
const audio = new Int16Array(this.sampleRate * 4);
|
||||
for (let i = 0; i < audio.length; i++) {
|
||||
audio[i] = Math.floor(Math.random() * 2000 - 1000);
|
||||
}
|
||||
this.featureBuffer = await this._getEmbeddings(audio);
|
||||
}
|
||||
|
||||
// --- melspectrogram -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the melspectrogram of int16 audio.
|
||||
* Returns an array of Float32Array rows, shape (frames, 32), with the
|
||||
* `x / 10 + 2` transform applied (matches the Python default).
|
||||
* @param {Int16Array} int16
|
||||
*/
|
||||
async _getMelspectrogram(int16) {
|
||||
const x = Float32Array.from(int16); // int16 magnitudes as float (NOT normalized)
|
||||
const tensor = new ort.Tensor("float32", x, [1, x.length]);
|
||||
const out = await this.melspecSession.run({ [this.melspecInputName]: tensor });
|
||||
const o = out[this.melspecSession.outputNames[0]];
|
||||
const dims = o.dims;
|
||||
const bins = dims[dims.length - 1];
|
||||
const frames = dims[dims.length - 2];
|
||||
const data = o.data;
|
||||
const rows = [];
|
||||
for (let f = 0; f < frames; f++) {
|
||||
const row = new Float32Array(bins);
|
||||
const base = f * bins;
|
||||
for (let b = 0; b < bins; b++) {
|
||||
row[b] = data[base + b] / 10 + 2;
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// --- embeddings -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Run the embedding model over a batch of 76x32 mel windows.
|
||||
* @param {Float32Array[][]} windows array of windows, each window is 76 rows of 32
|
||||
* @returns {Promise<Float32Array[]>} array of 96-dim embedding rows
|
||||
*/
|
||||
async _embedWindows(windows) {
|
||||
const n = windows.length;
|
||||
if (n === 0) return [];
|
||||
const data = new Float32Array(n * WINDOW_SIZE * MEL_BINS);
|
||||
let p = 0;
|
||||
for (const win of windows) {
|
||||
for (let r = 0; r < WINDOW_SIZE; r++) {
|
||||
data.set(win[r], p);
|
||||
p += MEL_BINS;
|
||||
}
|
||||
}
|
||||
const tensor = new ort.Tensor("float32", data, [n, WINDOW_SIZE, MEL_BINS, 1]);
|
||||
const out = await this.embeddingSession.run({
|
||||
[this.embeddingInputName]: tensor,
|
||||
});
|
||||
const o = out[this.embeddingSession.outputNames[0]];
|
||||
const flat = o.data; // length n * 96
|
||||
const rows = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
rows.push(flat.slice(i * EMBED_DIM, i * EMBED_DIM + EMBED_DIM));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute embeddings for a whole audio clip (used for warmup / batch use).
|
||||
* @param {Int16Array} int16
|
||||
*/
|
||||
async _getEmbeddings(int16) {
|
||||
const spec = await this._getMelspectrogram(int16);
|
||||
const windows = [];
|
||||
for (let i = 0; i < spec.length; i += STEP_SIZE) {
|
||||
const window = spec.slice(i, i + WINDOW_SIZE);
|
||||
if (window.length === WINDOW_SIZE) windows.push(window);
|
||||
}
|
||||
return this._embedWindows(windows);
|
||||
}
|
||||
|
||||
// --- streaming ------------------------------------------------------------
|
||||
|
||||
_bufferRawData(int16) {
|
||||
for (let i = 0; i < int16.length; i++) this.rawDataBuffer.push(int16[i]);
|
||||
if (this.rawDataBuffer.length > this.rawDataMaxLen) {
|
||||
this.rawDataBuffer = this.rawDataBuffer.slice(-this.rawDataMaxLen);
|
||||
}
|
||||
}
|
||||
|
||||
async _streamingMelspectrogram(nSamples) {
|
||||
if (this.rawDataBuffer.length < 400) {
|
||||
throw new Error(
|
||||
"The number of input frames must be at least 400 samples @ 16khz (25 ms)!"
|
||||
);
|
||||
}
|
||||
const start = Math.max(0, this.rawDataBuffer.length - (nSamples + 160 * 3));
|
||||
const tail = Int16Array.from(this.rawDataBuffer.slice(start));
|
||||
const rows = await this._getMelspectrogram(tail);
|
||||
for (const row of rows) this.melBuffer.push(row);
|
||||
if (this.melBuffer.length > this.melspectrogramMaxLen) {
|
||||
this.melBuffer = this.melBuffer.slice(-this.melspectrogramMaxLen);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming feature extraction. Feed it 16-bit PCM @ 16 kHz audio frames
|
||||
* (ideally multiples of 1280 samples / 80 ms).
|
||||
* @param {Int16Array} x
|
||||
* @returns {Promise<number>} number of samples that were processed this call
|
||||
*/
|
||||
async streamingFeatures(x) {
|
||||
let processedSamples = 0;
|
||||
|
||||
if (this.rawDataRemainder.length !== 0) {
|
||||
const merged = new Int16Array(this.rawDataRemainder.length + x.length);
|
||||
merged.set(this.rawDataRemainder, 0);
|
||||
merged.set(x, this.rawDataRemainder.length);
|
||||
x = merged;
|
||||
this.rawDataRemainder = new Int16Array(0);
|
||||
}
|
||||
|
||||
if (this.accumulatedSamples + x.length >= CHUNK) {
|
||||
const remainder = (this.accumulatedSamples + x.length) % CHUNK;
|
||||
if (remainder !== 0) {
|
||||
const xEven = x.subarray(0, x.length - remainder);
|
||||
this._bufferRawData(xEven);
|
||||
this.accumulatedSamples += xEven.length;
|
||||
this.rawDataRemainder = x.slice(x.length - remainder);
|
||||
} else {
|
||||
this._bufferRawData(x);
|
||||
this.accumulatedSamples += x.length;
|
||||
this.rawDataRemainder = new Int16Array(0);
|
||||
}
|
||||
} else {
|
||||
this.accumulatedSamples += x.length;
|
||||
this._bufferRawData(x);
|
||||
}
|
||||
|
||||
if (this.accumulatedSamples >= CHUNK && this.accumulatedSamples % CHUNK === 0) {
|
||||
await this._streamingMelspectrogram(this.accumulatedSamples);
|
||||
|
||||
// Compute new embeddings for each newly-available 80 ms chunk.
|
||||
for (let i = this.accumulatedSamples / CHUNK - 1; i >= 0; i--) {
|
||||
const ndx = -STEP_SIZE * i === 0 ? this.melBuffer.length : -STEP_SIZE * i;
|
||||
const endAbs = ndx < 0 ? this.melBuffer.length + ndx : ndx;
|
||||
const startAbs = endAbs - WINDOW_SIZE;
|
||||
if (startAbs >= 0) {
|
||||
const window = this.melBuffer.slice(startAbs, endAbs);
|
||||
const [embedding] = await this._embedWindows([window]);
|
||||
this.featureBuffer.push(embedding);
|
||||
}
|
||||
}
|
||||
|
||||
processedSamples = this.accumulatedSamples;
|
||||
this.accumulatedSamples = 0;
|
||||
}
|
||||
|
||||
if (this.featureBuffer.length > this.featureBufferMaxLen) {
|
||||
this.featureBuffer = this.featureBuffer.slice(-this.featureBufferMaxLen);
|
||||
}
|
||||
|
||||
return processedSamples !== 0 ? processedSamples : this.accumulatedSamples;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the most recent feature frames as a tensor-ready {data, dims}.
|
||||
* Mirrors `AudioFeatures.get_features`.
|
||||
* @param {number} nFeatureFrames
|
||||
* @param {number} startNdx negative index into the feature buffer, or -1
|
||||
*/
|
||||
getFeatures(nFeatureFrames = 16, startNdx = -1) {
|
||||
let frames;
|
||||
if (startNdx !== -1) {
|
||||
const end =
|
||||
startNdx + nFeatureFrames === 0 ? undefined : startNdx + nFeatureFrames;
|
||||
frames = this.featureBuffer.slice(startNdx, end);
|
||||
} else {
|
||||
frames = this.featureBuffer.slice(-nFeatureFrames);
|
||||
}
|
||||
const n = frames.length;
|
||||
const data = new Float32Array(n * EMBED_DIM);
|
||||
for (let i = 0; i < n; i++) data.set(frames[i], i * EMBED_DIM);
|
||||
return { data, dims: [1, n, EMBED_DIM] };
|
||||
}
|
||||
}
|
||||
|
||||
export { MEL_BINS, EMBED_DIM, WINDOW_SIZE, STEP_SIZE, CHUNK };
|
||||
57
web/src/mic-worklet.js
Normal file
57
web/src/mic-worklet.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// AudioWorklet processor that converts the microphone stream into 16-bit PCM
|
||||
// frames of 1280 samples (80 ms @ 16 kHz) and posts them to the main thread.
|
||||
//
|
||||
// It resamples from the AudioContext's native rate (the global `sampleRate`
|
||||
// inside the worklet scope) down/up to 16 kHz using linear interpolation, so
|
||||
// it works even when the browser ignores the requested 16 kHz context rate.
|
||||
|
||||
const TARGET_RATE = 16000;
|
||||
const FRAME = 1280;
|
||||
|
||||
class PCMWorklet extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this._ratio = sampleRate / TARGET_RATE; // input samples per output sample
|
||||
this._buf = new Int16Array(FRAME);
|
||||
this._n = 0;
|
||||
this._tail = new Float32Array(0); // leftover input samples between blocks
|
||||
this._frac = 0; // fractional read position within the current data buffer
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
const channel = inputs[0]?.[0];
|
||||
if (!channel) return true;
|
||||
|
||||
// Prepend any leftover samples needed for cross-block interpolation.
|
||||
let data = channel;
|
||||
if (this._tail.length) {
|
||||
data = new Float32Array(this._tail.length + channel.length);
|
||||
data.set(this._tail, 0);
|
||||
data.set(channel, this._tail.length);
|
||||
}
|
||||
|
||||
const ratio = this._ratio;
|
||||
let t = this._frac;
|
||||
while (Math.floor(t) + 1 < data.length) {
|
||||
const i = Math.floor(t);
|
||||
const frac = t - i;
|
||||
const s = data[i] + (data[i + 1] - data[i]) * frac; // linear interp
|
||||
let v = Math.floor(32767 * s);
|
||||
if (v > 32767) v = 32767;
|
||||
else if (v < -32768) v = -32768;
|
||||
this._buf[this._n++] = v;
|
||||
if (this._n === FRAME) {
|
||||
this.port.postMessage(this._buf.slice());
|
||||
this._n = 0;
|
||||
}
|
||||
t += ratio;
|
||||
}
|
||||
|
||||
const keepFrom = Math.floor(t);
|
||||
this._tail = data.slice(keepFrom);
|
||||
this._frac = t - keepFrom;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("pcm-worklet", PCMWorklet);
|
||||
25
web/src/microphone.d.ts
vendored
Normal file
25
web/src/microphone.d.ts
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
export interface MicrophoneOptions {
|
||||
/**
|
||||
* URL of the AudioWorklet module (`mic-worklet.js`). Defaults to the file
|
||||
* shipped next to this module. Override it if your bundler does not emit the
|
||||
* worklet asset automatically (point it at a copy served same-origin).
|
||||
*/
|
||||
workletUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures microphone audio as 16-bit PCM @ 16 kHz and delivers it in
|
||||
* 1280-sample (80 ms) frames via the `onFrame` callback.
|
||||
*/
|
||||
export class Microphone {
|
||||
constructor(onFrame: (frame: Int16Array) => void, opts?: MicrophoneOptions);
|
||||
|
||||
/** Request mic access and start delivering frames. Requires a secure context. */
|
||||
start(): Promise<void>;
|
||||
|
||||
/** Stop capture and release the microphone. */
|
||||
stop(): Promise<void>;
|
||||
|
||||
/** Actual sample rate of the capture context (should be 16000), or null if not started. */
|
||||
readonly sampleRate: number | null;
|
||||
}
|
||||
74
web/src/microphone.js
Normal file
74
web/src/microphone.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// Helper to capture microphone audio as 16-bit PCM @ 16 kHz frames and feed
|
||||
// them to a callback. Uses an AudioWorklet (see mic-worklet.js).
|
||||
|
||||
export class Microphone {
|
||||
/**
|
||||
* @param {(frame: Int16Array) => void} onFrame called with 1280-sample frames
|
||||
* @param {{workletUrl?: string}} [opts]
|
||||
*/
|
||||
constructor(onFrame, opts = {}) {
|
||||
this.onFrame = onFrame;
|
||||
this.workletUrl =
|
||||
opts.workletUrl ?? new URL("./mic-worklet.js", import.meta.url).href;
|
||||
this.context = null;
|
||||
this.stream = null;
|
||||
this.node = null;
|
||||
this.source = null;
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.context) return;
|
||||
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Prefer a 16 kHz context (the worklet also resamples as a fallback in
|
||||
// case the browser ignores or rejects this hint).
|
||||
const AudioCtx = globalThis.AudioContext || globalThis.webkitAudioContext;
|
||||
try {
|
||||
this.context = new AudioCtx({ sampleRate: 16000 });
|
||||
} catch {
|
||||
this.context = new AudioCtx();
|
||||
}
|
||||
if (this.context.state === "suspended") await this.context.resume();
|
||||
await this.context.audioWorklet.addModule(this.workletUrl);
|
||||
|
||||
this.source = this.context.createMediaStreamSource(this.stream);
|
||||
this.node = new AudioWorkletNode(this.context, "pcm-worklet");
|
||||
this.node.port.onmessage = (e) => this.onFrame(e.data);
|
||||
|
||||
this.source.connect(this.node);
|
||||
// Keep the worklet pulling audio without routing mic to the speakers.
|
||||
const sink = this.context.createGain();
|
||||
sink.gain.value = 0;
|
||||
this.node.connect(sink);
|
||||
sink.connect(this.context.destination);
|
||||
this._sink = sink;
|
||||
}
|
||||
|
||||
/** Actual sample rate of the capture context (should be 16000). */
|
||||
get sampleRate() {
|
||||
return this.context ? this.context.sampleRate : null;
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.node) this.node.port.onmessage = null;
|
||||
try {
|
||||
this.source?.disconnect();
|
||||
this.node?.disconnect();
|
||||
this._sink?.disconnect();
|
||||
} catch {}
|
||||
if (this.stream) this.stream.getTracks().forEach((t) => t.stop());
|
||||
if (this.context) await this.context.close();
|
||||
this.context = null;
|
||||
this.stream = null;
|
||||
this.node = null;
|
||||
this.source = null;
|
||||
}
|
||||
}
|
||||
11
web/src/models.d.ts
vendored
Normal file
11
web/src/models.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/** Feature models required by every wake word model. */
|
||||
export const FEATURE_MODELS: { melspectrogram: string; embedding: string };
|
||||
|
||||
/** Voice-activity-detection models (not yet used by the browser port). */
|
||||
export const VAD_MODELS: { silero_vad: string };
|
||||
|
||||
/** Pre-trained wake word models, keyed by name -> ONNX filename. */
|
||||
export const PRETRAINED_MODELS: Record<string, string>;
|
||||
|
||||
/** Integer-class -> label mappings for multi-class models (e.g. `timer`). */
|
||||
export const MODEL_CLASS_MAPPINGS: Record<string, Record<number, string>>;
|
||||
33
web/src/models.js
Normal file
33
web/src/models.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Registry of the pre-trained openWakeWord models and their class mappings.
|
||||
// Mirrors `openwakeword/__init__.py` from the Python package.
|
||||
|
||||
export const FEATURE_MODELS = {
|
||||
melspectrogram: "melspectrogram.onnx",
|
||||
embedding: "embedding_model.onnx",
|
||||
};
|
||||
|
||||
export const VAD_MODELS = {
|
||||
silero_vad: "silero_vad.onnx",
|
||||
};
|
||||
|
||||
// name -> onnx filename (relative to the models directory / base URL)
|
||||
export const PRETRAINED_MODELS = {
|
||||
alexa: "alexa_v0.1.onnx",
|
||||
hey_mycroft: "hey_mycroft_v0.1.onnx",
|
||||
hey_jarvis: "hey_jarvis_v0.1.onnx",
|
||||
hey_rhasspy: "hey_rhasspy_v0.1.onnx",
|
||||
timer: "timer_v0.1.onnx",
|
||||
weather: "weather_v0.1.onnx",
|
||||
};
|
||||
|
||||
// Integer-class -> label mappings for multi-class models.
|
||||
export const MODEL_CLASS_MAPPINGS = {
|
||||
timer: {
|
||||
1: "1_minute_timer",
|
||||
2: "5_minute_timer",
|
||||
3: "10_minute_timer",
|
||||
4: "20_minute_timer",
|
||||
5: "30_minute_timer",
|
||||
6: "1_hour_timer",
|
||||
},
|
||||
};
|
||||
100
web/src/openwakeword.d.ts
vendored
Normal file
100
web/src/openwakeword.d.ts
vendored
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { AudioFeatures } from "./audio-features.js";
|
||||
|
||||
export * from "./models.js";
|
||||
export { AudioFeatures } from "./audio-features.js";
|
||||
|
||||
/** Options for {@link configureOrt}. */
|
||||
export interface ConfigureOrtOptions {
|
||||
/** Base URL/path for the ONNX Runtime Web wasm binaries. */
|
||||
wasmPaths?: string;
|
||||
/** Number of wasm threads. Use 1 to avoid requiring COOP/COEP headers. */
|
||||
numThreads?: number;
|
||||
/** Enable SIMD wasm. */
|
||||
simd?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the ONNX Runtime Web environment. Call once before
|
||||
* {@link OpenWakeWord.create} to point at self-hosted wasm or tweak threading.
|
||||
*/
|
||||
export function configureOrt(opts?: ConfigureOrtOptions): void;
|
||||
|
||||
/** A custom wake word model supplied by URL. */
|
||||
export interface CustomWakewordModel {
|
||||
name: string;
|
||||
url: string;
|
||||
inputFrames?: number;
|
||||
classMapping?: Record<number, string>;
|
||||
}
|
||||
|
||||
/** Wake word model reference: a pre-trained name, or a custom model by URL. */
|
||||
export type WakewordModelRef = string | CustomWakewordModel;
|
||||
|
||||
/** Payload passed to {@link OpenWakeWordOptions.onDetection}. */
|
||||
export interface DetectionEvent {
|
||||
/** The wake word label that was detected. */
|
||||
label: string;
|
||||
/** Detection score in 0..1. */
|
||||
score: number;
|
||||
}
|
||||
|
||||
/** Options for {@link OpenWakeWord.create}. */
|
||||
export interface OpenWakeWordOptions {
|
||||
/** Base URL/path for model files. Default `"./models/"`. */
|
||||
baseUrl?: string;
|
||||
/**
|
||||
* Wake word models to load. Strings are looked up in the pre-trained registry
|
||||
* (e.g. `"hey_jarvis"`); objects load a custom model by URL. Defaults to all
|
||||
* pre-trained models.
|
||||
*/
|
||||
wakewordModels?: WakewordModelRef[];
|
||||
/** Override the melspectrogram model URL. */
|
||||
melspectrogramUrl?: string;
|
||||
/** Override the embedding model URL. */
|
||||
embeddingUrl?: string;
|
||||
/** ONNX Runtime execution providers. Default `["wasm"]`. */
|
||||
executionProviders?: string[];
|
||||
/** Options forwarded to {@link configureOrt}. */
|
||||
ort?: ConfigureOrtOptions;
|
||||
/**
|
||||
* Score threshold for triggering {@link onDetection}. Default `0.5`.
|
||||
* Also stored as `oww.threshold` so it can be changed at runtime.
|
||||
*/
|
||||
threshold?: number;
|
||||
/**
|
||||
* Called from within {@link OpenWakeWord.predict} whenever a label's score
|
||||
* meets or exceeds {@link threshold}. May be called multiple times per
|
||||
* `predict()` invocation if several labels fire simultaneously.
|
||||
*/
|
||||
onDetection?: (event: DetectionEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native browser port of `openwakeword.Model`. Runs the full melspectrogram ->
|
||||
* embedding -> wake word pipeline client-side using ONNX Runtime Web.
|
||||
*/
|
||||
export class OpenWakeWord {
|
||||
features: AudioFeatures;
|
||||
/** Detection score threshold. Can be updated at runtime. Default `0.5`. */
|
||||
threshold: number;
|
||||
/** Callback fired on detection. Can be replaced at runtime. */
|
||||
onDetection: ((event: DetectionEvent) => void) | null;
|
||||
|
||||
/** Create and initialise a model. */
|
||||
static create(opts?: OpenWakeWordOptions): Promise<OpenWakeWord>;
|
||||
|
||||
/** Names of the loaded wake word models. */
|
||||
readonly modelNames: string[];
|
||||
|
||||
/** Reset all streaming/prediction state. */
|
||||
reset(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Predict wake word scores for a frame of 16-bit PCM @ 16 kHz audio (ideally
|
||||
* multiples of 1280 samples / 80 ms).
|
||||
* @returns a `{ label: score }` map, score in 0..1.
|
||||
*/
|
||||
predict(x: Int16Array): Promise<Record<string, number>>;
|
||||
}
|
||||
|
||||
export default OpenWakeWord;
|
||||
239
web/src/openwakeword.js
Normal file
239
web/src/openwakeword.js
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import * as ort from "onnxruntime-web";
|
||||
import { AudioFeatures, CHUNK } from "./audio-features.js";
|
||||
import {
|
||||
FEATURE_MODELS,
|
||||
PRETRAINED_MODELS,
|
||||
MODEL_CLASS_MAPPINGS,
|
||||
} from "./models.js";
|
||||
|
||||
const DEFAULT_INPUT_FRAMES = 16; // openWakeWord models use 16 feature frames
|
||||
|
||||
/**
|
||||
* Configure the ONNX Runtime Web environment. Call once before creating a model
|
||||
* if you want to point at self-hosted wasm binaries or tweak threading.
|
||||
* @param {{wasmPaths?: string, numThreads?: number, simd?: boolean}} opts
|
||||
*/
|
||||
export function configureOrt(opts = {}) {
|
||||
if (opts.wasmPaths !== undefined) ort.env.wasm.wasmPaths = opts.wasmPaths;
|
||||
if (opts.numThreads !== undefined) ort.env.wasm.numThreads = opts.numThreads;
|
||||
if (opts.simd !== undefined) ort.env.wasm.simd = opts.simd;
|
||||
}
|
||||
|
||||
function readShapeDim(session, which, idx) {
|
||||
// Best-effort read of an input/output dimension across ort-web versions.
|
||||
const meta =
|
||||
which === "input"
|
||||
? session.inputMetadata?.[0]
|
||||
: session.outputMetadata?.[0];
|
||||
const shape = meta?.shape ?? meta?.dimensions;
|
||||
const v = shape?.[idx];
|
||||
return typeof v === "number" && v > 0 ? v : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native browser port of `openwakeword.Model`.
|
||||
*
|
||||
* Runs the full melspectrogram -> embedding -> wake word pipeline client-side
|
||||
* using ONNX Runtime Web. No server required.
|
||||
*/
|
||||
export class OpenWakeWord {
|
||||
constructor() {
|
||||
this.models = {}; // name -> { session, inputName, inputFrames, outputClasses, classMapping }
|
||||
this.features = null;
|
||||
this.predictionBuffer = {}; // label -> number[] (max 30)
|
||||
this.threshold = 0.5;
|
||||
this.onDetection = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and initialise a model.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.baseUrl="./models/"] Base URL/path for model files.
|
||||
* @param {Array<string|{name:string,url:string,inputFrames?:number,classMapping?:object}>} [opts.wakewordModels]
|
||||
* Wake word models to load. Strings are looked up in the pre-trained
|
||||
* registry (e.g. "hey_jarvis"); objects allow custom models by URL.
|
||||
* Defaults to all pre-trained models.
|
||||
* @param {string} [opts.melspectrogramUrl] Override the melspectrogram model URL.
|
||||
* @param {string} [opts.embeddingUrl] Override the embedding model URL.
|
||||
* @param {string[]} [opts.executionProviders=["wasm"]] ORT execution providers.
|
||||
* @param {object} [opts.ort] Options forwarded to {@link configureOrt}.
|
||||
* @returns {Promise<OpenWakeWord>}
|
||||
*/
|
||||
static async create(opts = {}) {
|
||||
const {
|
||||
baseUrl = "./models/",
|
||||
wakewordModels = Object.keys(PRETRAINED_MODELS),
|
||||
executionProviders = ["wasm"],
|
||||
ort: ortOpts,
|
||||
threshold = 0.5,
|
||||
onDetection = null,
|
||||
} = opts;
|
||||
|
||||
if (ortOpts) configureOrt(ortOpts);
|
||||
|
||||
const join = (file) =>
|
||||
/^https?:|^\.|^\//.test(file) ? file : baseUrl + file;
|
||||
const sessOpts = { executionProviders };
|
||||
|
||||
const melspectrogramUrl = opts.melspectrogramUrl
|
||||
? opts.melspectrogramUrl
|
||||
: join(FEATURE_MODELS.melspectrogram);
|
||||
const embeddingUrl = opts.embeddingUrl
|
||||
? opts.embeddingUrl
|
||||
: join(FEATURE_MODELS.embedding);
|
||||
|
||||
const self = new OpenWakeWord();
|
||||
|
||||
// Load feature models + create the streaming feature extractor.
|
||||
const [melspecSession, embeddingSession] = await Promise.all([
|
||||
ort.InferenceSession.create(melspectrogramUrl, sessOpts),
|
||||
ort.InferenceSession.create(embeddingUrl, sessOpts),
|
||||
]);
|
||||
self.features = new AudioFeatures(melspecSession, embeddingSession);
|
||||
|
||||
// Load wake word models.
|
||||
for (const entry of wakewordModels) {
|
||||
let name, url, inputFrames, classMapping;
|
||||
if (typeof entry === "string") {
|
||||
name = entry;
|
||||
const file = PRETRAINED_MODELS[entry] || entry;
|
||||
url = join(file);
|
||||
classMapping = MODEL_CLASS_MAPPINGS[entry];
|
||||
} else {
|
||||
name = entry.name;
|
||||
url = /^https?:|^\.|^\//.test(entry.url) ? entry.url : join(entry.url);
|
||||
inputFrames = entry.inputFrames;
|
||||
classMapping = entry.classMapping || MODEL_CLASS_MAPPINGS[name];
|
||||
}
|
||||
|
||||
const session = await ort.InferenceSession.create(url, sessOpts);
|
||||
const detectedFrames = readShapeDim(session, "input", 1);
|
||||
const outputClasses = readShapeDim(session, "output", 1) ?? 1;
|
||||
self.models[name] = {
|
||||
session,
|
||||
inputName: session.inputNames[0],
|
||||
inputFrames: inputFrames ?? detectedFrames ?? DEFAULT_INPUT_FRAMES,
|
||||
outputClasses,
|
||||
classMapping: classMapping || null,
|
||||
};
|
||||
}
|
||||
|
||||
self.threshold = threshold;
|
||||
self.onDetection = onDetection;
|
||||
|
||||
await self.features.warmup();
|
||||
return self;
|
||||
}
|
||||
|
||||
/** Names of the loaded wake word models. */
|
||||
get modelNames() {
|
||||
return Object.keys(this.models);
|
||||
}
|
||||
|
||||
/** Reset all streaming/prediction state. */
|
||||
async reset() {
|
||||
this.features.reset(true);
|
||||
await this.features.warmup();
|
||||
this.predictionBuffer = {};
|
||||
}
|
||||
|
||||
async _runModel(m, feat) {
|
||||
const tensor = new ort.Tensor("float32", feat.data, feat.dims);
|
||||
const out = await m.session.run({ [m.inputName]: tensor });
|
||||
const data = out[m.session.outputNames[0]].data;
|
||||
return Array.from(data); // length = outputClasses (the [0] row)
|
||||
}
|
||||
|
||||
_pushPrediction(label, value) {
|
||||
if (!this.predictionBuffer[label]) this.predictionBuffer[label] = [];
|
||||
this.predictionBuffer[label].push(value);
|
||||
if (this.predictionBuffer[label].length > 30) {
|
||||
this.predictionBuffer[label].shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Predict wake word scores for a frame of 16-bit PCM @ 16 kHz audio.
|
||||
* Ideally pass multiples of 1280 samples (80 ms).
|
||||
*
|
||||
* @param {Int16Array} x
|
||||
* @returns {Promise<Record<string, number>>} label -> score (0..1)
|
||||
*/
|
||||
async predict(x) {
|
||||
if (!(x instanceof Int16Array)) {
|
||||
throw new TypeError("Input audio (x) must be an Int16Array of 16 kHz PCM.");
|
||||
}
|
||||
|
||||
const nPrepared = await this.features.streamingFeatures(x);
|
||||
const predictions = {};
|
||||
|
||||
for (const [name, m] of Object.entries(this.models)) {
|
||||
let prediction; // array of length outputClasses
|
||||
|
||||
if (nPrepared > CHUNK) {
|
||||
const group = [];
|
||||
for (let i = Math.floor(nPrepared / CHUNK) - 1; i >= 0; i--) {
|
||||
const feat = this.features.getFeatures(
|
||||
m.inputFrames,
|
||||
-m.inputFrames - i
|
||||
);
|
||||
group.push(await this._runModel(m, feat));
|
||||
}
|
||||
prediction = group.reduce((acc, row) =>
|
||||
acc.map((v, idx) => Math.max(v, row[idx]))
|
||||
);
|
||||
} else if (nPrepared === CHUNK) {
|
||||
const feat = this.features.getFeatures(m.inputFrames);
|
||||
prediction = await this._runModel(m, feat);
|
||||
} else {
|
||||
// Not enough new samples yet: reuse the previous prediction.
|
||||
if (m.outputClasses === 1) {
|
||||
const buf = this.predictionBuffer[name];
|
||||
prediction = [buf && buf.length > 0 ? buf[buf.length - 1] : 0];
|
||||
} else {
|
||||
prediction = new Array(m.outputClasses).fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (m.outputClasses === 1) {
|
||||
predictions[name] = prediction[0];
|
||||
} else if (m.classMapping) {
|
||||
for (const [intLabel, cls] of Object.entries(m.classMapping)) {
|
||||
predictions[cls] = prediction[Number.parseInt(intLabel, 10)];
|
||||
}
|
||||
} else {
|
||||
for (let c = 0; c < m.outputClasses; c++) {
|
||||
predictions[`${name}_${c}`] = prediction[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zero out predictions for the first 5 frames during model warm-up.
|
||||
for (const label of Object.keys(predictions)) {
|
||||
if (!this.predictionBuffer[label] || this.predictionBuffer[label].length < 5) {
|
||||
predictions[label] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the prediction history buffers.
|
||||
for (const label of Object.keys(predictions)) {
|
||||
this._pushPrediction(label, predictions[label]);
|
||||
}
|
||||
|
||||
// Fire detection callback for any label that meets the threshold.
|
||||
if (this.onDetection) {
|
||||
for (const [label, score] of Object.entries(predictions)) {
|
||||
if (score >= this.threshold) {
|
||||
this.onDetection({ label, score });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return predictions;
|
||||
}
|
||||
}
|
||||
|
||||
export { AudioFeatures } from "./audio-features.js";
|
||||
export * from "./models.js";
|
||||
export default OpenWakeWord;
|
||||
87
web/test/verify.mjs
Normal file
87
web/test/verify.mjs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// End-to-end verification of the browser port, run under Node with the same
|
||||
// onnxruntime-web package. Streams real test clips through the model exactly
|
||||
// like Python's `Model.predict_clip` and checks that the right wake word fires.
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { OpenWakeWord, configureOrt } from "../src/openwakeword.js";
|
||||
|
||||
// This script lives in web/test. The web package root is one level up, and the
|
||||
// repository root (which holds tests/data/*.wav) is two levels up.
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = join(here, "..");
|
||||
const repo = join(here, "..", "..");
|
||||
const modelsDir = join(webRoot, "models") + "/";
|
||||
|
||||
configureOrt({ numThreads: 1 });
|
||||
|
||||
// Minimal 16-bit PCM mono WAV reader -> Int16Array of samples.
|
||||
function readWavInt16(buf) {
|
||||
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
let off = 12; // skip RIFF header
|
||||
let dataOffset = -1;
|
||||
let dataLen = 0;
|
||||
while (off + 8 <= dv.byteLength) {
|
||||
const id = String.fromCharCode(
|
||||
dv.getUint8(off), dv.getUint8(off + 1), dv.getUint8(off + 2), dv.getUint8(off + 3)
|
||||
);
|
||||
const size = dv.getUint32(off + 4, true);
|
||||
if (id === "data") {
|
||||
dataOffset = off + 8;
|
||||
dataLen = size;
|
||||
break;
|
||||
}
|
||||
off += 8 + size + (size % 2);
|
||||
}
|
||||
if (dataOffset < 0) throw new Error("no data chunk");
|
||||
return new Int16Array(buf.buffer, buf.byteOffset + dataOffset, dataLen / 2);
|
||||
}
|
||||
|
||||
async function predictClip(oww, samples, { padding = 1, chunk = 1280 } = {}) {
|
||||
const pad = 16000 * padding;
|
||||
const data = new Int16Array(pad + samples.length + pad);
|
||||
data.set(samples, pad);
|
||||
|
||||
const maxScores = {};
|
||||
for (let i = 0; i + chunk < data.length; i += chunk) {
|
||||
const frame = data.slice(i, i + chunk);
|
||||
const scores = await oww.predict(frame);
|
||||
for (const [k, v] of Object.entries(scores)) {
|
||||
maxScores[k] = Math.max(maxScores[k] ?? 0, v);
|
||||
}
|
||||
}
|
||||
return maxScores;
|
||||
}
|
||||
|
||||
const CASES = [
|
||||
{ file: "tests/data/alexa_test.wav", expect: "alexa" },
|
||||
{ file: "tests/data/hey_mycroft_test.wav", expect: "hey_mycroft" },
|
||||
];
|
||||
|
||||
const oww = await OpenWakeWord.create({
|
||||
baseUrl: modelsDir,
|
||||
wakewordModels: ["alexa", "hey_mycroft", "hey_jarvis", "hey_rhasspy"],
|
||||
});
|
||||
console.log("Loaded models:", oww.modelNames.join(", "));
|
||||
console.log(
|
||||
"Input frames per model:",
|
||||
Object.fromEntries(Object.entries(oww.models).map(([k, v]) => [k, v.inputFrames]))
|
||||
);
|
||||
|
||||
let allPass = true;
|
||||
for (const { file, expect } of CASES) {
|
||||
const buf = await readFile(join(repo, file));
|
||||
const samples = readWavInt16(buf);
|
||||
await oww.reset();
|
||||
const scores = await predictClip(oww, samples);
|
||||
const top = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
||||
const pass = scores[expect] >= 0.5 && top[0][0] === expect;
|
||||
allPass = allPass && pass;
|
||||
console.log(`\n${file}`);
|
||||
console.log(" max scores:", Object.fromEntries(top.map(([k, v]) => [k, +v.toFixed(3)])));
|
||||
console.log(` expected "${expect}" -> ${pass ? "PASS" : "FAIL"} (score ${(scores[expect] ?? 0).toFixed(3)})`);
|
||||
}
|
||||
|
||||
console.log(`\n${allPass ? "ALL PASS ✅" : "FAILURES ❌"}`);
|
||||
process.exit(allPass ? 0 : 1);
|
||||
Loading…
Add table
Add a link
Reference in a new issue