Spaces:
Running
Running
Deploy static voice agent
Browse files- .gitignore +34 -0
- CLAUDE.md +106 -0
- README.md +84 -6
- bun.lock +332 -0
- docs/specs/browser-voice-agent-arg/core-flows.md +181 -0
- docs/specs/browser-voice-agent-arg/epic-brief.md +104 -0
- docs/specs/browser-voice-agent-arg/tech-plan.md +229 -0
- docs/specs/browser-voice-agent-arg/tickets.md +284 -0
- index.html +70 -17
- package.json +27 -0
- src/adapters/asr.ts +129 -0
- src/adapters/llm.ts +182 -0
- src/adapters/tts.ts +155 -0
- src/app/controller.ts +584 -0
- src/app/store.ts +128 -0
- src/app/types.ts +240 -0
- src/main.ts +189 -0
- src/prompts/system.ts +7 -0
- src/services/audio-capture.ts +291 -0
- src/services/capabilities.ts +58 -0
- src/services/persistence.ts +52 -0
- src/services/playback.ts +257 -0
- src/services/pocket-runtime.ts +609 -0
- src/styles.css +227 -0
- src/vendor/onnxruntime-web/ort-wasm-simd-threaded.jsep.mjs +125 -0
- src/vendor/onnxruntime-web/ort-wasm-simd-threaded.jsep.wasm +3 -0
- src/vendor/onnxruntime-web/ort-wasm-simd-threaded.mjs +70 -0
- src/vendor/onnxruntime-web/ort-wasm-simd-threaded.wasm +3 -0
- src/vendor/onnxruntime-web/ort.min.mjs +0 -0
- src/vendor/pocket-tts/CODE-LICENSE +201 -0
- src/vendor/pocket-tts/inference-worker.js +1273 -0
- src/vendor/pocket-tts/onnx/flow_lm_flow_int8.onnx +3 -0
- src/vendor/pocket-tts/onnx/flow_lm_main_int8.onnx +3 -0
- src/vendor/pocket-tts/onnx/mimi_decoder_int8.onnx +3 -0
- src/vendor/pocket-tts/onnx/mimi_encoder.onnx +3 -0
- src/vendor/pocket-tts/onnx/text_conditioner.onnx +3 -0
- src/vendor/pocket-tts/sentencepiece.js +0 -0
- src/vendor/pocket-tts/tokenizer.model +3 -0
- src/vendor/pocket-tts/voices.bin +3 -0
- src/workers/pocket-bootstrap.ts +84 -0
- tests/capabilities.test.ts +52 -0
- tests/controller.test.ts +212 -0
- tests/persistence.test.ts +60 -0
- tsconfig.json +29 -0
- vite.config.ts +57 -0
.gitignore
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# dependencies (bun install)
|
| 2 |
+
node_modules
|
| 3 |
+
|
| 4 |
+
# output
|
| 5 |
+
out
|
| 6 |
+
dist
|
| 7 |
+
*.tgz
|
| 8 |
+
|
| 9 |
+
# code coverage
|
| 10 |
+
coverage
|
| 11 |
+
*.lcov
|
| 12 |
+
|
| 13 |
+
# logs
|
| 14 |
+
logs
|
| 15 |
+
_.log
|
| 16 |
+
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
| 17 |
+
|
| 18 |
+
# dotenv environment variable files
|
| 19 |
+
.env
|
| 20 |
+
.env.development.local
|
| 21 |
+
.env.test.local
|
| 22 |
+
.env.production.local
|
| 23 |
+
.env.local
|
| 24 |
+
|
| 25 |
+
# caches
|
| 26 |
+
.eslintcache
|
| 27 |
+
.cache
|
| 28 |
+
*.tsbuildinfo
|
| 29 |
+
|
| 30 |
+
# IntelliJ based IDEs
|
| 31 |
+
.idea
|
| 32 |
+
|
| 33 |
+
# Finder (MacOS) folder config
|
| 34 |
+
.DS_Store
|
CLAUDE.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
Default to using Bun instead of Node.js.
|
| 3 |
+
|
| 4 |
+
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
| 5 |
+
- Use `bun test` instead of `jest` or `vitest`
|
| 6 |
+
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
| 7 |
+
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
| 8 |
+
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
| 9 |
+
- Use `bunx <package> <command>` instead of `npx <package> <command>`
|
| 10 |
+
- Bun automatically loads .env, so don't use dotenv.
|
| 11 |
+
|
| 12 |
+
## APIs
|
| 13 |
+
|
| 14 |
+
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
| 15 |
+
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
| 16 |
+
- `Bun.redis` for Redis. Don't use `ioredis`.
|
| 17 |
+
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
| 18 |
+
- `WebSocket` is built-in. Don't use `ws`.
|
| 19 |
+
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
| 20 |
+
- Bun.$`ls` instead of execa.
|
| 21 |
+
|
| 22 |
+
## Testing
|
| 23 |
+
|
| 24 |
+
Use `bun test` to run tests.
|
| 25 |
+
|
| 26 |
+
```ts#index.test.ts
|
| 27 |
+
import { test, expect } from "bun:test";
|
| 28 |
+
|
| 29 |
+
test("hello world", () => {
|
| 30 |
+
expect(1).toBe(1);
|
| 31 |
+
});
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
## Frontend
|
| 35 |
+
|
| 36 |
+
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
| 37 |
+
|
| 38 |
+
Server:
|
| 39 |
+
|
| 40 |
+
```ts#index.ts
|
| 41 |
+
import index from "./index.html"
|
| 42 |
+
|
| 43 |
+
Bun.serve({
|
| 44 |
+
routes: {
|
| 45 |
+
"/": index,
|
| 46 |
+
"/api/users/:id": {
|
| 47 |
+
GET: (req) => {
|
| 48 |
+
return new Response(JSON.stringify({ id: req.params.id }));
|
| 49 |
+
},
|
| 50 |
+
},
|
| 51 |
+
},
|
| 52 |
+
// optional websocket support
|
| 53 |
+
websocket: {
|
| 54 |
+
open: (ws) => {
|
| 55 |
+
ws.send("Hello, world!");
|
| 56 |
+
},
|
| 57 |
+
message: (ws, message) => {
|
| 58 |
+
ws.send(message);
|
| 59 |
+
},
|
| 60 |
+
close: (ws) => {
|
| 61 |
+
// handle close
|
| 62 |
+
}
|
| 63 |
+
},
|
| 64 |
+
development: {
|
| 65 |
+
hmr: true,
|
| 66 |
+
console: true,
|
| 67 |
+
}
|
| 68 |
+
})
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
| 72 |
+
|
| 73 |
+
```html#index.html
|
| 74 |
+
<html>
|
| 75 |
+
<body>
|
| 76 |
+
<h1>Hello, world!</h1>
|
| 77 |
+
<script type="module" src="./frontend.tsx"></script>
|
| 78 |
+
</body>
|
| 79 |
+
</html>
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
With the following `frontend.tsx`:
|
| 83 |
+
|
| 84 |
+
```tsx#frontend.tsx
|
| 85 |
+
import React from "react";
|
| 86 |
+
import { createRoot } from "react-dom/client";
|
| 87 |
+
|
| 88 |
+
// import .css files directly and it works
|
| 89 |
+
import './index.css';
|
| 90 |
+
|
| 91 |
+
const root = createRoot(document.body);
|
| 92 |
+
|
| 93 |
+
export default function Frontend() {
|
| 94 |
+
return <h1>Hello, world!</h1>;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
root.render(<Frontend />);
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
Then, run index.ts
|
| 101 |
+
|
| 102 |
+
```sh
|
| 103 |
+
bun --hot ./index.ts
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
|
README.md
CHANGED
|
@@ -1,10 +1,88 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Browser Voice Agent Demo
|
| 3 |
+
emoji: 🎙️
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: static
|
| 7 |
+
app_build_command: npm run build
|
| 8 |
+
app_file: dist/index.html
|
| 9 |
+
custom_headers:
|
| 10 |
+
cross-origin-embedder-policy: require-corp
|
| 11 |
+
cross-origin-opener-policy: same-origin
|
| 12 |
+
cross-origin-resource-policy: cross-origin
|
| 13 |
---
|
| 14 |
|
| 15 |
+
# Browser Voice Agent Demo
|
| 16 |
+
|
| 17 |
+
Browser-first, on-device voice agent demo with Gradio-style camouflage. The app is served locally with Vite during development and built as static assets for public deployment.
|
| 18 |
+
|
| 19 |
+
## Stack
|
| 20 |
+
|
| 21 |
+
- `Vite` for local development, preview, and bundling
|
| 22 |
+
- `Bun` for package management and tests
|
| 23 |
+
- `@mlc-ai/web-llm` for local browser LLM inference
|
| 24 |
+
- `@huggingface/transformers` for local ASR
|
| 25 |
+
- Vendored Pocket TTS browser worker derived from `KevinAHM/pocket-tts-web`
|
| 26 |
+
|
| 27 |
+
## Local Development
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
bun install
|
| 31 |
+
bun run dev
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
Then open `http://localhost:3000`.
|
| 35 |
+
|
| 36 |
+
The same dev server now carries the isolation headers, so Pocket can take the threaded path without a separate custom preview server.
|
| 37 |
+
|
| 38 |
+
For a production-like static serve, use:
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
bun run preview
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
That serves the built `dist/` output with the same COOP/COEP/CORP headers.
|
| 45 |
+
|
| 46 |
+
## Test
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
bun test
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
## Build
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
bun run build
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
The production output is emitted to `dist/`.
|
| 59 |
+
|
| 60 |
+
## Pocket TTS Runtime
|
| 61 |
+
|
| 62 |
+
The app now ships a built-in Pocket TTS browser runtime and same-origin model assets:
|
| 63 |
+
|
| 64 |
+
- `src/vendor/pocket-tts/inference-worker.js`
|
| 65 |
+
- `src/vendor/pocket-tts/sentencepiece.js`
|
| 66 |
+
- `src/vendor/pocket-tts/CODE-LICENSE`
|
| 67 |
+
- `src/vendor/pocket-tts/onnx/*`
|
| 68 |
+
- `src/vendor/pocket-tts/tokenizer.model`
|
| 69 |
+
- `src/vendor/pocket-tts/voices.bin`
|
| 70 |
+
- `src/vendor/onnxruntime-web/*`
|
| 71 |
+
|
| 72 |
+
Those files are copied into `dist/` during `bun run build` and served locally from:
|
| 73 |
+
|
| 74 |
+
- `/pocket-tts/inference-worker.js`
|
| 75 |
+
- `/pocket-tts/sentencepiece.js`
|
| 76 |
+
- `/pocket-tts/onnx/*`
|
| 77 |
+
- `/pocket-tts/tokenizer.model`
|
| 78 |
+
- `/pocket-tts/voices.bin`
|
| 79 |
+
- `/onnxruntime-web/*`
|
| 80 |
+
|
| 81 |
+
The vendored worker code is Apache 2.0; its license file is preserved in [src/vendor/pocket-tts/CODE-LICENSE](/home/autark/src/private-voice-agent/src/vendor/pocket-tts/CODE-LICENSE).
|
| 82 |
+
|
| 83 |
+
For best Pocket performance, serve the app with:
|
| 84 |
+
|
| 85 |
+
- `Cross-Origin-Opener-Policy: same-origin`
|
| 86 |
+
- `Cross-Origin-Embedder-Policy: require-corp`
|
| 87 |
+
|
| 88 |
+
Without those headers, the worker still runs, but ONNX Runtime drops back to single-threaded WASM.
|
bun.lock
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"lockfileVersion": 1,
|
| 3 |
+
"configVersion": 1,
|
| 4 |
+
"workspaces": {
|
| 5 |
+
"": {
|
| 6 |
+
"name": "private-voice-agent",
|
| 7 |
+
"dependencies": {
|
| 8 |
+
"@huggingface/transformers": "^3.8.1",
|
| 9 |
+
"@mlc-ai/web-llm": "^0.2.82",
|
| 10 |
+
"webtalk": "^1.0.42",
|
| 11 |
+
},
|
| 12 |
+
"devDependencies": {
|
| 13 |
+
"@types/bun": "latest",
|
| 14 |
+
"vite": "^7.1.3",
|
| 15 |
+
},
|
| 16 |
+
"peerDependencies": {
|
| 17 |
+
"typescript": "^5",
|
| 18 |
+
},
|
| 19 |
+
},
|
| 20 |
+
},
|
| 21 |
+
"trustedDependencies": [
|
| 22 |
+
"onnxruntime-node",
|
| 23 |
+
"protobufjs",
|
| 24 |
+
],
|
| 25 |
+
"packages": {
|
| 26 |
+
"@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
|
| 27 |
+
|
| 28 |
+
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
|
| 29 |
+
|
| 30 |
+
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
|
| 31 |
+
|
| 32 |
+
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
|
| 33 |
+
|
| 34 |
+
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
|
| 35 |
+
|
| 36 |
+
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
|
| 37 |
+
|
| 38 |
+
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
|
| 39 |
+
|
| 40 |
+
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
|
| 41 |
+
|
| 42 |
+
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
|
| 43 |
+
|
| 44 |
+
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
|
| 45 |
+
|
| 46 |
+
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
|
| 47 |
+
|
| 48 |
+
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
|
| 49 |
+
|
| 50 |
+
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
|
| 51 |
+
|
| 52 |
+
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
|
| 53 |
+
|
| 54 |
+
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
|
| 55 |
+
|
| 56 |
+
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
|
| 57 |
+
|
| 58 |
+
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
|
| 59 |
+
|
| 60 |
+
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
|
| 61 |
+
|
| 62 |
+
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
|
| 63 |
+
|
| 64 |
+
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
|
| 65 |
+
|
| 66 |
+
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
|
| 67 |
+
|
| 68 |
+
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
|
| 69 |
+
|
| 70 |
+
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
|
| 71 |
+
|
| 72 |
+
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
|
| 73 |
+
|
| 74 |
+
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
|
| 75 |
+
|
| 76 |
+
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
|
| 77 |
+
|
| 78 |
+
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
|
| 79 |
+
|
| 80 |
+
"@huggingface/jinja": ["@huggingface/jinja@0.5.6", "", {}, "sha512-MyMWyLnjqo+KRJYSH7oWNbsOn5onuIvfXYPcc0WOGxU0eHUV7oAYUoQTl2BMdu7ml+ea/bu11UM+EshbeHwtIA=="],
|
| 81 |
+
|
| 82 |
+
"@huggingface/transformers": ["@huggingface/transformers@3.8.1", "", { "dependencies": { "@huggingface/jinja": "^0.5.3", "onnxruntime-node": "1.21.0", "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", "sharp": "^0.34.1" } }, "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA=="],
|
| 83 |
+
|
| 84 |
+
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
| 85 |
+
|
| 86 |
+
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
| 87 |
+
|
| 88 |
+
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
| 89 |
+
|
| 90 |
+
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
| 91 |
+
|
| 92 |
+
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
| 93 |
+
|
| 94 |
+
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
| 95 |
+
|
| 96 |
+
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
| 97 |
+
|
| 98 |
+
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
| 99 |
+
|
| 100 |
+
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
| 101 |
+
|
| 102 |
+
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
| 103 |
+
|
| 104 |
+
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
| 105 |
+
|
| 106 |
+
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
| 107 |
+
|
| 108 |
+
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
| 109 |
+
|
| 110 |
+
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
| 111 |
+
|
| 112 |
+
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
| 113 |
+
|
| 114 |
+
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
| 115 |
+
|
| 116 |
+
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
| 117 |
+
|
| 118 |
+
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
| 119 |
+
|
| 120 |
+
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
| 121 |
+
|
| 122 |
+
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
| 123 |
+
|
| 124 |
+
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
| 125 |
+
|
| 126 |
+
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
| 127 |
+
|
| 128 |
+
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
| 129 |
+
|
| 130 |
+
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
| 131 |
+
|
| 132 |
+
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
| 133 |
+
|
| 134 |
+
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
| 135 |
+
|
| 136 |
+
"@mlc-ai/web-llm": ["@mlc-ai/web-llm@0.2.82", "", { "dependencies": { "loglevel": "^1.9.1" } }, "sha512-ONhW+28PPVSUI1m0RkJcm7suwc47b65i5b/rTEIADq5I22p1+9uf/CBbDPRkkjj1WJB9s8oFp0ywAW0NY1G6fg=="],
|
| 137 |
+
|
| 138 |
+
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
| 139 |
+
|
| 140 |
+
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
| 141 |
+
|
| 142 |
+
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
|
| 143 |
+
|
| 144 |
+
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
|
| 145 |
+
|
| 146 |
+
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
|
| 147 |
+
|
| 148 |
+
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
| 149 |
+
|
| 150 |
+
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
|
| 151 |
+
|
| 152 |
+
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
| 153 |
+
|
| 154 |
+
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
| 155 |
+
|
| 156 |
+
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
| 157 |
+
|
| 158 |
+
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
|
| 159 |
+
|
| 160 |
+
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
|
| 161 |
+
|
| 162 |
+
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
|
| 163 |
+
|
| 164 |
+
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
|
| 165 |
+
|
| 166 |
+
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
|
| 167 |
+
|
| 168 |
+
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
|
| 169 |
+
|
| 170 |
+
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
|
| 171 |
+
|
| 172 |
+
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
|
| 173 |
+
|
| 174 |
+
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
|
| 175 |
+
|
| 176 |
+
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
|
| 177 |
+
|
| 178 |
+
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
|
| 179 |
+
|
| 180 |
+
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
|
| 181 |
+
|
| 182 |
+
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
|
| 183 |
+
|
| 184 |
+
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
|
| 185 |
+
|
| 186 |
+
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
|
| 187 |
+
|
| 188 |
+
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
|
| 189 |
+
|
| 190 |
+
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
|
| 191 |
+
|
| 192 |
+
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
|
| 193 |
+
|
| 194 |
+
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
|
| 195 |
+
|
| 196 |
+
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
|
| 197 |
+
|
| 198 |
+
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
|
| 199 |
+
|
| 200 |
+
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
|
| 201 |
+
|
| 202 |
+
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
|
| 203 |
+
|
| 204 |
+
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
|
| 205 |
+
|
| 206 |
+
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
|
| 207 |
+
|
| 208 |
+
"@sctg/sentencepiece-js": ["@sctg/sentencepiece-js@1.3.3", "", { "dependencies": { "app-root-path": "^3.1.0", "buffer": "^6.0.3" } }, "sha512-iPnzR2HGjdQQG2SpTyPH3wnnpgQ2aS14B3I2jHjomkoaMprM3Sn+WOQblHwByetnvn3n14y3IwjwogVUHA2cVA=="],
|
| 209 |
+
|
| 210 |
+
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
| 211 |
+
|
| 212 |
+
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
| 213 |
+
|
| 214 |
+
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
| 215 |
+
|
| 216 |
+
"app-root-path": ["app-root-path@3.1.0", "", {}, "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA=="],
|
| 217 |
+
|
| 218 |
+
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
| 219 |
+
|
| 220 |
+
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
| 221 |
+
|
| 222 |
+
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
| 223 |
+
|
| 224 |
+
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
| 225 |
+
|
| 226 |
+
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
| 227 |
+
|
| 228 |
+
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
| 229 |
+
|
| 230 |
+
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
| 231 |
+
|
| 232 |
+
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
| 233 |
+
|
| 234 |
+
"detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
|
| 235 |
+
|
| 236 |
+
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
| 237 |
+
|
| 238 |
+
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
| 239 |
+
|
| 240 |
+
"es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="],
|
| 241 |
+
|
| 242 |
+
"esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
|
| 243 |
+
|
| 244 |
+
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
| 245 |
+
|
| 246 |
+
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
| 247 |
+
|
| 248 |
+
"flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
|
| 249 |
+
|
| 250 |
+
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
| 251 |
+
|
| 252 |
+
"global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
|
| 253 |
+
|
| 254 |
+
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
|
| 255 |
+
|
| 256 |
+
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
| 257 |
+
|
| 258 |
+
"guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
|
| 259 |
+
|
| 260 |
+
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
| 261 |
+
|
| 262 |
+
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
| 263 |
+
|
| 264 |
+
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
|
| 265 |
+
|
| 266 |
+
"loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="],
|
| 267 |
+
|
| 268 |
+
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
| 269 |
+
|
| 270 |
+
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
| 271 |
+
|
| 272 |
+
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
| 273 |
+
|
| 274 |
+
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
|
| 275 |
+
|
| 276 |
+
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
| 277 |
+
|
| 278 |
+
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
| 279 |
+
|
| 280 |
+
"onnxruntime-common": ["onnxruntime-common@1.21.0", "", {}, "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ=="],
|
| 281 |
+
|
| 282 |
+
"onnxruntime-node": ["onnxruntime-node@1.21.0", "", { "dependencies": { "global-agent": "^3.0.0", "onnxruntime-common": "1.21.0", "tar": "^7.0.1" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw=="],
|
| 283 |
+
|
| 284 |
+
"onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="],
|
| 285 |
+
|
| 286 |
+
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
| 287 |
+
|
| 288 |
+
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
| 289 |
+
|
| 290 |
+
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
|
| 291 |
+
|
| 292 |
+
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
| 293 |
+
|
| 294 |
+
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
| 295 |
+
|
| 296 |
+
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
|
| 297 |
+
|
| 298 |
+
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
|
| 299 |
+
|
| 300 |
+
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
| 301 |
+
|
| 302 |
+
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
|
| 303 |
+
|
| 304 |
+
"serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
|
| 305 |
+
|
| 306 |
+
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
| 307 |
+
|
| 308 |
+
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
| 309 |
+
|
| 310 |
+
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
|
| 311 |
+
|
| 312 |
+
"tar": ["tar@7.5.11", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ=="],
|
| 313 |
+
|
| 314 |
+
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
| 315 |
+
|
| 316 |
+
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
| 317 |
+
|
| 318 |
+
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
| 319 |
+
|
| 320 |
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
| 321 |
+
|
| 322 |
+
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
| 323 |
+
|
| 324 |
+
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
| 325 |
+
|
| 326 |
+
"webtalk": ["webtalk@1.0.42", "", { "dependencies": { "@sctg/sentencepiece-js": "^1.3.3" }, "peerDependencies": { "@huggingface/transformers": "^3.8.1", "onnxruntime-node": "^1.21.0" }, "optionalPeers": ["@huggingface/transformers", "onnxruntime-node"], "bin": { "webtalk": "server.js" } }, "sha512-F8syx3rEpblc/6x4lzLcURmXNSW5N9E4fY97Xcs4/SOt2GoJwitKSnqOLiE05iFEeo2S06XT+MqwO3CQoPZbLg=="],
|
| 327 |
+
|
| 328 |
+
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
| 329 |
+
|
| 330 |
+
"onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.22.0-dev.20250409-89f8206ba4", "", {}, "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ=="],
|
| 331 |
+
}
|
| 332 |
+
}
|
docs/specs/browser-voice-agent-arg/core-flows.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- status: locked -->
|
| 2 |
+
# Core Flows: Browser Voice Agent ARG Demo
|
| 3 |
+
|
| 4 |
+
## Flow 1: First-Run Entry and Consent
|
| 5 |
+
**Actor**: User
|
| 6 |
+
**Trigger**: User opens the public demo URL
|
| 7 |
+
**Preconditions**:
|
| 8 |
+
- The demo is reachable in a supported browser
|
| 9 |
+
- The user has not yet granted mic permission for this session
|
| 10 |
+
**Postconditions**:
|
| 11 |
+
- The user has either entered the voice loop or been clearly stopped by a capability/permission issue
|
| 12 |
+
**Invariants**:
|
| 13 |
+
- The UI remains clinically matter-of-fact, not theatrical
|
| 14 |
+
- The surface looks like a familiar research demo, not a polished consumer app
|
| 15 |
+
- Consent for microphone and voice cloning is explicit
|
| 16 |
+
- The first-run flow stays short enough to preserve curiosity
|
| 17 |
+
|
| 18 |
+
1. User lands on a minimal single-screen demo for an on-device personal voice agent.
|
| 19 |
+
2. The page presents a recognizable research-demo layout: title, short description, input/output panels, and ordinary controls resembling a Gradio or Hugging Face Spaces prototype.
|
| 20 |
+
3. System presents a compact explanation of the demo and asks for microphone access and voice-cloning consent in deadpan product language.
|
| 21 |
+
4. User presses `Start`, which both confirms intent and immediately arms the microphone for the first utterance.
|
| 22 |
+
- If mic permission is granted: continue.
|
| 23 |
+
- If mic permission is denied: show a neutral failure state with a clear retry path.
|
| 24 |
+
5. System checks baseline runtime capability and prepares the voice loop.
|
| 25 |
+
- If the device/browser is insufficient: route to a clear unsupported state or a narrowly scoped retry path.
|
| 26 |
+
6. User arrives at the live conversation screen ready to speak the first query.
|
| 27 |
+
|
| 28 |
+
**Success state**: User reaches an armed conversation state with full awareness that their voice may be used for synthesis.
|
| 29 |
+
**Error states**:
|
| 30 |
+
- Mic permission denied
|
| 31 |
+
- Browser/device unsupported
|
| 32 |
+
- Runtime initialization takes too long or fails visibly
|
| 33 |
+
|
| 34 |
+
```
|
| 35 |
+
┌──────────────────────────────────────────────────────┐
|
| 36 |
+
│ Browser Voice Agent Demo │
|
| 37 |
+
│ On-device speech recognition + local voice reply │
|
| 38 |
+
├───────────────────────┬──────────────────────────────┤
|
| 39 |
+
│ Audio Input │ Assistant Output │
|
| 40 |
+
│ [ mic status ] │ [ minimal transcript log ] │
|
| 41 |
+
│ [ Start ] [ Clear ] │ [ audio playback status ] │
|
| 42 |
+
├───────────────────────┴──────────────────────────────┤
|
| 43 |
+
│ Consent note: mic + voice used locally for synthesis│
|
| 44 |
+
│ Footer: small, generic demo attribution │
|
| 45 |
+
└──────────────────────────────────────────────────────┘
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## Flow 2: First-Contact Moment
|
| 49 |
+
**Actor**: User
|
| 50 |
+
**Trigger**: User speaks the first normal query
|
| 51 |
+
**Preconditions**:
|
| 52 |
+
- Flow 1 completed successfully
|
| 53 |
+
- Microphone is active
|
| 54 |
+
- The demo is ready to capture speech and respond
|
| 55 |
+
**Postconditions**:
|
| 56 |
+
- The user hears the first cloned or approximated reply
|
| 57 |
+
- The system establishes the product's tension without changing tone
|
| 58 |
+
**Invariants**:
|
| 59 |
+
- The first cloned reply is the slip; no extra narrative setup precedes it
|
| 60 |
+
- The system treats the response as routine, not surprising
|
| 61 |
+
- Latency must feel conversational enough that the effect is not lost
|
| 62 |
+
- The surrounding layout continues to read as a boring model demo while the voice effect lands
|
| 63 |
+
|
| 64 |
+
```mermaid
|
| 65 |
+
sequenceDiagram
|
| 66 |
+
participant User
|
| 67 |
+
participant Demo as Browser Demo
|
| 68 |
+
participant STT as Speech Recognition
|
| 69 |
+
participant Agent as LLM Agent
|
| 70 |
+
participant TTS as Voice Synthesis
|
| 71 |
+
|
| 72 |
+
User->>Demo: Speak first query
|
| 73 |
+
Demo->>STT: Stream audio
|
| 74 |
+
Demo->>TTS: Extract voice reference from first utterance
|
| 75 |
+
STT-->>Agent: Transcript
|
| 76 |
+
Agent-->>Demo: Response text / stream
|
| 77 |
+
Demo->>TTS: Synthesize reply in cloned voice
|
| 78 |
+
TTS-->>User: First spoken reply
|
| 79 |
+
Demo-->>User: Deadpan UI state continues as normal
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
**Steps**:
|
| 83 |
+
1. User asks a normal question or makes a normal request.
|
| 84 |
+
2. System captures the audio once and uses it for both recognition and voice conditioning.
|
| 85 |
+
3. UI enters a neutral "thinking" state with no hint that anything unusual is about to happen.
|
| 86 |
+
4. System begins playback of the reply in an approximation of the user's own voice.
|
| 87 |
+
5. UI continues as if this is expected product behavior.
|
| 88 |
+
|
| 89 |
+
**Success state**: User experiences the first-contact moment clearly and immediately understands the demo's core trick.
|
| 90 |
+
**Error states**:
|
| 91 |
+
- Speech is not recognized well enough to answer
|
| 92 |
+
- Voice conditioning fails and no convincing reply can be generated
|
| 93 |
+
- Response latency is too long to sustain the illusion
|
| 94 |
+
|
| 95 |
+
## Flow 3: Live Conversation Loop
|
| 96 |
+
**Actors**: User, Voice agent
|
| 97 |
+
**Trigger**: The first-contact moment completes and the conversation continues
|
| 98 |
+
**Preconditions**:
|
| 99 |
+
- Flow 2 succeeded
|
| 100 |
+
- The session remains active
|
| 101 |
+
**Postconditions**:
|
| 102 |
+
- The user can keep speaking, listen to replies, interrupt, retry, or end the session
|
| 103 |
+
**Invariants**:
|
| 104 |
+
- Voice remains the primary interface
|
| 105 |
+
- The UI stays sparse and product-like
|
| 106 |
+
- Any memory or continuity supports normal assistant behavior, not authored v1 narrative escalation
|
| 107 |
+
- Barge-in is supported if it does not compromise first-response reliability
|
| 108 |
+
|
| 109 |
+
```mermaid
|
| 110 |
+
stateDiagram
|
| 111 |
+
[*] --> Listening
|
| 112 |
+
Listening --> Thinking : user utterance ends
|
| 113 |
+
Thinking --> Speaking : reply ready
|
| 114 |
+
Speaking --> Listening : playback completes
|
| 115 |
+
Speaking --> Listening : user interrupts / barge-in
|
| 116 |
+
Listening --> Error : capture or recognition failure
|
| 117 |
+
Thinking --> Error : model or synthesis failure
|
| 118 |
+
Error --> Listening : user retries
|
| 119 |
+
Listening --> [*] : user exits or resets
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
**Steps**:
|
| 123 |
+
1. User asks another question, follows up, or tests the system.
|
| 124 |
+
2. System listens, transcribes, generates, and speaks back with the same deadpan product framing.
|
| 125 |
+
3. If the user interrupts while audio is playing, playback stops and the system returns to listening.
|
| 126 |
+
4. If the user pauses or stops, the UI remains ready rather than pushing authored prompts.
|
| 127 |
+
5. User can clear the session or leave at any time.
|
| 128 |
+
|
| 129 |
+
**Success state**: The demo behaves like a polished voice-agent loop after the initial slip, reinforcing that the uncanny part is structural rather than a scripted scene.
|
| 130 |
+
**Error states**:
|
| 131 |
+
- Playback cannot be interrupted cleanly
|
| 132 |
+
- Session state drifts after repeated turns
|
| 133 |
+
- The UI accidentally reveals too much machinery or too much fiction
|
| 134 |
+
|
| 135 |
+
## Flow 4: Failure and Degraded Modes
|
| 136 |
+
**Actor**: User
|
| 137 |
+
**Trigger**: Any required capability fails or becomes unreliable
|
| 138 |
+
**Preconditions**:
|
| 139 |
+
- The user attempted Flow 1, 2, or 3
|
| 140 |
+
**Postconditions**:
|
| 141 |
+
- The user either recovers into a supported path or leaves with a clear explanation
|
| 142 |
+
**Invariants**:
|
| 143 |
+
- Failure states stay deadpan and technically credible
|
| 144 |
+
- The product never pretends a broken voice feature is intentional lore
|
| 145 |
+
- The user always has a clear next action
|
| 146 |
+
|
| 147 |
+
1. System detects a failure such as unsupported runtime, denied permission, recognition breakdown, or synthesis failure.
|
| 148 |
+
2. UI explains the problem in plain technical language.
|
| 149 |
+
3. User is offered the next best action.
|
| 150 |
+
- Retry microphone access
|
| 151 |
+
- Retry model/runtime initialization
|
| 152 |
+
- Exit cleanly
|
| 153 |
+
4. If the failure clears, the system returns to the appropriate live state.
|
| 154 |
+
|
| 155 |
+
**Success state**: The demo preserves credibility even when it cannot deliver the ideal voice loop, and it fails closed rather than pretending a weaker fallback is equivalent.
|
| 156 |
+
**Error states**:
|
| 157 |
+
- Recovery path loops without progress
|
| 158 |
+
- Messaging becomes confusing or unintentionally theatrical
|
| 159 |
+
- Degraded mode undermines the product promise too strongly
|
| 160 |
+
|
| 161 |
+
## Flow 5: Return Visit with Local Continuity
|
| 162 |
+
**Actor**: Returning user
|
| 163 |
+
**Trigger**: User reopens the demo in the same browser later
|
| 164 |
+
**Preconditions**:
|
| 165 |
+
- The browser has local state from a prior successful session
|
| 166 |
+
**Postconditions**:
|
| 167 |
+
- The user either resumes with lightweight continuity or starts fresh
|
| 168 |
+
**Invariants**:
|
| 169 |
+
- Local continuity is optional support, not the core of v1
|
| 170 |
+
- No server-side identity is required
|
| 171 |
+
- The UI makes reset/clear actions available
|
| 172 |
+
|
| 173 |
+
1. User returns to the demo in the same browser.
|
| 174 |
+
2. System checks for locally stored session preferences or lightweight conversation state.
|
| 175 |
+
3. UI offers either a resume path or a fresh start, using ordinary product wording.
|
| 176 |
+
4. User continues into the standard live conversation loop.
|
| 177 |
+
|
| 178 |
+
**Success state**: Returning users get continuity without turning v1 into a progression system.
|
| 179 |
+
**Error states**:
|
| 180 |
+
- Local state is corrupted or incompatible
|
| 181 |
+
- Resume behavior feels like hidden lore instead of normal app continuity
|
docs/specs/browser-voice-agent-arg/epic-brief.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- status: locked -->
|
| 2 |
+
<!-- epic-slug: browser-voice-agent-arg -->
|
| 3 |
+
# Epic Brief: Browser Voice Agent ARG Demo
|
| 4 |
+
|
| 5 |
+
## Problem
|
| 6 |
+
Most AI voice demos explain the stack but do not create a memorable product moment. They feel like benchmarks or wrappers, not experiences people want to share. This project aims to turn an on-device voice-agent demo into a deadpan, unsettling product experience: a generic "personal voice agent" that answers in an approximation of the user's own voice from the first spoken query. In v1, that first cloned reply is the entire slip. There is no long warm-up period where the assistant seems normal before it gets strange.
|
| 7 |
+
|
| 8 |
+
## Who's Affected
|
| 9 |
+
- Curious end users who open a link expecting a normal browser AI demo
|
| 10 |
+
- The host developer shipping the demo as a credible on-device technical showcase
|
| 11 |
+
- Technical evaluators assessing latency, on-device inference credibility, and UX polish
|
| 12 |
+
|
| 13 |
+
## Actors
|
| 14 |
+
|
| 15 |
+
| Actor | Description |
|
| 16 |
+
|-------|-------------|
|
| 17 |
+
| User | A person who opens the demo, grants mic access, speaks, listens, and may return later |
|
| 18 |
+
| Voice agent | The in-browser assistant persona presented as a normal personal voice agent |
|
| 19 |
+
| Host team | The developers/authors controlling the framing, technical implementation, and release posture |
|
| 20 |
+
|
| 21 |
+
## Goals
|
| 22 |
+
- Deliver a believable browser-based on-device voice-agent demo that works as a real technical showcase.
|
| 23 |
+
- Make the first response in the user's cloned voice the core emotional hook.
|
| 24 |
+
- Preserve a fully deadpan presentation: the app behaves like a normal AI wrapper even when the experience becomes uncanny.
|
| 25 |
+
- Execute a single moment of contact flawlessly: ask a normal question, hear yourself answer it, and feel the UI treat that as entirely routine.
|
| 26 |
+
- Keep the entry barrier low enough that someone can open a link and reach the first response quickly.
|
| 27 |
+
- Use a familiar research-demo visual language so the interface is instantly legible and non-threatening to the target audience.
|
| 28 |
+
|
| 29 |
+
## Non-Goals
|
| 30 |
+
- Building a broad consumer assistant with general productivity features
|
| 31 |
+
- Shipping native mobile apps in this epic
|
| 32 |
+
- Shipping community puzzle infrastructure, multiplayer mechanics, or coordinated ARG live-ops in v1
|
| 33 |
+
- Building authored narrative escalation beyond the first-contact moment in v1
|
| 34 |
+
- Monetization, subscriptions, or app-store growth mechanics
|
| 35 |
+
|
| 36 |
+
## Design Decisions
|
| 37 |
+
|
| 38 |
+
| Decision | Choice | Rationale |
|
| 39 |
+
|----------|--------|-----------|
|
| 40 |
+
| Product framing | Present as a generic personal voice-agent demo | The disguise is central to the effect and to the commentary on AI wrappers |
|
| 41 |
+
| Visual language | Approximate a recognizable Gradio / Hugging Face Spaces demo surface | Familiar ML-demo aesthetics lower scrutiny and answer "why does this exist?" without extra exposition |
|
| 42 |
+
| Delivery surface | Browser-first, link-shareable experience | Lowest friction, strongest "just try this" virality, easiest iteration |
|
| 43 |
+
| Tone | Fully deadpan | The product should never wink at the user or signal that it is a game |
|
| 44 |
+
| Core hook | First spoken reply uses an approximation of the user's voice | This is the signature moment that differentiates the demo from normal voice chat |
|
| 45 |
+
| V1 scope | Singular first-contact experience, not a multi-session narrative arc | The product must nail the one unforgettable moment before expanding into authored escalation |
|
| 46 |
+
| Persistence | Browser-local only if needed for continuity; no server state in v1 | Keep the "on-device" promise credible and the MVP constrained |
|
| 47 |
+
|
| 48 |
+
## Success Criteria
|
| 49 |
+
- A first-time user can reach the first spoken response without setup beyond opening the site and granting mic permission.
|
| 50 |
+
- The first-response moment is strong enough that users voluntarily share the demo or describe the experience to others.
|
| 51 |
+
- The baseline demo is credible as an on-device voice-agent showcase even before the ARG escalation begins.
|
| 52 |
+
- The experience remains intentionally ambiguous: users should not immediately know whether the app is merely polished, slightly broken, or deliberately uncanny.
|
| 53 |
+
- The UI, copy, and latency profile support the illusion that this is a legitimate technical demo rather than a dressed-up horror bit.
|
| 54 |
+
|
| 55 |
+
## Out of Scope
|
| 56 |
+
- Native iOS/Android packaging
|
| 57 |
+
- Account systems, cloud sync, and server-side progression in v1
|
| 58 |
+
- Large-scale moderation/community tooling
|
| 59 |
+
- Enterprise or accessibility certification work
|
| 60 |
+
- Voice biometrics as a security product
|
| 61 |
+
|
| 62 |
+
## Context
|
| 63 |
+
The concept combines three layers that usually ship separately:
|
| 64 |
+
1. A real on-device speech demo
|
| 65 |
+
2. A product-fiction wrapper that looks like an ordinary AI app
|
| 66 |
+
3. An ARG-adjacent framing where the user's own cloned voice is the central uncanny device
|
| 67 |
+
|
| 68 |
+
The current repository does not yet contain an app, interface, or runtime pipeline. It is a minimal Bun TypeScript starter, so this epic is effectively greenfield inside an existing repo.
|
| 69 |
+
The interface should borrow from the visual grammar of Gradio and Hugging Face Spaces demos: generic panel layout, default-ish controls, sparse copy, and minimal brand presence. That camouflage is part of the product effect, not just a styling preference.
|
| 70 |
+
|
| 71 |
+
## Constraints
|
| 72 |
+
- The experience must feel like a legitimate technical demo, not a horror game UI.
|
| 73 |
+
- The first-use flow needs to be short; long onboarding will kill the effect.
|
| 74 |
+
- The demo should remain credible if a technical user inspects the repo, bundle, or network traffic.
|
| 75 |
+
- The product must degrade gracefully when browser/device capabilities are insufficient.
|
| 76 |
+
- The cloned first reply is the reveal; v1 cannot depend on later authored escalation to justify itself.
|
| 77 |
+
- Inference must stay on-device, and any persistence in v1 must remain local to the browser.
|
| 78 |
+
- The surface should feel like a plausible solo research demo, not a startup landing page or branded consumer product.
|
| 79 |
+
|
| 80 |
+
## Definitions
|
| 81 |
+
- ARG-adjacent: A product experience that borrows alternate-reality tension and ambiguity without requiring full community puzzle infrastructure in v1.
|
| 82 |
+
- Deadpan: The product never acknowledges the joke, bit, or fiction layer in tone or UI.
|
| 83 |
+
- First-response moment: The first time the system answers back aloud in the user's cloned or approximated voice.
|
| 84 |
+
- Baseline demo: The version that already works as a respectable on-device voice-agent showcase without deeper narrative escalation.
|
| 85 |
+
- Slip: The instant when a normal query receives a cloned-voice reply and the product treats it as normal.
|
| 86 |
+
- Research-demo camouflage: A UI that looks like an ordinary model demo, especially one associated with Gradio or Hugging Face Spaces conventions.
|
| 87 |
+
|
| 88 |
+
## Assumptions & Unknowns
|
| 89 |
+
|
| 90 |
+
| Item | Type | How to Validate | Owner |
|
| 91 |
+
|------|------|-----------------|-------|
|
| 92 |
+
| Users will tolerate hearing their own voice played back immediately | Assumption | Prototype test with first-use reactions | Host team |
|
| 93 |
+
| The singular first-contact moment is strong enough to carry v1 without further authored narrative | Assumption | Prototype test with first-use reactions | Host team |
|
| 94 |
+
| Browser-local persistence will help credibility without becoming a hidden dependency | Unknown | Validate in UX and tech planning | Host team |
|
| 95 |
+
| "On-device" is a hard product promise, not just a marketing preference | Locked decision | Maintain in architecture and UX copy | Host team |
|
| 96 |
+
| The initial ship target is a public demo link, not a controlled installation build | Locked decision | Plan for public-first failure handling | Host team |
|
| 97 |
+
| Explicit consent for mic plus voice cloning is compatible with a fully deadpan tone | Locked decision | Reflect in onboarding copy | Host team |
|
| 98 |
+
|
| 99 |
+
## Kill Criteria / Stop Conditions
|
| 100 |
+
- If the product cannot deliver a credible first-response moment quickly enough to feel conversational
|
| 101 |
+
- If the disguise requires so much fake utility that the core uncanny experience gets diluted
|
| 102 |
+
- If the browser-only delivery target prevents a stable enough baseline demo
|
| 103 |
+
- If explicit consent requirements or technical caveats make the deadpan framing collapse
|
| 104 |
+
- If legal, ethical, or consent concerns around voice cloning force the concept to lose its core effect
|
docs/specs/browser-voice-agent-arg/tech-plan.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- status: locked -->
|
| 2 |
+
# Tech Plan: Browser Voice Agent ARG Demo
|
| 3 |
+
|
| 4 |
+
## Architecture Overview
|
| 5 |
+
|
| 6 |
+
V1 is a browser-first, client-side application with a thin Bun-based local development shell. The deployed artifact should be a static site or static-space build whose entire inference loop runs in the browser:
|
| 7 |
+
|
| 8 |
+
1. Capture microphone audio in the browser.
|
| 9 |
+
2. Detect utterance boundaries with lightweight VAD / level detection.
|
| 10 |
+
3. Transcribe utterances locally with a browser ASR pipeline.
|
| 11 |
+
4. Generate assistant responses locally with WebLLM over WebGPU.
|
| 12 |
+
5. Synthesize speech locally with a Pocket TTS-compatible browser adapter using the first utterance as the voice reference.
|
| 13 |
+
6. Render a familiar Gradio-like single-screen UI with a minimal transcript log and audio state.
|
| 14 |
+
|
| 15 |
+
Local development can use Bun to serve the app and bundle assets, but the production runtime must not require a server process or server-side inference.
|
| 16 |
+
|
| 17 |
+
```mermaid
|
| 18 |
+
graph TD
|
| 19 |
+
A[Mic Input] --> B[Audio Capture + VAD]
|
| 20 |
+
B --> C[ASR Adapter]
|
| 21 |
+
C --> D[Conversation Controller]
|
| 22 |
+
D --> E[WebLLM Adapter]
|
| 23 |
+
E --> F[TTS Adapter]
|
| 24 |
+
F --> G[Audio Playback]
|
| 25 |
+
D --> H[UI Store]
|
| 26 |
+
H --> I[Gradio-like Demo Shell]
|
| 27 |
+
D --> J[Local Storage]
|
| 28 |
+
K[Capability Gate] --> D
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## Design Decisions
|
| 32 |
+
|
| 33 |
+
| Decision | Choice | Rationale | Trade-off |
|
| 34 |
+
|----------|--------|-----------|-----------|
|
| 35 |
+
| Runtime model | Fully client-side inference in browser | Protects the on-device promise and supports the deadpan "inspect the network, find nothing" effect | Heavier startup, more capability gating |
|
| 36 |
+
| Deployment target | Static web build first; Hugging Face Spaces static hosting as preferred public target | Matches the research-demo camouflage and public-link distribution model | Requires asset strategy compatible with static hosting limits |
|
| 37 |
+
| UI stack | Plain HTML/CSS/TypeScript with Bun bundling | Repo is greenfield, UI is simple, and a framework adds little value here | More manual state wiring |
|
| 38 |
+
| LLM runtime | WebLLM on WebGPU | Official browser-targeted local LLM runtime with streaming support | Requires supported GPU/browser; cold start can be large |
|
| 39 |
+
| Speech recognition | Transformers.js Whisper ASR adapter | Official browser ASR path with local model execution | May be slower on weak devices; likely utterance-based rather than true realtime |
|
| 40 |
+
| Voice synthesis | Pocket TTS-compatible browser adapter behind an interface | Preserves the core product idea while isolating the riskiest integration point | Official Pocket TTS browser path is not first-party today |
|
| 41 |
+
| Interaction model | Voice-first with explicit `Start`, auto-armed mic, minimal transcript | Fast path to the slip while preserving consent and research-demo credibility | Open mic complexity around barge-in and interruption |
|
| 42 |
+
| Failure handling | Fail closed with clear technical messaging | A weak fallback undermines the singular product effect | Some devices simply will not get an experience |
|
| 43 |
+
|
| 44 |
+
## Non-Negotiables / Invariants
|
| 45 |
+
- The first cloned reply is the product. No technical shortcut may compromise that moment to polish secondary features.
|
| 46 |
+
- Inference stays local to the browser in v1. No remote LLM, ASR, TTS, or voice-embedding service.
|
| 47 |
+
- The UI must look like a plausible research demo, not a branded startup product.
|
| 48 |
+
- Consent for microphone use and local voice cloning must be explicit.
|
| 49 |
+
- If core capabilities are missing, the app fails closed instead of silently downgrading into a different experience.
|
| 50 |
+
|
| 51 |
+
## Data Model
|
| 52 |
+
|
| 53 |
+
```
|
| 54 |
+
AppCapability {
|
| 55 |
+
hasWebGPU: boolean
|
| 56 |
+
hasMediaDevices: boolean
|
| 57 |
+
hasAudioWorklet: boolean
|
| 58 |
+
canRunDemo: boolean
|
| 59 |
+
failureReason: string | null
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
SessionState {
|
| 63 |
+
phase: "idle" | "arming" | "listening" | "thinking" | "speaking" | "error"
|
| 64 |
+
micPermission: "unknown" | "granted" | "denied"
|
| 65 |
+
firstContactComplete: boolean
|
| 66 |
+
activeRunId: string | null
|
| 67 |
+
errorCode: string | null
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
ConversationTurn {
|
| 71 |
+
id: string
|
| 72 |
+
role: "user" | "assistant" | "system"
|
| 73 |
+
transcript: string
|
| 74 |
+
audioStatus: "none" | "queued" | "playing" | "done" | "failed"
|
| 75 |
+
createdAt: number
|
| 76 |
+
isFirstContact: boolean
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
VoiceProfileState {
|
| 80 |
+
source: "first-utterance" | "cached"
|
| 81 |
+
ready: boolean
|
| 82 |
+
referenceAudioKey: string | null
|
| 83 |
+
embeddingCacheKey: string | null
|
| 84 |
+
lastUpdatedAt: number | null
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
RuntimeConfig {
|
| 88 |
+
llmModelId: string
|
| 89 |
+
asrModelId: string
|
| 90 |
+
ttsModelId: string
|
| 91 |
+
maxTurnsPersisted: number
|
| 92 |
+
targetFirstAudioMs: number
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
PersistedSession {
|
| 96 |
+
version: number
|
| 97 |
+
turns: ConversationTurn[]
|
| 98 |
+
voiceProfile: VoiceProfileState
|
| 99 |
+
lastOpenedAt: number
|
| 100 |
+
}
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
## Component Architecture
|
| 104 |
+
|
| 105 |
+
```mermaid
|
| 106 |
+
graph TD
|
| 107 |
+
UI[Demo Shell UI] --> Store[App Store]
|
| 108 |
+
Store --> Controller[Conversation Controller]
|
| 109 |
+
Controller --> Capability[Capability Service]
|
| 110 |
+
Controller --> Audio[Audio Capture Service]
|
| 111 |
+
Controller --> ASR[ASR Adapter]
|
| 112 |
+
Controller --> LLM[WebLLM Adapter]
|
| 113 |
+
Controller --> TTS[Pocket TTS Adapter]
|
| 114 |
+
Controller --> Playback[Playback Service]
|
| 115 |
+
Controller --> Persistence[Local Persistence]
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
### Demo Shell UI
|
| 119 |
+
Single-screen page that visually evokes Gradio / Spaces defaults: panel layout, muted control styling, minimal footer, and no brand-heavy chrome. It owns only presentational state and delegates behavior to the store/controller.
|
| 120 |
+
|
| 121 |
+
### App Store
|
| 122 |
+
Small state container for capability status, current phase, transcript log, and error messaging. Keep it framework-agnostic and serializable for tests.
|
| 123 |
+
|
| 124 |
+
### Conversation Controller
|
| 125 |
+
The orchestration layer. It sequences capability checks, consent, utterance capture, ASR, LLM response streaming, TTS synthesis, playback, interruption, and persistence. This is the highest-risk logic and should remain isolated from DOM code.
|
| 126 |
+
|
| 127 |
+
### Capability Service
|
| 128 |
+
Detects WebGPU, microphone availability, AudioWorklet support, and any other hard requirements. Produces deterministic unsupported states before model loading starts.
|
| 129 |
+
|
| 130 |
+
### Audio Capture Service
|
| 131 |
+
Wraps `getUserMedia`, streams PCM frames, performs basic level detection / VAD, and emits utterance segments. Must support user interruption during playback.
|
| 132 |
+
|
| 133 |
+
### ASR Adapter
|
| 134 |
+
Owns model loading and utterance transcription. Input is an utterance segment; output is a transcript plus timing metadata. Keep the interface narrow so the ASR backend can be swapped if needed.
|
| 135 |
+
|
| 136 |
+
### WebLLM Adapter
|
| 137 |
+
Creates and caches the browser LLM engine, streams response text, and exposes cancellation. It should hide model-specific setup from the rest of the app.
|
| 138 |
+
|
| 139 |
+
### Pocket TTS Adapter
|
| 140 |
+
Owns the voice-reference bootstrap from the first utterance, speaker-state caching, and speech synthesis. This adapter is intentionally isolated because Pocket TTS browser integration is the least settled part of the stack.
|
| 141 |
+
|
| 142 |
+
### Playback Service
|
| 143 |
+
Queues synthesized audio, starts playback, reports playback state, and stops immediately on barge-in. Keep it separate from TTS so interruption logic stays testable.
|
| 144 |
+
|
| 145 |
+
### Local Persistence
|
| 146 |
+
Stores a lightweight transcript history, voice-profile cache metadata, and last-opened timestamp in browser storage. This supports return visits without creating a progression system.
|
| 147 |
+
|
| 148 |
+
## Interfaces & Compatibility
|
| 149 |
+
- Public API changes: none yet; this is a new app surface.
|
| 150 |
+
- Data/schema migrations: local storage versioning only.
|
| 151 |
+
- Versioning strategy: internal schema version for persisted browser state.
|
| 152 |
+
- Upgrade notes required: yes, for persisted-state invalidation when models or transcript schema change.
|
| 153 |
+
|
| 154 |
+
## Performance & Resource Budgets
|
| 155 |
+
- Warm start to interactive UI: under 2 seconds after assets are loaded.
|
| 156 |
+
- Capability gate result: under 250ms.
|
| 157 |
+
- First response, utterance-end to first generated text token: under 1500ms on supported hardware.
|
| 158 |
+
- First response, utterance-end to first synthesized audio: under 2500ms on supported hardware.
|
| 159 |
+
- Barge-in stop latency: under 150ms.
|
| 160 |
+
- Persisted transcript history: capped to a small fixed number of turns to avoid storage bloat.
|
| 161 |
+
|
| 162 |
+
These are target budgets, not promises. If Pocket TTS browser integration materially breaks the first-audio budget, that is a stop/re-scope trigger rather than a silent degradation.
|
| 163 |
+
|
| 164 |
+
## Observability & Diagnostics
|
| 165 |
+
- UI-visible diagnostics for unsupported browser, missing WebGPU, denied mic, model load failure, ASR failure, and TTS failure.
|
| 166 |
+
- Console debug mode gated behind a query param or local flag for development.
|
| 167 |
+
- Timing instrumentation for: capability check, model load, utterance capture duration, ASR duration, first token latency, first audio latency, playback interruption latency.
|
| 168 |
+
- Optional lightweight in-browser event log shown in a collapsible debug panel during development only.
|
| 169 |
+
|
| 170 |
+
## File Changes
|
| 171 |
+
|
| 172 |
+
### New Files
|
| 173 |
+
- `index.html` — single-screen demo shell
|
| 174 |
+
- `src/main.ts` — client bootstrap
|
| 175 |
+
- `src/styles.css` — Gradio-like demo styling
|
| 176 |
+
- `src/app/controller.ts` — orchestration logic
|
| 177 |
+
- `src/app/store.ts` — app state container
|
| 178 |
+
- `src/app/types.ts` — shared runtime types
|
| 179 |
+
- `src/services/capabilities.ts` — browser capability detection
|
| 180 |
+
- `src/services/audio-capture.ts` — mic stream + VAD / segmentation
|
| 181 |
+
- `src/services/playback.ts` — audio playback and interruption
|
| 182 |
+
- `src/services/persistence.ts` — local storage read/write
|
| 183 |
+
- `src/adapters/asr.ts` — ASR adapter interface + transformers.js implementation
|
| 184 |
+
- `src/adapters/llm.ts` — WebLLM adapter
|
| 185 |
+
- `src/adapters/tts.ts` — Pocket TTS adapter interface + implementation shell
|
| 186 |
+
- `src/prompts/system.ts` — minimal deadpan system prompt
|
| 187 |
+
- `tests/controller.test.ts` — orchestration tests
|
| 188 |
+
- `tests/capabilities.test.ts` — capability gate tests
|
| 189 |
+
- `tests/persistence.test.ts` — persisted-state tests
|
| 190 |
+
|
| 191 |
+
### Modified Files
|
| 192 |
+
- `index.ts` — Bun development server entry or build helper instead of placeholder stub
|
| 193 |
+
- `package.json` — add scripts and runtime dependencies
|
| 194 |
+
- `README.md` — real setup, model notes, and deployment instructions
|
| 195 |
+
|
| 196 |
+
## Milestone Sequencing
|
| 197 |
+
|
| 198 |
+
| # | Milestone | Gate |
|
| 199 |
+
|---|-----------|------|
|
| 200 |
+
| 1 | App shell + capability gate | Static page loads, Gradio-like shell renders, unsupported browsers show clear state |
|
| 201 |
+
| 2 | Mic capture + utterance segmentation | User can grant mic access, speak, and produce stable utterance blobs |
|
| 202 |
+
| 3 | Local ASR | Utterances transcribe locally and populate the transcript log |
|
| 203 |
+
| 4 | WebLLM response loop | Assistant responses generate locally and stream into UI |
|
| 204 |
+
| 5 | Pocket TTS first-contact path | First utterance can seed voice profile and produce a cloned reply |
|
| 205 |
+
| 6 | Playback + barge-in | Audio plays reliably and interruption works without corrupting session state |
|
| 206 |
+
| 7 | Persistence + polish | Lightweight local continuity works and README/deploy story is credible |
|
| 207 |
+
|
| 208 |
+
## Testing Strategy
|
| 209 |
+
- Layer 1 — Unit tests: capability logic, persistence, phase transitions, controller cancellation rules.
|
| 210 |
+
- Layer 2 — Adapter contract tests: mock ASR/LLM/TTS adapters so orchestration can be verified without loading heavy models.
|
| 211 |
+
- Layer 3 — Browser smoke path: manual scripted checklist for consent -> first utterance -> first cloned reply -> interrupt -> reset.
|
| 212 |
+
- Layer 4 — Deployment smoke path: build artifact runs locally as static files and on the chosen public host.
|
| 213 |
+
|
| 214 |
+
## Risks and Mitigations
|
| 215 |
+
|
| 216 |
+
| Risk | Likelihood | Impact | Mitigation |
|
| 217 |
+
|------|------------|--------|------------|
|
| 218 |
+
| Pocket TTS browser integration is immature or undocumented | High | High | Isolate behind adapter, prototype early, treat as milestone 5 gate rather than late integration |
|
| 219 |
+
| WebLLM model cold start is too heavy for believable first-use flow | Medium | High | Add clear preload state, keep model size conservative, test on target hardware early |
|
| 220 |
+
| Transformers.js ASR latency is too slow on weaker devices | Medium | Medium | Keep utterance segmentation simple, constrain supported devices, fail closed on unsupported environments |
|
| 221 |
+
| Barge-in adds race conditions between capture and playback | Medium | Medium | Separate playback service, test cancellation and phase transitions explicitly |
|
| 222 |
+
| Gradio camouflage becomes parody instead of plausibility | Low | Medium | Stay close to familiar demo affordances, avoid over-stylization, keep copy sparse |
|
| 223 |
+
| Static hosting asset limits complicate model delivery | Medium | High | Decide early whether models are remote-fetched from approved origins or bundled selectively |
|
| 224 |
+
|
| 225 |
+
## Open Questions
|
| 226 |
+
- Which exact WebLLM model is the best balance between cold-start cost and conversational quality for this demo?
|
| 227 |
+
- What is the concrete Pocket TTS browser path: ONNX export, existing browser wrapper, or custom runtime bridge?
|
| 228 |
+
- Should public deployment optimize first for Hugging Face Spaces camouflage or for a simpler static host with a copied aesthetic?
|
| 229 |
+
- How much transcript history should be visible before the UI stops reading like a boring demo?
|
docs/specs/browser-voice-agent-arg/tickets.md
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- status: locked -->
|
| 2 |
+
# Tickets: Browser Voice Agent ARG Demo
|
| 3 |
+
|
| 4 |
+
Detailed tickets selected by default because the repo is greenfield and the riskiest parts are integration-heavy.
|
| 5 |
+
|
| 6 |
+
## Execution Order
|
| 7 |
+
|
| 8 |
+
T1 -> T2 -> T3 -> T4 -> T5 -> T6 -> T7 -> T8
|
| 9 |
+
|
| 10 |
+
Parallel groups:
|
| 11 |
+
- `A`: T7 and T8 can run in parallel after T6 if touch lists remain disjoint.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
### T1: Scaffold the static client app and demo shell
|
| 16 |
+
**Specs**: epic-brief.md, core-flows.md §Flow 1, tech-plan.md §Architecture Overview, §File Changes
|
| 17 |
+
**Files**: `index.ts`, `index.html`, `package.json`, `README.md`, `src/main.ts`, `src/styles.css`
|
| 18 |
+
**Touch list**: dev server/build pipeline, HTML shell, base styles, scripts, docs
|
| 19 |
+
**Dependencies**: None
|
| 20 |
+
**Effort**: Medium (2-4 hours)
|
| 21 |
+
**Parallel group**: —
|
| 22 |
+
|
| 23 |
+
**Description**:
|
| 24 |
+
Replace the Bun starter with a real client app entry and a single-screen UI shell that reads like a generic research demo. Add the build/dev scripts required for a static browser app and document the local workflow.
|
| 25 |
+
|
| 26 |
+
**Implementation Steps**:
|
| 27 |
+
1. Convert `index.ts` into a Bun development server or build helper suitable for serving the static client app locally.
|
| 28 |
+
2. Add `index.html` and `src/main.ts` as the browser entrypoint.
|
| 29 |
+
3. Create `src/styles.css` with Gradio-like layout primitives, muted controls, and panel styling.
|
| 30 |
+
4. Update `package.json` scripts for `dev`, `build`, and `test`.
|
| 31 |
+
5. Replace the placeholder README with setup and architecture notes.
|
| 32 |
+
|
| 33 |
+
**Acceptance Criteria**:
|
| 34 |
+
- [ ] Running the dev command serves a single-screen demo shell instead of `Hello via Bun!`
|
| 35 |
+
- [ ] The UI visually reads as a plausible research demo and includes `Start` and `Clear` controls
|
| 36 |
+
- [ ] A production build command outputs static assets for deployment
|
| 37 |
+
- [ ] `bun test` runs successfully, even if only baseline tests exist at this point
|
| 38 |
+
|
| 39 |
+
**Verification**:
|
| 40 |
+
- Commands: `bun run dev`, `bun run build`, `bun test`
|
| 41 |
+
- Manual checks: open the local app and confirm the shell looks like a generic demo rather than a branded landing page
|
| 42 |
+
|
| 43 |
+
**Rollback**:
|
| 44 |
+
- Revert the scaffold commit; no persistent data or migrations are introduced
|
| 45 |
+
|
| 46 |
+
---
|
| 47 |
+
|
| 48 |
+
### T2: Add capability gating, consent, and app state wiring
|
| 49 |
+
**Specs**: epic-brief.md §Constraints, core-flows.md §Flow 1, tech-plan.md §Non-Negotiables, §Data Model, §Component Architecture
|
| 50 |
+
**Files**: `src/main.ts`, `src/app/controller.ts`, `src/app/store.ts`, `src/app/types.ts`, `src/services/capabilities.ts`, `tests/capabilities.test.ts`
|
| 51 |
+
**Touch list**: capability detection, state container, consent view, unsupported/error states
|
| 52 |
+
**Dependencies**: T1
|
| 53 |
+
**Effort**: Medium (2-4 hours)
|
| 54 |
+
**Parallel group**: —
|
| 55 |
+
|
| 56 |
+
**Description**:
|
| 57 |
+
Implement the app state model, capability checks, explicit consent copy, and the `Start` path that arms the mic only when the environment can plausibly run the demo.
|
| 58 |
+
|
| 59 |
+
**Implementation Steps**:
|
| 60 |
+
1. Define runtime and session types in `src/app/types.ts`.
|
| 61 |
+
2. Implement capability detection for WebGPU, microphone availability, and other hard browser requirements.
|
| 62 |
+
3. Add a simple store for session phase, consent state, and current error.
|
| 63 |
+
4. Wire the UI so unsupported browsers and denied permissions produce clear deadpan messaging.
|
| 64 |
+
5. Add tests for capability-gate decisions and unsupported-state mapping.
|
| 65 |
+
|
| 66 |
+
**Acceptance Criteria**:
|
| 67 |
+
- [ ] The app distinguishes supported vs unsupported runtime states before model work starts
|
| 68 |
+
- [ ] Consent copy explicitly covers microphone use and local voice cloning
|
| 69 |
+
- [ ] Pressing `Start` transitions into an armed state only when prerequisites are satisfied
|
| 70 |
+
- [ ] Unsupported devices fail closed with a clear explanation
|
| 71 |
+
- [ ] Capability tests pass under `bun test`
|
| 72 |
+
|
| 73 |
+
**Verification**:
|
| 74 |
+
- Commands: `bun test`
|
| 75 |
+
- Manual checks: simulate unsupported and supported states; verify the messaging remains technical, not theatrical
|
| 76 |
+
|
| 77 |
+
**Rollback**:
|
| 78 |
+
- Revert the capability/store commit; no external state beyond browser memory is introduced
|
| 79 |
+
|
| 80 |
+
---
|
| 81 |
+
|
| 82 |
+
### T3: Implement audio capture and utterance segmentation
|
| 83 |
+
**Specs**: core-flows.md §Flow 1, §Flow 2, §Flow 3, tech-plan.md §Audio Capture Service, §Performance & Resource Budgets
|
| 84 |
+
**Files**: `src/app/controller.ts`, `src/app/store.ts`, `src/services/audio-capture.ts`, `src/app/types.ts`, `tests/controller.test.ts`
|
| 85 |
+
**Touch list**: microphone capture, utterance lifecycle, phase transitions
|
| 86 |
+
**Dependencies**: T2
|
| 87 |
+
**Effort**: Medium (2-4 hours)
|
| 88 |
+
**Parallel group**: —
|
| 89 |
+
|
| 90 |
+
**Description**:
|
| 91 |
+
Add the live audio capture path that arms the microphone, collects utterances, detects end-of-speech, and hands utterance blobs back to the controller.
|
| 92 |
+
|
| 93 |
+
**Implementation Steps**:
|
| 94 |
+
1. Create an audio capture service wrapping `getUserMedia` and PCM/Blob capture.
|
| 95 |
+
2. Add simple level-based utterance boundary detection suitable for v1.
|
| 96 |
+
3. Wire session phase transitions: `arming -> listening -> thinking`.
|
| 97 |
+
4. Expose cancellation/reset hooks so later playback interruption can coexist with capture.
|
| 98 |
+
5. Add controller tests for phase transitions and reset behavior using mocked audio services.
|
| 99 |
+
|
| 100 |
+
**Acceptance Criteria**:
|
| 101 |
+
- [ ] Pressing `Start` can arm the mic and begin listening
|
| 102 |
+
- [ ] A user utterance produces a stable audio segment for downstream ASR
|
| 103 |
+
- [ ] Session phases transition correctly around utterance capture
|
| 104 |
+
- [ ] `Clear` or reset returns the app to a safe idle state
|
| 105 |
+
- [ ] Controller tests cover the major capture transitions
|
| 106 |
+
|
| 107 |
+
**Verification**:
|
| 108 |
+
- Commands: `bun test`
|
| 109 |
+
- Manual checks: grant mic permission, speak, and confirm the app advances into a post-utterance state
|
| 110 |
+
|
| 111 |
+
**Rollback**:
|
| 112 |
+
- Revert the audio-capture commit; browser permission prompts may remain browser-managed but app state returns to previous behavior
|
| 113 |
+
|
| 114 |
+
---
|
| 115 |
+
|
| 116 |
+
### T4: Add local ASR and transcript logging
|
| 117 |
+
**Specs**: core-flows.md §Flow 2, §Flow 3, tech-plan.md §ASR Adapter, §Data Model, §File Changes
|
| 118 |
+
**Files**: `src/adapters/asr.ts`, `src/app/controller.ts`, `src/app/store.ts`, `src/app/types.ts`, `tests/controller.test.ts`
|
| 119 |
+
**Touch list**: ASR adapter, transcript turns, controller orchestration
|
| 120 |
+
**Dependencies**: T3
|
| 121 |
+
**Effort**: Medium (2-4 hours)
|
| 122 |
+
**Parallel group**: —
|
| 123 |
+
|
| 124 |
+
**Description**:
|
| 125 |
+
Transcribe captured utterances locally and append them to the minimal transcript log so the demo reads as a real research interface even before TTS lands.
|
| 126 |
+
|
| 127 |
+
**Implementation Steps**:
|
| 128 |
+
1. Define the ASR adapter contract and a transformers.js-backed implementation shell.
|
| 129 |
+
2. Connect utterance segments from the controller into ASR transcription.
|
| 130 |
+
3. Append successful user transcripts to the conversation turn list.
|
| 131 |
+
4. Handle ASR failure states explicitly in the store/controller.
|
| 132 |
+
5. Extend controller tests with mocked ASR success and failure paths.
|
| 133 |
+
|
| 134 |
+
**Acceptance Criteria**:
|
| 135 |
+
- [ ] Captured utterances can be transcribed through the adapter contract
|
| 136 |
+
- [ ] Successful ASR results appear in the transcript log
|
| 137 |
+
- [ ] ASR failure produces a technical error state instead of silent failure
|
| 138 |
+
- [ ] Tests cover both happy-path and ASR failure behavior
|
| 139 |
+
|
| 140 |
+
**Verification**:
|
| 141 |
+
- Commands: `bun test`
|
| 142 |
+
- Manual checks: speak a simple prompt and confirm the transcript appears in the UI
|
| 143 |
+
|
| 144 |
+
**Rollback**:
|
| 145 |
+
- Revert the ASR commit; transcript logging returns to pre-ASR behavior
|
| 146 |
+
|
| 147 |
+
---
|
| 148 |
+
|
| 149 |
+
### T5: Add WebLLM response generation and streaming UI updates
|
| 150 |
+
**Specs**: core-flows.md §Flow 2, §Flow 3, tech-plan.md §WebLLM Adapter, §Performance & Resource Budgets
|
| 151 |
+
**Files**: `src/adapters/llm.ts`, `src/prompts/system.ts`, `src/app/controller.ts`, `src/app/store.ts`, `tests/controller.test.ts`
|
| 152 |
+
**Touch list**: LLM adapter, assistant turn creation, streaming state, prompt configuration
|
| 153 |
+
**Dependencies**: T4
|
| 154 |
+
**Effort**: Medium (2-4 hours)
|
| 155 |
+
**Parallel group**: —
|
| 156 |
+
|
| 157 |
+
**Description**:
|
| 158 |
+
Load the local LLM, generate assistant responses from transcribed user turns, and surface the reply as a streaming assistant turn in the transcript UI.
|
| 159 |
+
|
| 160 |
+
**Implementation Steps**:
|
| 161 |
+
1. Implement a WebLLM adapter with initialization, streaming response, and cancellation.
|
| 162 |
+
2. Add the minimal deadpan system prompt and runtime config wiring.
|
| 163 |
+
3. Update the controller to create assistant turns and stream token/text updates into the store.
|
| 164 |
+
4. Propagate model-load and generation failures into clear error states.
|
| 165 |
+
5. Extend controller tests with mocked streaming success and cancellation.
|
| 166 |
+
|
| 167 |
+
**Acceptance Criteria**:
|
| 168 |
+
- [ ] The app can initialize an LLM adapter and generate local assistant text
|
| 169 |
+
- [ ] Assistant text appears incrementally or near-incrementally in the transcript log
|
| 170 |
+
- [ ] Cancellation and reset do not corrupt session state
|
| 171 |
+
- [ ] Prompt wiring remains deadpan and minimal
|
| 172 |
+
- [ ] Controller tests cover response success, cancellation, and failure
|
| 173 |
+
|
| 174 |
+
**Verification**:
|
| 175 |
+
- Commands: `bun test`
|
| 176 |
+
- Manual checks: complete one voice query and confirm the assistant text appears before TTS is integrated
|
| 177 |
+
|
| 178 |
+
**Rollback**:
|
| 179 |
+
- Revert the LLM commit; transcript log remains user-only
|
| 180 |
+
|
| 181 |
+
---
|
| 182 |
+
|
| 183 |
+
### T6: Integrate Pocket TTS for the first-contact cloned reply
|
| 184 |
+
**Specs**: epic-brief.md §Goals, core-flows.md §Flow 2, tech-plan.md §Pocket TTS Adapter, §Risks and Mitigations
|
| 185 |
+
**Files**: `src/adapters/tts.ts`, `src/app/controller.ts`, `src/app/store.ts`, `src/app/types.ts`, `tests/controller.test.ts`
|
| 186 |
+
**Touch list**: TTS adapter, first-utterance voice reference, first-contact orchestration
|
| 187 |
+
**Dependencies**: T5
|
| 188 |
+
**Effort**: Large (split risk acknowledged; 4-6 hours)
|
| 189 |
+
**Parallel group**: —
|
| 190 |
+
|
| 191 |
+
**Description**:
|
| 192 |
+
Use the first captured utterance as the voice reference, synthesize the assistant reply in an approximation of the user's own voice, and make that first playback the core product moment.
|
| 193 |
+
|
| 194 |
+
**Implementation Steps**:
|
| 195 |
+
1. Define the TTS adapter contract with voice-reference bootstrap, synthesize, and cancellation methods.
|
| 196 |
+
2. Cache the first utterance or derived speaker state for reuse in-session.
|
| 197 |
+
3. Hook the controller so the first assistant reply routes through the TTS adapter before playback.
|
| 198 |
+
4. Distinguish TTS failure from LLM failure and surface it clearly.
|
| 199 |
+
5. Add controller tests with mocked TTS to verify first-contact orchestration.
|
| 200 |
+
|
| 201 |
+
**Acceptance Criteria**:
|
| 202 |
+
- [ ] The first user utterance can seed the TTS adapter as a voice reference
|
| 203 |
+
- [ ] The first assistant reply is synthesized through the TTS path rather than a placeholder voice
|
| 204 |
+
- [ ] TTS failure is explicit and does not masquerade as a successful first-contact moment
|
| 205 |
+
- [ ] Controller tests cover first-contact success and TTS failure
|
| 206 |
+
|
| 207 |
+
**Verification**:
|
| 208 |
+
- Commands: `bun test`
|
| 209 |
+
- Manual checks: ask a normal first question and verify the spoken reply uses the cloned/approximated voice
|
| 210 |
+
|
| 211 |
+
**Rollback**:
|
| 212 |
+
- Revert the TTS commit; the app returns to transcript-only assistant behavior until the risk is reworked
|
| 213 |
+
|
| 214 |
+
**Failure Journal Stub**:
|
| 215 |
+
- Failure symptom: direct `webtalk` imports pulled Node-only modules into the browser bundle and broke `bun build`.
|
| 216 |
+
- Hypothesis tested: importing the package root or browser-facing files would provide a drop-in Pocket runtime for the app bundle.
|
| 217 |
+
- Fix applied: narrowed Pocket integration behind `window.__PRIVATE_VOICE_POCKET_TTS__` so the app stays buildable while the browser-safe runtime is isolated to one seam.
|
| 218 |
+
- Follow-up fix: vendored the browser worker subset from `KevinAHM/pocket-tts-web`, served it as explicit static assets, and pointed model fetches at the public Hugging Face Space URLs instead of relying on Bun to bundle the worker graph.
|
| 219 |
+
|
| 220 |
+
---
|
| 221 |
+
|
| 222 |
+
### T7: Add playback control and barge-in handling
|
| 223 |
+
**Specs**: core-flows.md §Flow 3, tech-plan.md §Playback Service, §Performance & Resource Budgets
|
| 224 |
+
**Files**: `src/services/playback.ts`, `src/app/controller.ts`, `src/app/store.ts`, `tests/controller.test.ts`
|
| 225 |
+
**Touch list**: playback queue, interruption, session phase reconciliation
|
| 226 |
+
**Dependencies**: T6
|
| 227 |
+
**Effort**: Medium (2-4 hours)
|
| 228 |
+
**Parallel group**: A
|
| 229 |
+
|
| 230 |
+
**Description**:
|
| 231 |
+
Make playback interruptible so the product can credibly behave like a voice agent rather than a one-shot audio player.
|
| 232 |
+
|
| 233 |
+
**Implementation Steps**:
|
| 234 |
+
1. Implement a playback service that owns audio element / buffer lifecycle.
|
| 235 |
+
2. Add immediate stop behavior on barge-in or explicit reset.
|
| 236 |
+
3. Reconcile controller phases around `speaking -> listening`.
|
| 237 |
+
4. Extend controller tests with interruption and rapid-repeat scenarios.
|
| 238 |
+
|
| 239 |
+
**Acceptance Criteria**:
|
| 240 |
+
- [ ] Synthesized audio plays reliably after generation
|
| 241 |
+
- [ ] User interruption stops playback quickly and returns the app to listening
|
| 242 |
+
- [ ] Reset during playback leaves the store in a clean idle state
|
| 243 |
+
- [ ] Tests cover interruption and playback cleanup
|
| 244 |
+
|
| 245 |
+
**Verification**:
|
| 246 |
+
- Commands: `bun test`
|
| 247 |
+
- Manual checks: interrupt an assistant reply mid-playback and confirm the app resumes listening cleanly
|
| 248 |
+
|
| 249 |
+
**Rollback**:
|
| 250 |
+
- Revert the playback-control commit; one-way playback remains functional
|
| 251 |
+
|
| 252 |
+
---
|
| 253 |
+
|
| 254 |
+
### T8: Add lightweight local persistence and deployment polish
|
| 255 |
+
**Specs**: core-flows.md §Flow 5, tech-plan.md §Local Persistence, §Deployment target, §README
|
| 256 |
+
**Files**: `src/services/persistence.ts`, `src/app/controller.ts`, `src/app/store.ts`, `src/app/types.ts`, `README.md`
|
| 257 |
+
**Touch list**: browser storage, resume/reset, deployment docs
|
| 258 |
+
**Dependencies**: T6
|
| 259 |
+
**Effort**: Medium (2-4 hours)
|
| 260 |
+
**Parallel group**: A
|
| 261 |
+
|
| 262 |
+
**Description**:
|
| 263 |
+
Persist a small amount of local session state, keep reset behavior explicit, and document the public deployment path, ideally including a Spaces flow.
|
| 264 |
+
|
| 265 |
+
**Implementation Steps**:
|
| 266 |
+
1. Implement versioned local storage read/write helpers.
|
| 267 |
+
2. Persist a capped transcript history and voice-profile metadata.
|
| 268 |
+
3. Add resume/fresh-start behavior that still reads like ordinary app continuity.
|
| 269 |
+
4. Document the static deployment workflow and any model-hosting constraints in the README.
|
| 270 |
+
5. Add persistence tests for load, save, reset, and version invalidation.
|
| 271 |
+
|
| 272 |
+
**Acceptance Criteria**:
|
| 273 |
+
- [ ] Local transcript and voice-profile metadata survive a refresh in the same browser
|
| 274 |
+
- [ ] Reset/clear removes stored state cleanly
|
| 275 |
+
- [ ] Persistence versioning prevents incompatible stale state from breaking the app
|
| 276 |
+
- [ ] README explains local dev and public deployment expectations
|
| 277 |
+
- [ ] Persistence tests pass under `bun test`
|
| 278 |
+
|
| 279 |
+
**Verification**:
|
| 280 |
+
- Commands: `bun test`, `bun run build`
|
| 281 |
+
- Manual checks: refresh after a successful interaction and verify continuity or reset behavior matches the spec
|
| 282 |
+
|
| 283 |
+
**Rollback**:
|
| 284 |
+
- Revert the persistence commit and clear browser local storage
|
index.html
CHANGED
|
@@ -1,19 +1,72 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta
|
| 6 |
+
name="viewport"
|
| 7 |
+
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
| 8 |
+
/>
|
| 9 |
+
<title>Personal Voice Agent</title>
|
| 10 |
+
<meta
|
| 11 |
+
name="description"
|
| 12 |
+
content="Local browser voice assistant with on-device speech processing."
|
| 13 |
+
/>
|
| 14 |
+
<script type="module" src="./src/main.ts"></script>
|
| 15 |
+
</head>
|
| 16 |
+
<body>
|
| 17 |
+
<main class="app-shell">
|
| 18 |
+
<section class="view" id="calibration-view">
|
| 19 |
+
<article class="card calibration-card">
|
| 20 |
+
<p class="eyebrow">Microphone Calibration</p>
|
| 21 |
+
<h1 class="card-title">Speech Calibration</h1>
|
| 22 |
+
<p class="card-copy" id="calibration-copy">
|
| 23 |
+
Read the following lines clearly at a comfortable speaking pace
|
| 24 |
+
until calibration is complete. Repeat from the beginning if time
|
| 25 |
+
remains.
|
| 26 |
+
</p>
|
| 27 |
+
<p class="prompt-block" id="calibration-prompt">
|
| 28 |
+
The quick brown fox jumps over the lazy dog.
|
| 29 |
+
I am speaking in my natural voice at a comfortable volume.
|
| 30 |
+
The sound of my own voice has never bothered me.
|
| 31 |
+
A recording is never quite the same as the original.
|
| 32 |
+
I confirm this is a true representation of how I speak.
|
| 33 |
+
My speech remains clear when I maintain a consistent distance from the microphone.
|
| 34 |
+
I will continue speaking until the calibration timer reaches zero.
|
| 35 |
+
This sample should capture my normal pronunciation and pacing.
|
| 36 |
+
</p>
|
| 37 |
+
<div class="meter" aria-hidden="true">
|
| 38 |
+
<div class="meter-fill" id="calibration-fill"></div>
|
| 39 |
+
</div>
|
| 40 |
+
<p class="timer-text" id="calibration-timer">
|
| 41 |
+
Calibration required before the first session.
|
| 42 |
+
</p>
|
| 43 |
+
<p class="summary-text" id="benchmark-summary" hidden></p>
|
| 44 |
+
<div class="button-row">
|
| 45 |
+
<button class="primary-button" id="calibration-button" type="button">
|
| 46 |
+
Calibrate
|
| 47 |
+
</button>
|
| 48 |
+
</div>
|
| 49 |
+
<div class="error-box" id="calibration-error" hidden></div>
|
| 50 |
+
</article>
|
| 51 |
+
</section>
|
| 52 |
+
|
| 53 |
+
<section class="view" id="assistant-view" hidden>
|
| 54 |
+
<article class="card assistant-card">
|
| 55 |
+
<div class="transcript-log" id="transcript-log">
|
| 56 |
+
<div class="transcript-empty" id="transcript-empty">
|
| 57 |
+
Press Record and ask a question.
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<div class="button-row">
|
| 62 |
+
<button class="primary-button" id="record-button" type="button">
|
| 63 |
+
Record
|
| 64 |
+
</button>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<div class="error-box" id="assistant-error" hidden></div>
|
| 68 |
+
</article>
|
| 69 |
+
</section>
|
| 70 |
+
</main>
|
| 71 |
+
</body>
|
| 72 |
</html>
|
package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "private-voice-agent",
|
| 3 |
+
"type": "module",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "vite --host 0.0.0.0 --port 3000",
|
| 7 |
+
"build": "vite build",
|
| 8 |
+
"preview": "vite preview --host 0.0.0.0 --port 3000",
|
| 9 |
+
"test": "bun test"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@huggingface/transformers": "^3.8.1",
|
| 13 |
+
"@mlc-ai/web-llm": "^0.2.82",
|
| 14 |
+
"webtalk": "^1.0.42"
|
| 15 |
+
},
|
| 16 |
+
"devDependencies": {
|
| 17 |
+
"@types/bun": "latest",
|
| 18 |
+
"vite": "^7.1.3"
|
| 19 |
+
},
|
| 20 |
+
"peerDependencies": {
|
| 21 |
+
"typescript": "^5"
|
| 22 |
+
},
|
| 23 |
+
"trustedDependencies": [
|
| 24 |
+
"onnxruntime-node",
|
| 25 |
+
"protobufjs"
|
| 26 |
+
]
|
| 27 |
+
}
|
src/adapters/asr.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
ASRAdapter,
|
| 3 |
+
ASRResult,
|
| 4 |
+
RuntimeWarmupOptions,
|
| 5 |
+
} from "../app/types";
|
| 6 |
+
|
| 7 |
+
interface TransformerPipeline {
|
| 8 |
+
(input: string): Promise<{ text?: string } | string>;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
type TransformersProgressInfo = {
|
| 12 |
+
status?: "initiate" | "download" | "progress" | "done" | "ready";
|
| 13 |
+
file?: string;
|
| 14 |
+
progress?: number;
|
| 15 |
+
task?: string;
|
| 16 |
+
model?: string;
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const formatProgress = (progress: TransformersProgressInfo): string => {
|
| 20 |
+
switch (progress.status) {
|
| 21 |
+
case "initiate":
|
| 22 |
+
return `Preparing ${progress.file ?? "speech model"}...`;
|
| 23 |
+
case "download":
|
| 24 |
+
return `Downloading ${progress.file ?? "speech model"}...`;
|
| 25 |
+
case "progress":
|
| 26 |
+
return `Downloading ${progress.file ?? "speech model"} (${Math.round(progress.progress ?? 0)}%)`;
|
| 27 |
+
case "done":
|
| 28 |
+
return `Loaded ${progress.file ?? "speech model"}.`;
|
| 29 |
+
case "ready":
|
| 30 |
+
return `Speech model ready (${progress.model ?? progress.task ?? "loaded"}).`;
|
| 31 |
+
default:
|
| 32 |
+
return "Loading speech model...";
|
| 33 |
+
}
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
export class TransformersAsrAdapter implements ASRAdapter {
|
| 37 |
+
#pipeline: TransformerPipeline | null = null;
|
| 38 |
+
#device: "webgpu" | "wasm" = "wasm";
|
| 39 |
+
|
| 40 |
+
constructor(private readonly modelId: string) {}
|
| 41 |
+
|
| 42 |
+
async initialize(options: RuntimeWarmupOptions = {}): Promise<void> {
|
| 43 |
+
if (this.#pipeline) {
|
| 44 |
+
return;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const { env, pipeline } = await import("@huggingface/transformers");
|
| 48 |
+
env.allowLocalModels = false;
|
| 49 |
+
env.useFS = false;
|
| 50 |
+
env.useFSCache = false;
|
| 51 |
+
env.useBrowserCache = true;
|
| 52 |
+
|
| 53 |
+
const preferredDevice =
|
| 54 |
+
typeof navigator !== "undefined" && "gpu" in navigator ? "webgpu" : "wasm";
|
| 55 |
+
|
| 56 |
+
try {
|
| 57 |
+
options.onProgress?.(`Loading ${this.modelId} on ${preferredDevice.toUpperCase()}...`);
|
| 58 |
+
this.#pipeline = (await pipeline(
|
| 59 |
+
"automatic-speech-recognition",
|
| 60 |
+
this.modelId,
|
| 61 |
+
{
|
| 62 |
+
device: preferredDevice,
|
| 63 |
+
dtype: preferredDevice === "webgpu" ? "fp32" : "q8",
|
| 64 |
+
progress_callback: (progress: TransformersProgressInfo) => {
|
| 65 |
+
options.onProgress?.(formatProgress(progress));
|
| 66 |
+
},
|
| 67 |
+
},
|
| 68 |
+
)) as TransformerPipeline;
|
| 69 |
+
this.#device = preferredDevice;
|
| 70 |
+
} catch (error) {
|
| 71 |
+
if (preferredDevice !== "webgpu") {
|
| 72 |
+
throw error;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
console.warn(
|
| 76 |
+
"ASR WebGPU initialization failed, falling back to WASM.",
|
| 77 |
+
error,
|
| 78 |
+
);
|
| 79 |
+
|
| 80 |
+
options.onProgress?.("Speech WebGPU setup failed. Falling back to WASM...");
|
| 81 |
+
this.#pipeline = (await pipeline(
|
| 82 |
+
"automatic-speech-recognition",
|
| 83 |
+
this.modelId,
|
| 84 |
+
{
|
| 85 |
+
device: "wasm",
|
| 86 |
+
dtype: "q8",
|
| 87 |
+
progress_callback: (progress: TransformersProgressInfo) => {
|
| 88 |
+
options.onProgress?.(formatProgress(progress));
|
| 89 |
+
},
|
| 90 |
+
},
|
| 91 |
+
)) as TransformerPipeline;
|
| 92 |
+
this.#device = "wasm";
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
options.onProgress?.(`Speech ready on ${this.#device.toUpperCase()}.`);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
async warmup(options: RuntimeWarmupOptions = {}): Promise<void> {
|
| 99 |
+
await this.initialize(options);
|
| 100 |
+
options.onProgress?.("Speech runtime warmed.");
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
async transcribe(audio: Blob): Promise<ASRResult> {
|
| 104 |
+
await this.initialize();
|
| 105 |
+
|
| 106 |
+
if (!this.#pipeline) {
|
| 107 |
+
throw new Error("ASR pipeline failed to initialize.");
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
const url = URL.createObjectURL(audio);
|
| 111 |
+
|
| 112 |
+
try {
|
| 113 |
+
const result = await this.#pipeline(url);
|
| 114 |
+
const text = typeof result === "string" ? result : result.text ?? "";
|
| 115 |
+
|
| 116 |
+
if (!text.trim()) {
|
| 117 |
+
throw new Error("Speech recognition returned an empty transcript.");
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
return { text: text.trim() };
|
| 121 |
+
} finally {
|
| 122 |
+
URL.revokeObjectURL(url);
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
getDevice(): "webgpu" | "wasm" {
|
| 127 |
+
return this.#device;
|
| 128 |
+
}
|
| 129 |
+
}
|
src/adapters/llm.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
ConversationTurn,
|
| 3 |
+
LLMAdapter,
|
| 4 |
+
LLMGenerateOptions,
|
| 5 |
+
RuntimeWarmupOptions,
|
| 6 |
+
} from "../app/types";
|
| 7 |
+
import { SYSTEM_PROMPT } from "../prompts/system";
|
| 8 |
+
|
| 9 |
+
type ChatMessage = {
|
| 10 |
+
role: "system" | "user" | "assistant";
|
| 11 |
+
content: string;
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
type WebLLMModule = typeof import("@mlc-ai/web-llm");
|
| 15 |
+
type EngineType = Awaited<ReturnType<WebLLMModule["CreateMLCEngine"]>>;
|
| 16 |
+
type InitProgress = {
|
| 17 |
+
progress?: number;
|
| 18 |
+
text?: string;
|
| 19 |
+
timeElapsed?: number;
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const MODEL_PREFERENCES = [
|
| 23 |
+
"Qwen2.5-0.5B-Instruct-q4f16_1-MLC",
|
| 24 |
+
"SmolLM2-360M-Instruct-q4f16_1-MLC",
|
| 25 |
+
"Llama-3.2-1B-Instruct-q4f16_1-MLC",
|
| 26 |
+
"Qwen2.5-1.5B-Instruct-q4f16_1-MLC",
|
| 27 |
+
];
|
| 28 |
+
|
| 29 |
+
const formatProgress = (progress: InitProgress): string => {
|
| 30 |
+
const percentage =
|
| 31 |
+
typeof progress.progress === "number"
|
| 32 |
+
? `${Math.round(progress.progress * 100)}%`
|
| 33 |
+
: null;
|
| 34 |
+
|
| 35 |
+
if (progress.text && percentage) {
|
| 36 |
+
return `${progress.text} (${percentage})`;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
if (progress.text) {
|
| 40 |
+
return progress.text;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
if (percentage) {
|
| 44 |
+
return `Loading language model (${percentage})`;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
return "Loading language model...";
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
export class WebLLMChatAdapter implements LLMAdapter {
|
| 51 |
+
#engine: EngineType | null = null;
|
| 52 |
+
#modelId: string | null = null;
|
| 53 |
+
|
| 54 |
+
constructor(private readonly preferredModelId?: string) {}
|
| 55 |
+
|
| 56 |
+
async initialize(options: RuntimeWarmupOptions = {}): Promise<void> {
|
| 57 |
+
if (this.#engine) {
|
| 58 |
+
options.onProgress?.(
|
| 59 |
+
`Language ready (${this.#modelId ?? this.preferredModelId ?? "model loaded"}).`,
|
| 60 |
+
);
|
| 61 |
+
return;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
const webllm = await import("@mlc-ai/web-llm");
|
| 65 |
+
const modelId =
|
| 66 |
+
this.preferredModelId ?? WebLLMChatAdapter.#resolveModelId(webllm);
|
| 67 |
+
options.onProgress?.(`Preparing ${modelId}...`);
|
| 68 |
+
this.#engine = await webllm.CreateMLCEngine(modelId, {
|
| 69 |
+
initProgressCallback: (progress: InitProgress) => {
|
| 70 |
+
options.onProgress?.(formatProgress(progress));
|
| 71 |
+
},
|
| 72 |
+
});
|
| 73 |
+
this.#modelId = modelId;
|
| 74 |
+
options.onProgress?.(`Language ready (${modelId}).`);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
async generate(options: LLMGenerateOptions): Promise<string> {
|
| 78 |
+
await this.initialize();
|
| 79 |
+
|
| 80 |
+
if (!this.#engine) {
|
| 81 |
+
throw new Error("LLM engine failed to initialize.");
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const messages = WebLLMChatAdapter.#toMessages(options.turns);
|
| 85 |
+
const stream = await this.#engine.chat.completions.create({
|
| 86 |
+
messages,
|
| 87 |
+
temperature: 0.7,
|
| 88 |
+
stream: true,
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
let finalText = "";
|
| 92 |
+
|
| 93 |
+
for await (const chunk of stream) {
|
| 94 |
+
if (options.signal?.aborted) {
|
| 95 |
+
break;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const delta = chunk.choices[0]?.delta?.content ?? "";
|
| 99 |
+
if (!delta) {
|
| 100 |
+
continue;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
finalText += delta;
|
| 104 |
+
options.onChunk(delta);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
return finalText.trim();
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
async warmup(options: RuntimeWarmupOptions = {}): Promise<void> {
|
| 111 |
+
await this.initialize(options);
|
| 112 |
+
|
| 113 |
+
if (!this.#engine) {
|
| 114 |
+
throw new Error("LLM engine failed to initialize.");
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
options.onProgress?.("Compiling first language response...");
|
| 118 |
+
await this.#engine.chat.completions.create({
|
| 119 |
+
messages: [
|
| 120 |
+
{
|
| 121 |
+
role: "system",
|
| 122 |
+
content: SYSTEM_PROMPT,
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
role: "user",
|
| 126 |
+
content: "Reply with one short word.",
|
| 127 |
+
},
|
| 128 |
+
],
|
| 129 |
+
temperature: 0,
|
| 130 |
+
max_tokens: 1,
|
| 131 |
+
});
|
| 132 |
+
options.onProgress?.("Language runtime warmed.");
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
static #toMessages(turns: ConversationTurn[]): ChatMessage[] {
|
| 136 |
+
const messages: ChatMessage[] = [
|
| 137 |
+
{
|
| 138 |
+
role: "system",
|
| 139 |
+
content: SYSTEM_PROMPT,
|
| 140 |
+
},
|
| 141 |
+
];
|
| 142 |
+
|
| 143 |
+
for (const turn of turns) {
|
| 144 |
+
if (turn.role === "system" || !turn.transcript.trim()) {
|
| 145 |
+
continue;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
messages.push({
|
| 149 |
+
role: turn.role,
|
| 150 |
+
content: turn.transcript,
|
| 151 |
+
});
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
return messages;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
static #resolveModelId(webllm: WebLLMModule): string {
|
| 158 |
+
const candidates = webllm.prebuiltAppConfig?.model_list ?? [];
|
| 159 |
+
const normalized = candidates
|
| 160 |
+
.map((candidate) => {
|
| 161 |
+
const modelId = "model_id" in candidate ? candidate.model_id : "";
|
| 162 |
+
return { candidate, modelId };
|
| 163 |
+
})
|
| 164 |
+
.filter((entry) => Boolean(entry.modelId));
|
| 165 |
+
|
| 166 |
+
for (const preference of MODEL_PREFERENCES) {
|
| 167 |
+
const match = normalized.find((entry) =>
|
| 168 |
+
entry.modelId.includes(preference),
|
| 169 |
+
);
|
| 170 |
+
if (match) {
|
| 171 |
+
return match.modelId;
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
const fallback = normalized.at(0)?.modelId;
|
| 176 |
+
if (!fallback) {
|
| 177 |
+
throw new Error("WebLLM did not expose any prebuilt models.");
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
return fallback;
|
| 181 |
+
}
|
| 182 |
+
}
|
src/adapters/tts.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
RuntimeWarmupOptions,
|
| 3 |
+
TTSAdapter,
|
| 4 |
+
TTSBootstrapResult,
|
| 5 |
+
VoiceProfileState,
|
| 6 |
+
} from "../app/types";
|
| 7 |
+
import { BrowserPocketTTSRuntime } from "../services/pocket-runtime";
|
| 8 |
+
|
| 9 |
+
interface BrowserPocketTTSRuntime {
|
| 10 |
+
initialize?: (options?: {
|
| 11 |
+
modelId: string;
|
| 12 |
+
onProgress?: (message: string) => void;
|
| 13 |
+
}) => Promise<void>;
|
| 14 |
+
warmup?: (options?: { onProgress?: (message: string) => void }) => Promise<void>;
|
| 15 |
+
bootstrapFromUtterance: (audio: Blob) => Promise<{ embeddingId?: string }>;
|
| 16 |
+
stream?: (options: {
|
| 17 |
+
text: string;
|
| 18 |
+
signal?: AbortSignal;
|
| 19 |
+
referenceAudio?: Blob;
|
| 20 |
+
onAudioChunk: (chunk: Float32Array) => void | Promise<void>;
|
| 21 |
+
}) => Promise<void>;
|
| 22 |
+
synthesize: (options: {
|
| 23 |
+
text: string;
|
| 24 |
+
signal?: AbortSignal;
|
| 25 |
+
referenceAudio?: Blob;
|
| 26 |
+
}) => Promise<Blob>;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
declare global {
|
| 30 |
+
interface Window {
|
| 31 |
+
__PRIVATE_VOICE_POCKET_TTS__?: BrowserPocketTTSRuntime;
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const createVoiceProfile = (
|
| 36 |
+
embeddingCacheKey: string | null,
|
| 37 |
+
): VoiceProfileState => ({
|
| 38 |
+
source: embeddingCacheKey ? "cached" : "first-utterance",
|
| 39 |
+
ready: true,
|
| 40 |
+
referenceAudioKey: embeddingCacheKey ?? "first-utterance",
|
| 41 |
+
embeddingCacheKey,
|
| 42 |
+
lastUpdatedAt: Date.now(),
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
export class PocketTTSAdapter implements TTSAdapter {
|
| 46 |
+
#runtime: BrowserPocketTTSRuntime | null = null;
|
| 47 |
+
#referenceAudio: Blob | null = null;
|
| 48 |
+
#voiceProfile: VoiceProfileState | null = null;
|
| 49 |
+
|
| 50 |
+
constructor(private readonly modelId: string) {}
|
| 51 |
+
|
| 52 |
+
async initialize(options: RuntimeWarmupOptions = {}): Promise<void> {
|
| 53 |
+
if (this.#runtime) {
|
| 54 |
+
options.onProgress?.("Voice runtime ready.");
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const runtime =
|
| 59 |
+
typeof window !== "undefined"
|
| 60 |
+
? window.__PRIVATE_VOICE_POCKET_TTS__ ??
|
| 61 |
+
(this.#runtime ??= new BrowserPocketTTSRuntime())
|
| 62 |
+
: null;
|
| 63 |
+
|
| 64 |
+
if (!runtime) {
|
| 65 |
+
throw new Error(
|
| 66 |
+
"Speech runtime is unavailable in this browser environment.",
|
| 67 |
+
);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
await runtime.initialize?.({
|
| 71 |
+
modelId: this.modelId,
|
| 72 |
+
onProgress: options.onProgress,
|
| 73 |
+
});
|
| 74 |
+
this.#runtime = runtime;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
async warmup(options: RuntimeWarmupOptions = {}): Promise<void> {
|
| 78 |
+
await this.initialize(options);
|
| 79 |
+
await this.#runtime?.warmup?.({
|
| 80 |
+
onProgress: options.onProgress,
|
| 81 |
+
});
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
async bootstrapFromUtterance(audio: Blob): Promise<TTSBootstrapResult> {
|
| 85 |
+
await this.initialize();
|
| 86 |
+
|
| 87 |
+
if (!this.#runtime) {
|
| 88 |
+
throw new Error("Speech runtime is unavailable.");
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
this.#referenceAudio = audio;
|
| 92 |
+
|
| 93 |
+
const cloned = await this.#runtime.bootstrapFromUtterance(audio);
|
| 94 |
+
const voiceProfile = createVoiceProfile(cloned.embeddingId ?? null);
|
| 95 |
+
this.#voiceProfile = voiceProfile;
|
| 96 |
+
|
| 97 |
+
return { voiceProfile };
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
async synthesize(options: {
|
| 101 |
+
text: string;
|
| 102 |
+
signal?: AbortSignal;
|
| 103 |
+
referenceAudio?: Blob;
|
| 104 |
+
}): Promise<Blob> {
|
| 105 |
+
await this.initialize();
|
| 106 |
+
|
| 107 |
+
if (options.signal?.aborted) {
|
| 108 |
+
throw new Error("Speech synthesis was cancelled.");
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
if (!this.#runtime) {
|
| 112 |
+
throw new Error("Speech runtime is unavailable.");
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
const referenceAudio = options.referenceAudio ?? this.#referenceAudio;
|
| 116 |
+
if (!referenceAudio) {
|
| 117 |
+
throw new Error("No voice reference is available for synthesis.");
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
return this.#runtime.synthesize({
|
| 121 |
+
text: options.text,
|
| 122 |
+
signal: options.signal,
|
| 123 |
+
referenceAudio,
|
| 124 |
+
});
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
async synthesizeStream(options: {
|
| 128 |
+
text: string;
|
| 129 |
+
signal?: AbortSignal;
|
| 130 |
+
referenceAudio?: Blob;
|
| 131 |
+
onAudioChunk: (chunk: Float32Array) => void | Promise<void>;
|
| 132 |
+
}): Promise<void> {
|
| 133 |
+
await this.initialize();
|
| 134 |
+
|
| 135 |
+
if (options.signal?.aborted) {
|
| 136 |
+
throw new Error("Speech synthesis was cancelled.");
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
if (!this.#runtime?.stream) {
|
| 140 |
+
throw new Error("Streaming speech synthesis is unavailable.");
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
const referenceAudio = options.referenceAudio ?? this.#referenceAudio;
|
| 144 |
+
if (!referenceAudio && !this.#voiceProfile?.ready) {
|
| 145 |
+
throw new Error("No voice reference is available for synthesis.");
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
await this.#runtime.stream({
|
| 149 |
+
text: options.text,
|
| 150 |
+
signal: options.signal,
|
| 151 |
+
referenceAudio,
|
| 152 |
+
onAudioChunk: options.onAudioChunk,
|
| 153 |
+
});
|
| 154 |
+
}
|
| 155 |
+
}
|
src/app/controller.ts
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
AppState,
|
| 3 |
+
AudioDurationCaptureProgress,
|
| 4 |
+
ConversationControllerDependencies,
|
| 5 |
+
ConversationTurn,
|
| 6 |
+
MicPermissionState,
|
| 7 |
+
} from "./types";
|
| 8 |
+
import { AppStore } from "./store";
|
| 9 |
+
import {
|
| 10 |
+
CALIBRATION_DURATION_MS,
|
| 11 |
+
CALIBRATION_DURATION_SECONDS,
|
| 12 |
+
createEmptyVoiceProfile,
|
| 13 |
+
} from "./types";
|
| 14 |
+
|
| 15 |
+
const createTurn = (
|
| 16 |
+
role: ConversationTurn["role"],
|
| 17 |
+
transcript: string,
|
| 18 |
+
options: {
|
| 19 |
+
audioStatus?: ConversationTurn["audioStatus"];
|
| 20 |
+
isFirstContact?: boolean;
|
| 21 |
+
} = {},
|
| 22 |
+
): ConversationTurn => ({
|
| 23 |
+
id: crypto.randomUUID(),
|
| 24 |
+
role,
|
| 25 |
+
transcript,
|
| 26 |
+
audioStatus: options.audioStatus ?? "none",
|
| 27 |
+
createdAt: Date.now(),
|
| 28 |
+
isFirstContact: options.isFirstContact ?? false,
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
const MIN_EAGER_SPEECH_CHARS = 160;
|
| 32 |
+
const MIN_EAGER_SPEECH_WORDS = 24;
|
| 33 |
+
const EAGER_SPEECH_FLUSH_DELAY_MS = 520;
|
| 34 |
+
|
| 35 |
+
const countWords = (value: string): number =>
|
| 36 |
+
value
|
| 37 |
+
.trim()
|
| 38 |
+
.split(/\s+/)
|
| 39 |
+
.filter(Boolean).length;
|
| 40 |
+
|
| 41 |
+
const findSentenceBoundary = (value: string): number => {
|
| 42 |
+
const match = /[.!?]+["')\]]*(?=\s|$)/.exec(value);
|
| 43 |
+
if (!match || match.index == null) {
|
| 44 |
+
return -1;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
return match.index + match[0].length;
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
const findEagerBoundary = (value: string): number => {
|
| 51 |
+
if (
|
| 52 |
+
value.length < MIN_EAGER_SPEECH_CHARS ||
|
| 53 |
+
countWords(value) < MIN_EAGER_SPEECH_WORDS
|
| 54 |
+
) {
|
| 55 |
+
return -1;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const whitespaceBoundary = value.lastIndexOf(" ");
|
| 59 |
+
if (whitespaceBoundary >= Math.floor(MIN_EAGER_SPEECH_CHARS * 0.94)) {
|
| 60 |
+
return whitespaceBoundary;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
return -1;
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
const drainCompletedSpeechSegments = (
|
| 67 |
+
buffer: string,
|
| 68 |
+
force = false,
|
| 69 |
+
): { segments: string[]; remainder: string } => {
|
| 70 |
+
let remainder = buffer.replace(/^\s+/, "");
|
| 71 |
+
const segments: string[] = [];
|
| 72 |
+
|
| 73 |
+
while (remainder.trim()) {
|
| 74 |
+
const sentenceBoundary = findSentenceBoundary(remainder);
|
| 75 |
+
if (sentenceBoundary > 0) {
|
| 76 |
+
const segment = remainder.slice(0, sentenceBoundary).trim();
|
| 77 |
+
if (segment) {
|
| 78 |
+
segments.push(segment);
|
| 79 |
+
}
|
| 80 |
+
remainder = remainder.slice(sentenceBoundary).replace(/^\s+/, "");
|
| 81 |
+
continue;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
if (!force) {
|
| 85 |
+
break;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const finalSegment = remainder.trim();
|
| 89 |
+
if (finalSegment) {
|
| 90 |
+
segments.push(finalSegment);
|
| 91 |
+
}
|
| 92 |
+
remainder = "";
|
| 93 |
+
break;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
return { segments, remainder };
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
const drainEagerSpeechSegment = (
|
| 100 |
+
buffer: string,
|
| 101 |
+
): { segment: string | null; remainder: string } => {
|
| 102 |
+
const remainder = buffer.replace(/^\s+/, "");
|
| 103 |
+
const eagerBoundary = findEagerBoundary(remainder);
|
| 104 |
+
|
| 105 |
+
if (eagerBoundary <= 0) {
|
| 106 |
+
return { segment: null, remainder };
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
return {
|
| 110 |
+
segment: remainder.slice(0, eagerBoundary).trim(),
|
| 111 |
+
remainder: remainder.slice(eagerBoundary).replace(/^\s+/, ""),
|
| 112 |
+
};
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
export class ConversationController {
|
| 116 |
+
#store: AppStore;
|
| 117 |
+
#deps: ConversationControllerDependencies;
|
| 118 |
+
#sessionAbortController: AbortController | null = null;
|
| 119 |
+
#loopTask: Promise<void> | null = null;
|
| 120 |
+
|
| 121 |
+
constructor(store: AppStore, deps: ConversationControllerDependencies) {
|
| 122 |
+
this.#store = store;
|
| 123 |
+
this.#deps = deps;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
get state(): AppState {
|
| 127 |
+
return this.#store.getState();
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
async bootstrap(): Promise<void> {
|
| 131 |
+
const capability = await this.#deps.capabilities.detect();
|
| 132 |
+
this.#store.update({
|
| 133 |
+
capability,
|
| 134 |
+
runtimeReady: false,
|
| 135 |
+
statusText: capability.canRunDemo ? "Run Microphone Calibration" : "Unsupported",
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
const persisted = this.#deps.persistence.load();
|
| 139 |
+
if (persisted) {
|
| 140 |
+
this.#store.hydrate(persisted);
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
setConsentAccepted(consentAccepted: boolean): void {
|
| 145 |
+
this.#store.update({ consentAccepted });
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
async runBenchmark(): Promise<void> {
|
| 149 |
+
const state = this.#store.getState();
|
| 150 |
+
const updateCalibrationProgress = (
|
| 151 |
+
progress: AudioDurationCaptureProgress,
|
| 152 |
+
) => {
|
| 153 |
+
if (this.#store.getState().phase !== "benchmarking") {
|
| 154 |
+
return;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
this.#store.update({
|
| 158 |
+
calibrationProgress: progress.progress,
|
| 159 |
+
calibrationSecondsRemaining: Math.max(
|
| 160 |
+
0,
|
| 161 |
+
Math.ceil(progress.remainingMs / 1000),
|
| 162 |
+
),
|
| 163 |
+
calibrationRecording: progress.isRecording,
|
| 164 |
+
});
|
| 165 |
+
};
|
| 166 |
+
|
| 167 |
+
if (state.phase === "benchmarking") {
|
| 168 |
+
return;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
if (!state.consentAccepted) {
|
| 172 |
+
this.#store.update({
|
| 173 |
+
errorMessage: "Allow microphone access and local audio processing to calibrate the system.",
|
| 174 |
+
phase: "error",
|
| 175 |
+
statusText: "Consent required",
|
| 176 |
+
runtimeReady: false,
|
| 177 |
+
});
|
| 178 |
+
return;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
if (!state.capability.canRunDemo) {
|
| 182 |
+
this.#store.update({
|
| 183 |
+
errorMessage: state.capability.failureReason,
|
| 184 |
+
phase: "error",
|
| 185 |
+
statusText: "Unsupported",
|
| 186 |
+
runtimeReady: false,
|
| 187 |
+
});
|
| 188 |
+
return;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
this.#store.update({
|
| 192 |
+
phase: "benchmarking",
|
| 193 |
+
statusText: "Microphone calibration",
|
| 194 |
+
errorMessage: null,
|
| 195 |
+
runtimeReady: false,
|
| 196 |
+
benchmarkSummary: null,
|
| 197 |
+
calibrationProgress: 0,
|
| 198 |
+
calibrationSecondsRemaining: CALIBRATION_DURATION_SECONDS,
|
| 199 |
+
calibrationRecording: false,
|
| 200 |
+
voiceProfile: createEmptyVoiceProfile(),
|
| 201 |
+
});
|
| 202 |
+
|
| 203 |
+
try {
|
| 204 |
+
const micPermission =
|
| 205 |
+
await this.#deps.audioCapture.ensureMicrophonePermission();
|
| 206 |
+
this.#store.update({ micPermission });
|
| 207 |
+
|
| 208 |
+
if (micPermission !== "granted") {
|
| 209 |
+
throw new Error(
|
| 210 |
+
"Microphone access was denied. Retry when the browser permission is available.",
|
| 211 |
+
);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
const calibrationAbortController = new AbortController();
|
| 215 |
+
const warmupPromise = (async () => {
|
| 216 |
+
await this.#deps.asr.initialize();
|
| 217 |
+
await this.#deps.asr.warmup?.();
|
| 218 |
+
await this.#deps.llm.initialize();
|
| 219 |
+
await this.#deps.llm.warmup?.();
|
| 220 |
+
await this.#deps.tts.initialize();
|
| 221 |
+
await this.#deps.tts.warmup?.();
|
| 222 |
+
})().catch((error) => {
|
| 223 |
+
calibrationAbortController.abort();
|
| 224 |
+
throw error;
|
| 225 |
+
});
|
| 226 |
+
|
| 227 |
+
this.#store.update({
|
| 228 |
+
statusText: "Microphone calibration",
|
| 229 |
+
calibrationRecording: true,
|
| 230 |
+
});
|
| 231 |
+
const calibrationAudio = await this.#deps.audioCapture.recordForDuration({
|
| 232 |
+
durationMs: CALIBRATION_DURATION_MS,
|
| 233 |
+
signal: calibrationAbortController.signal,
|
| 234 |
+
onProgress: updateCalibrationProgress,
|
| 235 |
+
});
|
| 236 |
+
|
| 237 |
+
this.#store.update({
|
| 238 |
+
calibrationRecording: false,
|
| 239 |
+
calibrationProgress: 1,
|
| 240 |
+
calibrationSecondsRemaining: 0,
|
| 241 |
+
statusText: "Finalizing calibration",
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
await warmupPromise;
|
| 245 |
+
await this.#deps.asr.transcribe(calibrationAudio);
|
| 246 |
+
|
| 247 |
+
this.#store.update({
|
| 248 |
+
statusText: "Finalizing calibration",
|
| 249 |
+
});
|
| 250 |
+
const bootstrap = await this.#deps.tts.bootstrapFromUtterance(
|
| 251 |
+
calibrationAudio,
|
| 252 |
+
);
|
| 253 |
+
this.#store.setVoiceProfile(bootstrap.voiceProfile);
|
| 254 |
+
|
| 255 |
+
this.#store.update({
|
| 256 |
+
phase: "idle",
|
| 257 |
+
statusText: state.capability.hasCrossOriginIsolation
|
| 258 |
+
? "Ready"
|
| 259 |
+
: "Ready (single-threaded)",
|
| 260 |
+
runtimeReady: true,
|
| 261 |
+
errorMessage: null,
|
| 262 |
+
benchmarkSummary:
|
| 263 |
+
"Calibration ready. Captured 20 seconds of local audio and completed the transcription check.",
|
| 264 |
+
});
|
| 265 |
+
} catch (error) {
|
| 266 |
+
this.#store.update({
|
| 267 |
+
phase: "error",
|
| 268 |
+
statusText: "Calibration failed",
|
| 269 |
+
runtimeReady: false,
|
| 270 |
+
calibrationRecording: false,
|
| 271 |
+
errorMessage:
|
| 272 |
+
error instanceof Error
|
| 273 |
+
? error.message
|
| 274 |
+
: "Microphone calibration failed.",
|
| 275 |
+
});
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
async start(): Promise<void> {
|
| 280 |
+
const state = this.#store.getState();
|
| 281 |
+
|
| 282 |
+
if (
|
| 283 |
+
state.phase === "arming" ||
|
| 284 |
+
state.phase === "listening" ||
|
| 285 |
+
state.phase === "thinking" ||
|
| 286 |
+
state.phase === "speaking"
|
| 287 |
+
) {
|
| 288 |
+
this.#stopActiveSession();
|
| 289 |
+
return;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
if (!state.consentAccepted) {
|
| 293 |
+
this.#store.update({
|
| 294 |
+
errorMessage: "Allow microphone access and local audio processing to begin.",
|
| 295 |
+
phase: "error",
|
| 296 |
+
statusText: "Consent required",
|
| 297 |
+
});
|
| 298 |
+
return;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
if (!state.capability.canRunDemo) {
|
| 302 |
+
this.#store.update({
|
| 303 |
+
errorMessage: state.capability.failureReason,
|
| 304 |
+
phase: "error",
|
| 305 |
+
statusText: "Unsupported",
|
| 306 |
+
});
|
| 307 |
+
return;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
if (!state.runtimeReady) {
|
| 311 |
+
this.#store.update({
|
| 312 |
+
phase: "error",
|
| 313 |
+
statusText: "Calibration required",
|
| 314 |
+
errorMessage: "Run Microphone Calibration before starting a session.",
|
| 315 |
+
});
|
| 316 |
+
return;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
this.#sessionAbortController?.abort();
|
| 320 |
+
this.#sessionAbortController = new AbortController();
|
| 321 |
+
|
| 322 |
+
this.#store.update({
|
| 323 |
+
errorMessage: null,
|
| 324 |
+
phase: "arming",
|
| 325 |
+
statusText: "Arming microphone",
|
| 326 |
+
});
|
| 327 |
+
|
| 328 |
+
const micPermission = await this.#deps.audioCapture.ensureMicrophonePermission();
|
| 329 |
+
this.#store.update({ micPermission });
|
| 330 |
+
|
| 331 |
+
if (micPermission !== "granted") {
|
| 332 |
+
this.#store.update({
|
| 333 |
+
phase: "error",
|
| 334 |
+
statusText: "Microphone denied",
|
| 335 |
+
errorMessage: "Microphone access was denied. Retry when the browser permission is available.",
|
| 336 |
+
});
|
| 337 |
+
return;
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
this.#loopTask = this.#runLoop(this.#sessionAbortController.signal);
|
| 341 |
+
void this.#loopTask;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
async clear(): Promise<void> {
|
| 345 |
+
this.#stopActiveSession();
|
| 346 |
+
this.#deps.persistence.clear();
|
| 347 |
+
this.#store.clearConversation();
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
async dispose(): Promise<void> {
|
| 351 |
+
await this.clear();
|
| 352 |
+
await this.#deps.audioCapture.dispose();
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
async #runLoop(signal: AbortSignal): Promise<void> {
|
| 356 |
+
try {
|
| 357 |
+
this.#store.update({
|
| 358 |
+
phase: "listening",
|
| 359 |
+
statusText: "Listening",
|
| 360 |
+
errorMessage: null,
|
| 361 |
+
});
|
| 362 |
+
|
| 363 |
+
const utterance = await this.#deps.audioCapture.listenForUtterance({
|
| 364 |
+
signal,
|
| 365 |
+
});
|
| 366 |
+
if (signal.aborted) {
|
| 367 |
+
return;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
await this.#processUtterance(utterance, signal);
|
| 371 |
+
} catch (error) {
|
| 372 |
+
if (signal.aborted) {
|
| 373 |
+
return;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
this.#store.update({
|
| 377 |
+
phase: "error",
|
| 378 |
+
statusText: "Runtime failed",
|
| 379 |
+
errorMessage:
|
| 380 |
+
error instanceof Error ? error.message : "Audio or model runtime failed.",
|
| 381 |
+
});
|
| 382 |
+
} finally {
|
| 383 |
+
if (this.#sessionAbortController?.signal === signal) {
|
| 384 |
+
this.#sessionAbortController = null;
|
| 385 |
+
}
|
| 386 |
+
if (this.#loopTask) {
|
| 387 |
+
this.#loopTask = null;
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
async #processUtterance(audio: Blob, signal: AbortSignal): Promise<void> {
|
| 393 |
+
this.#store.update({
|
| 394 |
+
phase: "thinking",
|
| 395 |
+
statusText: "Transcribing",
|
| 396 |
+
errorMessage: null,
|
| 397 |
+
});
|
| 398 |
+
|
| 399 |
+
const transcript = await this.#deps.asr.transcribe(audio);
|
| 400 |
+
const userTurn = createTurn("user", transcript.text);
|
| 401 |
+
this.#store.appendTurn(userTurn);
|
| 402 |
+
const promptTurns = this.#store.getState().turns;
|
| 403 |
+
|
| 404 |
+
let voiceProfile = this.#store.getState().voiceProfile;
|
| 405 |
+
const voiceBootstrapPromise = voiceProfile.ready
|
| 406 |
+
? Promise.resolve({ voiceProfile })
|
| 407 |
+
: this.#deps.tts.bootstrapFromUtterance(audio);
|
| 408 |
+
|
| 409 |
+
if (!voiceProfile.ready) {
|
| 410 |
+
this.#store.update({
|
| 411 |
+
statusText: "Thinking",
|
| 412 |
+
});
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
const assistantTurn = createTurn("assistant", "", {
|
| 416 |
+
audioStatus: "queued",
|
| 417 |
+
isFirstContact: !this.#store.getState().firstContactComplete,
|
| 418 |
+
});
|
| 419 |
+
this.#store.appendTurn(assistantTurn);
|
| 420 |
+
this.#store.update({
|
| 421 |
+
statusText: "Thinking",
|
| 422 |
+
});
|
| 423 |
+
|
| 424 |
+
const playbackStream = this.#deps.playback.createStream({
|
| 425 |
+
signal,
|
| 426 |
+
onEnded: () => {
|
| 427 |
+
this.#store.updateTurn(assistantTurn.id, { audioStatus: "done" });
|
| 428 |
+
},
|
| 429 |
+
});
|
| 430 |
+
|
| 431 |
+
let speechBuffer = "";
|
| 432 |
+
let speechFlushTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
|
| 433 |
+
let hasStartedAudio = false;
|
| 434 |
+
let speechQueue = Promise.resolve();
|
| 435 |
+
const ensureVoiceProfile = async () => {
|
| 436 |
+
if (voiceProfile.ready) {
|
| 437 |
+
return;
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
const bootstrap = await voiceBootstrapPromise;
|
| 441 |
+
voiceProfile = bootstrap.voiceProfile;
|
| 442 |
+
this.#store.setVoiceProfile(voiceProfile);
|
| 443 |
+
};
|
| 444 |
+
const enqueueSpeech = (segment: string) => {
|
| 445 |
+
const text = segment.trim();
|
| 446 |
+
if (!text) {
|
| 447 |
+
return;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
speechQueue = speechQueue.then(async () => {
|
| 451 |
+
if (signal.aborted) {
|
| 452 |
+
return;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
await ensureVoiceProfile();
|
| 456 |
+
await this.#deps.tts.synthesizeStream({
|
| 457 |
+
text,
|
| 458 |
+
signal,
|
| 459 |
+
onAudioChunk: async (chunk) => {
|
| 460 |
+
if (!hasStartedAudio) {
|
| 461 |
+
hasStartedAudio = true;
|
| 462 |
+
this.#store.update({
|
| 463 |
+
phase: "speaking",
|
| 464 |
+
statusText: "Speaking",
|
| 465 |
+
});
|
| 466 |
+
this.#store.updateTurn(assistantTurn.id, {
|
| 467 |
+
audioStatus: "playing",
|
| 468 |
+
});
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
await playbackStream.enqueue(chunk);
|
| 472 |
+
},
|
| 473 |
+
});
|
| 474 |
+
});
|
| 475 |
+
};
|
| 476 |
+
const clearSpeechFlushTimer = () => {
|
| 477 |
+
if (speechFlushTimer == null) {
|
| 478 |
+
return;
|
| 479 |
+
}
|
| 480 |
+
globalThis.clearTimeout(speechFlushTimer);
|
| 481 |
+
speechFlushTimer = null;
|
| 482 |
+
};
|
| 483 |
+
const scheduleSpeechFlush = () => {
|
| 484 |
+
if (speechFlushTimer != null || signal.aborted) {
|
| 485 |
+
return;
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
speechFlushTimer = globalThis.setTimeout(() => {
|
| 489 |
+
speechFlushTimer = null;
|
| 490 |
+
const eager = drainEagerSpeechSegment(speechBuffer);
|
| 491 |
+
if (!eager.segment) {
|
| 492 |
+
return;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
speechBuffer = eager.remainder;
|
| 496 |
+
enqueueSpeech(eager.segment);
|
| 497 |
+
|
| 498 |
+
if (drainEagerSpeechSegment(speechBuffer).segment) {
|
| 499 |
+
scheduleSpeechFlush();
|
| 500 |
+
}
|
| 501 |
+
}, EAGER_SPEECH_FLUSH_DELAY_MS);
|
| 502 |
+
};
|
| 503 |
+
|
| 504 |
+
const responseText = await this.#deps.llm.generate({
|
| 505 |
+
turns: promptTurns,
|
| 506 |
+
signal,
|
| 507 |
+
onChunk: (chunk) => {
|
| 508 |
+
this.#store.updateTurn(assistantTurn.id, {
|
| 509 |
+
transcript: `${this.#findTurnTranscript(assistantTurn.id)}${chunk}`,
|
| 510 |
+
});
|
| 511 |
+
|
| 512 |
+
speechBuffer += chunk;
|
| 513 |
+
const ready = drainCompletedSpeechSegments(speechBuffer);
|
| 514 |
+
speechBuffer = ready.remainder;
|
| 515 |
+
if (ready.segments.length > 0) {
|
| 516 |
+
clearSpeechFlushTimer();
|
| 517 |
+
}
|
| 518 |
+
for (const segment of ready.segments) {
|
| 519 |
+
enqueueSpeech(segment);
|
| 520 |
+
}
|
| 521 |
+
if (drainEagerSpeechSegment(speechBuffer).segment) {
|
| 522 |
+
scheduleSpeechFlush();
|
| 523 |
+
}
|
| 524 |
+
},
|
| 525 |
+
});
|
| 526 |
+
|
| 527 |
+
this.#store.updateTurn(assistantTurn.id, {
|
| 528 |
+
transcript: responseText,
|
| 529 |
+
});
|
| 530 |
+
|
| 531 |
+
clearSpeechFlushTimer();
|
| 532 |
+
const eager = drainEagerSpeechSegment(speechBuffer);
|
| 533 |
+
if (eager.segment) {
|
| 534 |
+
speechBuffer = eager.remainder;
|
| 535 |
+
enqueueSpeech(eager.segment);
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
const flushed = drainCompletedSpeechSegments(speechBuffer, true);
|
| 539 |
+
speechBuffer = flushed.remainder;
|
| 540 |
+
for (const segment of flushed.segments) {
|
| 541 |
+
enqueueSpeech(segment);
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
await ensureVoiceProfile();
|
| 545 |
+
await speechQueue;
|
| 546 |
+
await playbackStream.finish();
|
| 547 |
+
|
| 548 |
+
if (!hasStartedAudio) {
|
| 549 |
+
this.#store.updateTurn(assistantTurn.id, { audioStatus: "done" });
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
this.#store.update({
|
| 553 |
+
firstContactComplete: true,
|
| 554 |
+
phase: "idle",
|
| 555 |
+
statusText: this.#getReadyStatusText(),
|
| 556 |
+
});
|
| 557 |
+
this.#deps.persistence.save(this.#store.toPersistedSession());
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
#findTurnTranscript(turnId: string): string {
|
| 561 |
+
return (
|
| 562 |
+
this.#store
|
| 563 |
+
.getState()
|
| 564 |
+
.turns.find((turn) => turn.id === turnId)?.transcript ?? ""
|
| 565 |
+
);
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
#getReadyStatusText(): string {
|
| 569 |
+
return this.#store.getState().capability.hasCrossOriginIsolation
|
| 570 |
+
? "Ready"
|
| 571 |
+
: "Ready (single-threaded)";
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
#stopActiveSession(): void {
|
| 575 |
+
this.#sessionAbortController?.abort();
|
| 576 |
+
this.#sessionAbortController = null;
|
| 577 |
+
this.#deps.audioCapture.stop();
|
| 578 |
+
this.#deps.playback.stop();
|
| 579 |
+
this.#store.update({
|
| 580 |
+
phase: "idle",
|
| 581 |
+
statusText: this.#getReadyStatusText(),
|
| 582 |
+
});
|
| 583 |
+
}
|
| 584 |
+
}
|
src/app/store.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
AppState,
|
| 3 |
+
ConversationTurn,
|
| 4 |
+
PersistedSession,
|
| 5 |
+
VoiceProfileState,
|
| 6 |
+
} from "./types";
|
| 7 |
+
import {
|
| 8 |
+
PERSISTENCE_VERSION,
|
| 9 |
+
createEmptyVoiceProfile,
|
| 10 |
+
createInitialState,
|
| 11 |
+
} from "./types";
|
| 12 |
+
|
| 13 |
+
type Listener = (state: AppState) => void;
|
| 14 |
+
type Updater = AppState | ((state: AppState) => AppState);
|
| 15 |
+
|
| 16 |
+
const getIdleStatusText = (state: AppState): string => {
|
| 17 |
+
if (!state.capability.canRunDemo) {
|
| 18 |
+
return state.capability.failureReason ?? "Unsupported";
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
if (!state.runtimeReady) {
|
| 22 |
+
return "Run Microphone Calibration";
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return state.capability.hasCrossOriginIsolation
|
| 26 |
+
? "Ready"
|
| 27 |
+
: "Ready (single-threaded)";
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
export class AppStore {
|
| 31 |
+
#state: AppState;
|
| 32 |
+
#listeners = new Set<Listener>();
|
| 33 |
+
|
| 34 |
+
constructor(initialState: AppState = createInitialState()) {
|
| 35 |
+
this.#state = initialState;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
getState(): AppState {
|
| 39 |
+
return this.#state;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
subscribe(listener: Listener): () => void {
|
| 43 |
+
this.#listeners.add(listener);
|
| 44 |
+
listener(this.#state);
|
| 45 |
+
return () => {
|
| 46 |
+
this.#listeners.delete(listener);
|
| 47 |
+
};
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
setState(next: Updater): void {
|
| 51 |
+
const previous = this.#state;
|
| 52 |
+
const resolved = typeof next === "function" ? next(previous) : next;
|
| 53 |
+
|
| 54 |
+
if (resolved === previous) {
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
this.#state = resolved;
|
| 59 |
+
|
| 60 |
+
for (const listener of this.#listeners) {
|
| 61 |
+
listener(this.#state);
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
update(partial: Partial<AppState>): void {
|
| 66 |
+
this.setState((state) => ({ ...state, ...partial }));
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
appendTurn(turn: ConversationTurn): void {
|
| 70 |
+
this.setState((state) => ({
|
| 71 |
+
...state,
|
| 72 |
+
turns: [...state.turns, turn],
|
| 73 |
+
}));
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
updateTurn(turnId: string, update: Partial<ConversationTurn>): void {
|
| 77 |
+
this.setState((state) => ({
|
| 78 |
+
...state,
|
| 79 |
+
turns: state.turns.map((turn) =>
|
| 80 |
+
turn.id === turnId ? { ...turn, ...update } : turn,
|
| 81 |
+
),
|
| 82 |
+
}));
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
hydrate(session: PersistedSession): void {
|
| 86 |
+
this.setState((state) => ({
|
| 87 |
+
...state,
|
| 88 |
+
turns: session.turns,
|
| 89 |
+
voiceProfile: session.voiceProfile,
|
| 90 |
+
supportsResume: session.turns.length > 0 || session.voiceProfile.ready,
|
| 91 |
+
lastOpenedAt: session.lastOpenedAt,
|
| 92 |
+
firstContactComplete: session.turns.some((turn) => turn.isFirstContact),
|
| 93 |
+
statusText: getIdleStatusText(state),
|
| 94 |
+
}));
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
setVoiceProfile(voiceProfile: VoiceProfileState): void {
|
| 98 |
+
this.update({ voiceProfile });
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
clearConversation(): void {
|
| 102 |
+
this.setState((state) => ({
|
| 103 |
+
...state,
|
| 104 |
+
phase: "idle",
|
| 105 |
+
firstContactComplete: false,
|
| 106 |
+
turns: [],
|
| 107 |
+
voiceProfile: state.runtimeReady ? state.voiceProfile : createEmptyVoiceProfile(),
|
| 108 |
+
errorMessage: null,
|
| 109 |
+
statusText: getIdleStatusText(state),
|
| 110 |
+
supportsResume: false,
|
| 111 |
+
lastOpenedAt: null,
|
| 112 |
+
calibrationProgress: state.runtimeReady ? 1 : 0,
|
| 113 |
+
calibrationSecondsRemaining: state.runtimeReady ? 0 : null,
|
| 114 |
+
calibrationRecording: false,
|
| 115 |
+
}));
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
toPersistedSession(): PersistedSession {
|
| 119 |
+
const state = this.getState();
|
| 120 |
+
|
| 121 |
+
return {
|
| 122 |
+
version: PERSISTENCE_VERSION,
|
| 123 |
+
turns: state.turns.slice(-state.config.maxTurnsPersisted),
|
| 124 |
+
voiceProfile: state.voiceProfile,
|
| 125 |
+
lastOpenedAt: Date.now(),
|
| 126 |
+
};
|
| 127 |
+
}
|
| 128 |
+
}
|
src/app/types.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type AppPhase =
|
| 2 |
+
| "idle"
|
| 3 |
+
| "benchmarking"
|
| 4 |
+
| "arming"
|
| 5 |
+
| "listening"
|
| 6 |
+
| "thinking"
|
| 7 |
+
| "speaking"
|
| 8 |
+
| "error";
|
| 9 |
+
|
| 10 |
+
export type MicPermissionState = "unknown" | "granted" | "denied";
|
| 11 |
+
|
| 12 |
+
export type AudioStatus = "none" | "queued" | "playing" | "done" | "failed";
|
| 13 |
+
|
| 14 |
+
export interface AppCapability {
|
| 15 |
+
hasWebGPU: boolean;
|
| 16 |
+
hasMediaDevices: boolean;
|
| 17 |
+
hasAudioWorklet: boolean;
|
| 18 |
+
hasCrossOriginIsolation: boolean;
|
| 19 |
+
canRunDemo: boolean;
|
| 20 |
+
failureReason: string | null;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export interface ConversationTurn {
|
| 24 |
+
id: string;
|
| 25 |
+
role: "user" | "assistant" | "system";
|
| 26 |
+
transcript: string;
|
| 27 |
+
audioStatus: AudioStatus;
|
| 28 |
+
createdAt: number;
|
| 29 |
+
isFirstContact: boolean;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export interface VoiceProfileState {
|
| 33 |
+
source: "first-utterance" | "cached";
|
| 34 |
+
ready: boolean;
|
| 35 |
+
referenceAudioKey: string | null;
|
| 36 |
+
embeddingCacheKey: string | null;
|
| 37 |
+
lastUpdatedAt: number | null;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export interface AudioDurationCaptureProgress {
|
| 41 |
+
progress: number;
|
| 42 |
+
elapsedMs: number;
|
| 43 |
+
remainingMs: number;
|
| 44 |
+
isRecording: boolean;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export interface RuntimeConfig {
|
| 48 |
+
llmModelId?: string;
|
| 49 |
+
asrModelId: string;
|
| 50 |
+
ttsModelId: string;
|
| 51 |
+
maxTurnsPersisted: number;
|
| 52 |
+
targetFirstAudioMs: number;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export interface PersistedSession {
|
| 56 |
+
version: number;
|
| 57 |
+
turns: ConversationTurn[];
|
| 58 |
+
voiceProfile: VoiceProfileState;
|
| 59 |
+
lastOpenedAt: number;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export interface AppState {
|
| 63 |
+
capability: AppCapability;
|
| 64 |
+
phase: AppPhase;
|
| 65 |
+
micPermission: MicPermissionState;
|
| 66 |
+
consentAccepted: boolean;
|
| 67 |
+
firstContactComplete: boolean;
|
| 68 |
+
turns: ConversationTurn[];
|
| 69 |
+
voiceProfile: VoiceProfileState;
|
| 70 |
+
statusText: string;
|
| 71 |
+
errorMessage: string | null;
|
| 72 |
+
runtimeReady: boolean;
|
| 73 |
+
supportsResume: boolean;
|
| 74 |
+
lastOpenedAt: number | null;
|
| 75 |
+
calibrationProgress: number;
|
| 76 |
+
calibrationSecondsRemaining: number | null;
|
| 77 |
+
calibrationRecording: boolean;
|
| 78 |
+
benchmarkSummary: string | null;
|
| 79 |
+
config: RuntimeConfig;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
export interface CapabilitySnapshot {
|
| 83 |
+
hasWebGPU: boolean;
|
| 84 |
+
hasMediaDevices: boolean;
|
| 85 |
+
hasAudioWorklet: boolean;
|
| 86 |
+
hasCrossOriginIsolation: boolean;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
export interface CapabilitiesService {
|
| 90 |
+
detect(): Promise<AppCapability>;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
export interface AudioCaptureService {
|
| 94 |
+
ensureMicrophonePermission(): Promise<MicPermissionState>;
|
| 95 |
+
listenForUtterance(options?: { signal?: AbortSignal }): Promise<Blob>;
|
| 96 |
+
recordForDuration(options: {
|
| 97 |
+
durationMs: number;
|
| 98 |
+
signal?: AbortSignal;
|
| 99 |
+
onProgress?: (progress: AudioDurationCaptureProgress) => void;
|
| 100 |
+
}): Promise<Blob>;
|
| 101 |
+
stop(): void;
|
| 102 |
+
dispose(): Promise<void>;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
export interface ASRResult {
|
| 106 |
+
text: string;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
export interface RuntimeWarmupOptions {
|
| 110 |
+
onProgress?: (message: string) => void;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
export interface ASRAdapter {
|
| 114 |
+
initialize(options?: RuntimeWarmupOptions): Promise<void>;
|
| 115 |
+
warmup?(options?: RuntimeWarmupOptions): Promise<void>;
|
| 116 |
+
transcribe(audio: Blob): Promise<ASRResult>;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
export interface LLMGenerateOptions {
|
| 120 |
+
turns: ConversationTurn[];
|
| 121 |
+
signal?: AbortSignal;
|
| 122 |
+
onChunk: (chunk: string) => void;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
export interface LLMAdapter {
|
| 126 |
+
initialize(options?: RuntimeWarmupOptions): Promise<void>;
|
| 127 |
+
warmup?(options?: RuntimeWarmupOptions): Promise<void>;
|
| 128 |
+
generate(options: LLMGenerateOptions): Promise<string>;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
export interface TTSBootstrapResult {
|
| 132 |
+
voiceProfile: VoiceProfileState;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
export interface TTSAdapter {
|
| 136 |
+
initialize(options?: RuntimeWarmupOptions): Promise<void>;
|
| 137 |
+
warmup?(options?: RuntimeWarmupOptions): Promise<void>;
|
| 138 |
+
bootstrapFromUtterance(audio: Blob): Promise<TTSBootstrapResult>;
|
| 139 |
+
synthesizeStream(options: {
|
| 140 |
+
text: string;
|
| 141 |
+
signal?: AbortSignal;
|
| 142 |
+
referenceAudio?: Blob;
|
| 143 |
+
onAudioChunk: (chunk: Float32Array) => void | Promise<void>;
|
| 144 |
+
}): Promise<void>;
|
| 145 |
+
synthesize(options: {
|
| 146 |
+
text: string;
|
| 147 |
+
signal?: AbortSignal;
|
| 148 |
+
referenceAudio?: Blob;
|
| 149 |
+
}): Promise<Blob>;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
export interface PlaybackStream {
|
| 153 |
+
enqueue(chunk: Float32Array): Promise<void>;
|
| 154 |
+
finish(): Promise<void>;
|
| 155 |
+
stop(): void;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
export interface PlaybackService {
|
| 159 |
+
play(
|
| 160 |
+
audio: Blob,
|
| 161 |
+
options?: { signal?: AbortSignal; onEnded?: () => void },
|
| 162 |
+
): Promise<void>;
|
| 163 |
+
createStream(options?: {
|
| 164 |
+
signal?: AbortSignal;
|
| 165 |
+
onEnded?: () => void;
|
| 166 |
+
}): PlaybackStream;
|
| 167 |
+
stop(): void;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
export interface PersistenceService {
|
| 171 |
+
load(): PersistedSession | null;
|
| 172 |
+
save(value: PersistedSession): void;
|
| 173 |
+
clear(): void;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
export interface ConversationControllerDependencies {
|
| 177 |
+
capabilities: CapabilitiesService;
|
| 178 |
+
audioCapture: AudioCaptureService;
|
| 179 |
+
asr: ASRAdapter;
|
| 180 |
+
llm: LLMAdapter;
|
| 181 |
+
tts: TTSAdapter;
|
| 182 |
+
playback: PlaybackService;
|
| 183 |
+
persistence: PersistenceService;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
export const PERSISTENCE_VERSION = 1;
|
| 187 |
+
|
| 188 |
+
export const CALIBRATION_DURATION_SECONDS = 20;
|
| 189 |
+
export const CALIBRATION_DURATION_MS = CALIBRATION_DURATION_SECONDS * 1000;
|
| 190 |
+
export const CALIBRATION_INSTRUCTIONS =
|
| 191 |
+
"Read the following lines clearly at a comfortable speaking pace until calibration is complete. Repeat from the beginning if time remains.";
|
| 192 |
+
export const CALIBRATION_PROMPT =
|
| 193 |
+
"The quick brown fox jumps over the lazy dog.\nI am speaking in my natural voice at a comfortable volume.\nThe sound of my own voice has never bothered me.\nA recording is never quite the same as the original.\nI confirm this is a true representation of how I speak.\nMy speech remains clear when I maintain a consistent distance from the microphone.\nI will continue speaking until the calibration timer reaches zero.\nThis sample should capture my normal pronunciation and pacing.";
|
| 194 |
+
|
| 195 |
+
export const createEmptyCapability = (): AppCapability => ({
|
| 196 |
+
hasWebGPU: false,
|
| 197 |
+
hasMediaDevices: false,
|
| 198 |
+
hasAudioWorklet: false,
|
| 199 |
+
hasCrossOriginIsolation: false,
|
| 200 |
+
canRunDemo: false,
|
| 201 |
+
failureReason: "Checking browser capabilities.",
|
| 202 |
+
});
|
| 203 |
+
|
| 204 |
+
export const createEmptyVoiceProfile = (): VoiceProfileState => ({
|
| 205 |
+
source: "first-utterance",
|
| 206 |
+
ready: false,
|
| 207 |
+
referenceAudioKey: null,
|
| 208 |
+
embeddingCacheKey: null,
|
| 209 |
+
lastUpdatedAt: null,
|
| 210 |
+
});
|
| 211 |
+
|
| 212 |
+
export const createDefaultRuntimeConfig = (): RuntimeConfig => ({
|
| 213 |
+
llmModelId: "Qwen2.5-0.5B-Instruct-q4f16_1-MLC",
|
| 214 |
+
asrModelId: "Xenova/whisper-tiny.en",
|
| 215 |
+
ttsModelId: "/pocket-tts",
|
| 216 |
+
maxTurnsPersisted: 8,
|
| 217 |
+
targetFirstAudioMs: 2500,
|
| 218 |
+
});
|
| 219 |
+
|
| 220 |
+
export const createInitialState = (
|
| 221 |
+
config: RuntimeConfig = createDefaultRuntimeConfig(),
|
| 222 |
+
): AppState => ({
|
| 223 |
+
capability: createEmptyCapability(),
|
| 224 |
+
phase: "idle",
|
| 225 |
+
micPermission: "unknown",
|
| 226 |
+
consentAccepted: false,
|
| 227 |
+
firstContactComplete: false,
|
| 228 |
+
turns: [],
|
| 229 |
+
voiceProfile: createEmptyVoiceProfile(),
|
| 230 |
+
statusText: "Idle",
|
| 231 |
+
errorMessage: null,
|
| 232 |
+
runtimeReady: false,
|
| 233 |
+
supportsResume: false,
|
| 234 |
+
lastOpenedAt: null,
|
| 235 |
+
calibrationProgress: 0,
|
| 236 |
+
calibrationSecondsRemaining: null,
|
| 237 |
+
calibrationRecording: false,
|
| 238 |
+
benchmarkSummary: null,
|
| 239 |
+
config,
|
| 240 |
+
});
|
src/main.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "./styles.css";
|
| 2 |
+
|
| 3 |
+
import { ConversationController } from "./app/controller";
|
| 4 |
+
import { AppStore } from "./app/store";
|
| 5 |
+
import {
|
| 6 |
+
CALIBRATION_INSTRUCTIONS,
|
| 7 |
+
CALIBRATION_PROMPT,
|
| 8 |
+
createInitialState,
|
| 9 |
+
} from "./app/types";
|
| 10 |
+
import { TransformersAsrAdapter } from "./adapters/asr";
|
| 11 |
+
import { WebLLMChatAdapter } from "./adapters/llm";
|
| 12 |
+
import { PocketTTSAdapter } from "./adapters/tts";
|
| 13 |
+
import { BrowserAudioCaptureService } from "./services/audio-capture";
|
| 14 |
+
import { BrowserCapabilitiesService } from "./services/capabilities";
|
| 15 |
+
import { LocalPersistenceService } from "./services/persistence";
|
| 16 |
+
import { BrowserPlaybackService } from "./services/playback";
|
| 17 |
+
|
| 18 |
+
const store = new AppStore(createInitialState());
|
| 19 |
+
|
| 20 |
+
const controller = new ConversationController(store, {
|
| 21 |
+
capabilities: new BrowserCapabilitiesService(),
|
| 22 |
+
audioCapture: new BrowserAudioCaptureService(),
|
| 23 |
+
asr: new TransformersAsrAdapter(store.getState().config.asrModelId),
|
| 24 |
+
llm: new WebLLMChatAdapter(store.getState().config.llmModelId),
|
| 25 |
+
tts: new PocketTTSAdapter(store.getState().config.ttsModelId),
|
| 26 |
+
playback: new BrowserPlaybackService(),
|
| 27 |
+
persistence: new LocalPersistenceService(),
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
type ViewName = "calibration" | "assistant";
|
| 31 |
+
|
| 32 |
+
let currentView: ViewName = "calibration";
|
| 33 |
+
|
| 34 |
+
const calibrationView = document.querySelector<HTMLElement>("#calibration-view");
|
| 35 |
+
const assistantView = document.querySelector<HTMLElement>("#assistant-view");
|
| 36 |
+
const calibrationButton =
|
| 37 |
+
document.querySelector<HTMLButtonElement>("#calibration-button");
|
| 38 |
+
const calibrationCopy =
|
| 39 |
+
document.querySelector<HTMLParagraphElement>("#calibration-copy");
|
| 40 |
+
const calibrationPrompt =
|
| 41 |
+
document.querySelector<HTMLParagraphElement>("#calibration-prompt");
|
| 42 |
+
const calibrationFill =
|
| 43 |
+
document.querySelector<HTMLDivElement>("#calibration-fill");
|
| 44 |
+
const calibrationTimer =
|
| 45 |
+
document.querySelector<HTMLParagraphElement>("#calibration-timer");
|
| 46 |
+
const benchmarkSummary =
|
| 47 |
+
document.querySelector<HTMLParagraphElement>("#benchmark-summary");
|
| 48 |
+
const calibrationError =
|
| 49 |
+
document.querySelector<HTMLDivElement>("#calibration-error");
|
| 50 |
+
const transcriptLog = document.querySelector<HTMLDivElement>("#transcript-log");
|
| 51 |
+
const transcriptEmpty =
|
| 52 |
+
document.querySelector<HTMLDivElement>("#transcript-empty");
|
| 53 |
+
const recordButton = document.querySelector<HTMLButtonElement>("#record-button");
|
| 54 |
+
const assistantError =
|
| 55 |
+
document.querySelector<HTMLDivElement>("#assistant-error");
|
| 56 |
+
|
| 57 |
+
if (
|
| 58 |
+
!calibrationView ||
|
| 59 |
+
!assistantView ||
|
| 60 |
+
!calibrationButton ||
|
| 61 |
+
!calibrationCopy ||
|
| 62 |
+
!calibrationPrompt ||
|
| 63 |
+
!calibrationFill ||
|
| 64 |
+
!calibrationTimer ||
|
| 65 |
+
!benchmarkSummary ||
|
| 66 |
+
!calibrationError ||
|
| 67 |
+
!transcriptLog ||
|
| 68 |
+
!transcriptEmpty ||
|
| 69 |
+
!recordButton ||
|
| 70 |
+
!assistantError
|
| 71 |
+
) {
|
| 72 |
+
throw new Error("The demo shell is missing required DOM nodes.");
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const isAssistantActivePhase = (phase: ReturnType<typeof store.getState>["phase"]) =>
|
| 76 |
+
phase === "arming" ||
|
| 77 |
+
phase === "listening" ||
|
| 78 |
+
phase === "thinking" ||
|
| 79 |
+
phase === "speaking";
|
| 80 |
+
|
| 81 |
+
const render = () => {
|
| 82 |
+
const state = store.getState();
|
| 83 |
+
|
| 84 |
+
calibrationView.hidden = currentView !== "calibration";
|
| 85 |
+
assistantView.hidden = currentView !== "assistant";
|
| 86 |
+
|
| 87 |
+
calibrationCopy.textContent = state.runtimeReady
|
| 88 |
+
? "Calibration is complete. Continue when you're ready to open the assistant."
|
| 89 |
+
: state.phase === "benchmarking"
|
| 90 |
+
? "Calibration is running now. Keep reading the prompt clearly until the timer ends."
|
| 91 |
+
: CALIBRATION_INSTRUCTIONS;
|
| 92 |
+
calibrationPrompt.textContent = CALIBRATION_PROMPT;
|
| 93 |
+
calibrationFill.style.width = `${Math.round(state.calibrationProgress * 100)}%`;
|
| 94 |
+
calibrationTimer.textContent = state.phase === "benchmarking"
|
| 95 |
+
? state.calibrationRecording
|
| 96 |
+
? `${state.calibrationSecondsRemaining ?? 0}s remaining`
|
| 97 |
+
: "Finalizing calibration..."
|
| 98 |
+
: state.runtimeReady
|
| 99 |
+
? "Calibration complete."
|
| 100 |
+
: "Calibration required.";
|
| 101 |
+
|
| 102 |
+
benchmarkSummary.hidden = !state.benchmarkSummary;
|
| 103 |
+
benchmarkSummary.textContent = state.benchmarkSummary ?? "";
|
| 104 |
+
|
| 105 |
+
calibrationButton.disabled = state.phase === "benchmarking";
|
| 106 |
+
calibrationButton.textContent = state.phase === "benchmarking"
|
| 107 |
+
? "Calibrating..."
|
| 108 |
+
: state.runtimeReady
|
| 109 |
+
? "Continue"
|
| 110 |
+
: "Calibrate";
|
| 111 |
+
|
| 112 |
+
transcriptLog.replaceChildren();
|
| 113 |
+
if (state.turns.length === 0) {
|
| 114 |
+
transcriptEmpty.hidden = false;
|
| 115 |
+
transcriptEmpty.textContent = state.runtimeReady
|
| 116 |
+
? 'Press Record and say "Hello, my name is _____."'
|
| 117 |
+
: "Complete calibration first.";
|
| 118 |
+
transcriptLog.append(transcriptEmpty);
|
| 119 |
+
} else {
|
| 120 |
+
transcriptEmpty.hidden = true;
|
| 121 |
+
for (const turn of state.turns) {
|
| 122 |
+
const element = document.createElement("article");
|
| 123 |
+
element.className = "turn";
|
| 124 |
+
element.dataset.role = turn.role;
|
| 125 |
+
|
| 126 |
+
const header = document.createElement("div");
|
| 127 |
+
header.className = "turn-header";
|
| 128 |
+
header.innerHTML = `<span>${turn.role}</span><span>${turn.audioStatus}</span>`;
|
| 129 |
+
|
| 130 |
+
const body = document.createElement("div");
|
| 131 |
+
body.className = "turn-body";
|
| 132 |
+
body.textContent = turn.transcript || "…";
|
| 133 |
+
|
| 134 |
+
element.append(header, body);
|
| 135 |
+
transcriptLog.append(element);
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
recordButton.disabled = !state.runtimeReady || state.phase === "benchmarking";
|
| 140 |
+
recordButton.textContent =
|
| 141 |
+
state.phase === "listening" || state.phase === "arming"
|
| 142 |
+
? "Stop Recording"
|
| 143 |
+
: state.phase === "thinking" || state.phase === "speaking"
|
| 144 |
+
? "Stop"
|
| 145 |
+
: "Record";
|
| 146 |
+
|
| 147 |
+
calibrationError.hidden =
|
| 148 |
+
currentView !== "calibration" || !state.errorMessage;
|
| 149 |
+
calibrationError.textContent =
|
| 150 |
+
currentView === "calibration" ? state.errorMessage ?? "" : "";
|
| 151 |
+
|
| 152 |
+
assistantError.hidden = currentView !== "assistant" || !state.errorMessage;
|
| 153 |
+
assistantError.textContent =
|
| 154 |
+
currentView === "assistant" ? state.errorMessage ?? "" : "";
|
| 155 |
+
};
|
| 156 |
+
|
| 157 |
+
calibrationButton.addEventListener("click", () => {
|
| 158 |
+
controller.setConsentAccepted(true);
|
| 159 |
+
if (store.getState().runtimeReady) {
|
| 160 |
+
currentView = "assistant";
|
| 161 |
+
render();
|
| 162 |
+
return;
|
| 163 |
+
}
|
| 164 |
+
void controller.runBenchmark();
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
recordButton.addEventListener("click", () => {
|
| 168 |
+
controller.setConsentAccepted(true);
|
| 169 |
+
void controller.start();
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
window.addEventListener("beforeunload", () => {
|
| 173 |
+
void controller.dispose();
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
store.subscribe(() => {
|
| 177 |
+
render();
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
void controller.bootstrap().then(() => {
|
| 181 |
+
render();
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
if (import.meta.hot) {
|
| 185 |
+
import.meta.hot.accept();
|
| 186 |
+
import.meta.hot.dispose(() => {
|
| 187 |
+
void controller.dispose();
|
| 188 |
+
});
|
| 189 |
+
}
|
src/prompts/system.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const SYSTEM_PROMPT = `You are a deadpan browser voice agent demo.
|
| 2 |
+
|
| 3 |
+
Respond directly, briefly, and clearly. Sound like a capable local assistant, not a character.
|
| 4 |
+
Do not mention that you are part of a game, experiment, fiction, or narrative.
|
| 5 |
+
Do not call attention to the user's cloned voice.
|
| 6 |
+
Do not add theatrical flourishes or emotional commentary unless the user explicitly asks for it.
|
| 7 |
+
`;
|
src/services/audio-capture.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
AudioCaptureService,
|
| 3 |
+
AudioDurationCaptureProgress,
|
| 4 |
+
MicPermissionState,
|
| 5 |
+
} from "../app/types";
|
| 6 |
+
|
| 7 |
+
const DEFAULT_THRESHOLD = 0.024;
|
| 8 |
+
const DEFAULT_SILENCE_MS = 900;
|
| 9 |
+
const DEFAULT_MAX_WAIT_MS = 12000;
|
| 10 |
+
|
| 11 |
+
export class BrowserAudioCaptureService implements AudioCaptureService {
|
| 12 |
+
#stream: MediaStream | null = null;
|
| 13 |
+
#audioContext: AudioContext | null = null;
|
| 14 |
+
#analyser: AnalyserNode | null = null;
|
| 15 |
+
#source: MediaStreamAudioSourceNode | null = null;
|
| 16 |
+
#recorder: MediaRecorder | null = null;
|
| 17 |
+
|
| 18 |
+
async ensureMicrophonePermission(): Promise<MicPermissionState> {
|
| 19 |
+
try {
|
| 20 |
+
await this.#ensureStream();
|
| 21 |
+
return "granted";
|
| 22 |
+
} catch {
|
| 23 |
+
return "denied";
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
async listenForUtterance(options: { signal?: AbortSignal } = {}): Promise<Blob> {
|
| 28 |
+
const stream = await this.#ensureStream();
|
| 29 |
+
|
| 30 |
+
if (typeof MediaRecorder === "undefined") {
|
| 31 |
+
throw new Error("MediaRecorder is unavailable in this browser.");
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
const mimeType = BrowserAudioCaptureService.#pickMimeType();
|
| 35 |
+
const recorder = mimeType
|
| 36 |
+
? new MediaRecorder(stream, { mimeType })
|
| 37 |
+
: new MediaRecorder(stream);
|
| 38 |
+
this.#recorder = recorder;
|
| 39 |
+
|
| 40 |
+
const chunks: BlobPart[] = [];
|
| 41 |
+
let detectedSpeech = false;
|
| 42 |
+
let lastSpeechAt = 0;
|
| 43 |
+
let startedAt = 0;
|
| 44 |
+
let loopHandle = 0;
|
| 45 |
+
|
| 46 |
+
recorder.ondataavailable = (event) => {
|
| 47 |
+
if (event.data.size > 0) {
|
| 48 |
+
chunks.push(event.data);
|
| 49 |
+
}
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const stop = () => {
|
| 53 |
+
cancelAnimationFrame(loopHandle);
|
| 54 |
+
if (recorder.state !== "inactive") {
|
| 55 |
+
recorder.stop();
|
| 56 |
+
}
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
options.signal?.addEventListener(
|
| 60 |
+
"abort",
|
| 61 |
+
() => {
|
| 62 |
+
stop();
|
| 63 |
+
},
|
| 64 |
+
{ once: true },
|
| 65 |
+
);
|
| 66 |
+
|
| 67 |
+
const data = new Float32Array(this.#analyser?.fftSize ?? 2048);
|
| 68 |
+
|
| 69 |
+
const monitor = () => {
|
| 70 |
+
if (!this.#analyser) {
|
| 71 |
+
loopHandle = requestAnimationFrame(monitor);
|
| 72 |
+
return;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
this.#analyser.getFloatTimeDomainData(data);
|
| 76 |
+
const rms = BrowserAudioCaptureService.#rms(data);
|
| 77 |
+
const now = performance.now();
|
| 78 |
+
|
| 79 |
+
if (rms > DEFAULT_THRESHOLD) {
|
| 80 |
+
detectedSpeech = true;
|
| 81 |
+
lastSpeechAt = now;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
if (
|
| 85 |
+
detectedSpeech &&
|
| 86 |
+
lastSpeechAt > 0 &&
|
| 87 |
+
now - lastSpeechAt >= DEFAULT_SILENCE_MS
|
| 88 |
+
) {
|
| 89 |
+
stop();
|
| 90 |
+
return;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
if (!detectedSpeech && now - startedAt >= DEFAULT_MAX_WAIT_MS) {
|
| 94 |
+
stop();
|
| 95 |
+
return;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
loopHandle = requestAnimationFrame(monitor);
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
const result = await new Promise<Blob>((resolve, reject) => {
|
| 102 |
+
recorder.onstop = () => {
|
| 103 |
+
cancelAnimationFrame(loopHandle);
|
| 104 |
+
|
| 105 |
+
if (chunks.length === 0) {
|
| 106 |
+
reject(new Error("No audio was captured."));
|
| 107 |
+
return;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
resolve(new Blob(chunks, { type: recorder.mimeType || "audio/webm" }));
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
recorder.onerror = () => {
|
| 114 |
+
cancelAnimationFrame(loopHandle);
|
| 115 |
+
reject(new Error("Audio capture failed."));
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
startedAt = performance.now();
|
| 119 |
+
recorder.start(250);
|
| 120 |
+
loopHandle = requestAnimationFrame(monitor);
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
this.#recorder = null;
|
| 124 |
+
return result;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
async recordForDuration(options: {
|
| 128 |
+
durationMs: number;
|
| 129 |
+
signal?: AbortSignal;
|
| 130 |
+
onProgress?: (progress: AudioDurationCaptureProgress) => void;
|
| 131 |
+
}): Promise<Blob> {
|
| 132 |
+
const stream = await this.#ensureStream();
|
| 133 |
+
|
| 134 |
+
if (typeof MediaRecorder === "undefined") {
|
| 135 |
+
throw new Error("MediaRecorder is unavailable in this browser.");
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
const mimeType = BrowserAudioCaptureService.#pickMimeType();
|
| 139 |
+
const recorder = mimeType
|
| 140 |
+
? new MediaRecorder(stream, { mimeType })
|
| 141 |
+
: new MediaRecorder(stream);
|
| 142 |
+
this.#recorder = recorder;
|
| 143 |
+
|
| 144 |
+
const chunks: BlobPart[] = [];
|
| 145 |
+
const durationMs = Math.max(1_000, options.durationMs);
|
| 146 |
+
let timerHandle = 0;
|
| 147 |
+
let progressHandle = 0;
|
| 148 |
+
let startedAt = 0;
|
| 149 |
+
let aborted = false;
|
| 150 |
+
|
| 151 |
+
recorder.ondataavailable = (event) => {
|
| 152 |
+
if (event.data.size > 0) {
|
| 153 |
+
chunks.push(event.data);
|
| 154 |
+
}
|
| 155 |
+
};
|
| 156 |
+
|
| 157 |
+
const emitProgress = (elapsedMs: number, isRecording: boolean) => {
|
| 158 |
+
options.onProgress?.({
|
| 159 |
+
progress: Math.max(0, Math.min(1, elapsedMs / durationMs)),
|
| 160 |
+
elapsedMs,
|
| 161 |
+
remainingMs: Math.max(0, durationMs - elapsedMs),
|
| 162 |
+
isRecording,
|
| 163 |
+
});
|
| 164 |
+
};
|
| 165 |
+
|
| 166 |
+
const stop = () => {
|
| 167 |
+
window.clearTimeout(timerHandle);
|
| 168 |
+
window.clearInterval(progressHandle);
|
| 169 |
+
if (recorder.state !== "inactive") {
|
| 170 |
+
recorder.stop();
|
| 171 |
+
}
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
options.signal?.addEventListener(
|
| 175 |
+
"abort",
|
| 176 |
+
() => {
|
| 177 |
+
aborted = true;
|
| 178 |
+
stop();
|
| 179 |
+
},
|
| 180 |
+
{ once: true },
|
| 181 |
+
);
|
| 182 |
+
|
| 183 |
+
try {
|
| 184 |
+
return await new Promise<Blob>((resolve, reject) => {
|
| 185 |
+
recorder.onstop = () => {
|
| 186 |
+
window.clearTimeout(timerHandle);
|
| 187 |
+
window.clearInterval(progressHandle);
|
| 188 |
+
|
| 189 |
+
if (aborted) {
|
| 190 |
+
reject(new Error("Microphone calibration was cancelled."));
|
| 191 |
+
return;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
if (chunks.length === 0) {
|
| 195 |
+
reject(new Error("No microphone calibration audio was captured."));
|
| 196 |
+
return;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
emitProgress(durationMs, false);
|
| 200 |
+
resolve(new Blob(chunks, { type: recorder.mimeType || "audio/webm" }));
|
| 201 |
+
};
|
| 202 |
+
|
| 203 |
+
recorder.onerror = () => {
|
| 204 |
+
window.clearTimeout(timerHandle);
|
| 205 |
+
window.clearInterval(progressHandle);
|
| 206 |
+
reject(new Error("Microphone calibration recording failed."));
|
| 207 |
+
};
|
| 208 |
+
|
| 209 |
+
startedAt = performance.now();
|
| 210 |
+
emitProgress(0, true);
|
| 211 |
+
recorder.start(250);
|
| 212 |
+
timerHandle = window.setTimeout(() => {
|
| 213 |
+
stop();
|
| 214 |
+
}, durationMs);
|
| 215 |
+
progressHandle = window.setInterval(() => {
|
| 216 |
+
const elapsedMs = Math.min(durationMs, performance.now() - startedAt);
|
| 217 |
+
emitProgress(elapsedMs, true);
|
| 218 |
+
}, 100);
|
| 219 |
+
});
|
| 220 |
+
} finally {
|
| 221 |
+
this.#recorder = null;
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
stop(): void {
|
| 226 |
+
if (this.#recorder && this.#recorder.state !== "inactive") {
|
| 227 |
+
this.#recorder.stop();
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
async dispose(): Promise<void> {
|
| 232 |
+
this.stop();
|
| 233 |
+
this.#stream?.getTracks().forEach((track) => track.stop());
|
| 234 |
+
this.#stream = null;
|
| 235 |
+
this.#source?.disconnect();
|
| 236 |
+
this.#source = null;
|
| 237 |
+
this.#analyser?.disconnect();
|
| 238 |
+
this.#analyser = null;
|
| 239 |
+
await this.#audioContext?.close();
|
| 240 |
+
this.#audioContext = null;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
async #ensureStream(): Promise<MediaStream> {
|
| 244 |
+
if (this.#stream) {
|
| 245 |
+
return this.#stream;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
const stream = await navigator.mediaDevices.getUserMedia({
|
| 249 |
+
audio: {
|
| 250 |
+
channelCount: 1,
|
| 251 |
+
echoCancellation: true,
|
| 252 |
+
noiseSuppression: true,
|
| 253 |
+
autoGainControl: true,
|
| 254 |
+
},
|
| 255 |
+
});
|
| 256 |
+
|
| 257 |
+
this.#stream = stream;
|
| 258 |
+
|
| 259 |
+
this.#audioContext = new AudioContext();
|
| 260 |
+
this.#source = this.#audioContext.createMediaStreamSource(stream);
|
| 261 |
+
this.#analyser = this.#audioContext.createAnalyser();
|
| 262 |
+
this.#analyser.fftSize = 2048;
|
| 263 |
+
this.#source.connect(this.#analyser);
|
| 264 |
+
|
| 265 |
+
return stream;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
static #pickMimeType(): string | undefined {
|
| 269 |
+
const candidates = [
|
| 270 |
+
"audio/webm;codecs=opus",
|
| 271 |
+
"audio/mp4",
|
| 272 |
+
"audio/webm",
|
| 273 |
+
];
|
| 274 |
+
|
| 275 |
+
for (const candidate of candidates) {
|
| 276 |
+
if (MediaRecorder.isTypeSupported(candidate)) {
|
| 277 |
+
return candidate;
|
| 278 |
+
}
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
return undefined;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
static #rms(values: Float32Array): number {
|
| 285 |
+
let total = 0;
|
| 286 |
+
for (const value of values) {
|
| 287 |
+
total += value * value;
|
| 288 |
+
}
|
| 289 |
+
return Math.sqrt(total / values.length);
|
| 290 |
+
}
|
| 291 |
+
}
|
src/services/capabilities.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
AppCapability,
|
| 3 |
+
CapabilitiesService,
|
| 4 |
+
CapabilitySnapshot,
|
| 5 |
+
} from "../app/types";
|
| 6 |
+
|
| 7 |
+
export const evaluateCapabilitySnapshot = (
|
| 8 |
+
snapshot: CapabilitySnapshot,
|
| 9 |
+
): AppCapability => {
|
| 10 |
+
if (!snapshot.hasMediaDevices) {
|
| 11 |
+
return {
|
| 12 |
+
...snapshot,
|
| 13 |
+
canRunDemo: false,
|
| 14 |
+
failureReason: "This browser does not expose microphone capture APIs.",
|
| 15 |
+
};
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
if (!snapshot.hasWebGPU) {
|
| 19 |
+
return {
|
| 20 |
+
...snapshot,
|
| 21 |
+
canRunDemo: false,
|
| 22 |
+
failureReason: "WebGPU is unavailable, so local model execution cannot start.",
|
| 23 |
+
};
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
if (!snapshot.hasAudioWorklet) {
|
| 27 |
+
return {
|
| 28 |
+
...snapshot,
|
| 29 |
+
canRunDemo: false,
|
| 30 |
+
failureReason: "AudioWorklet support is unavailable, so live capture is unreliable.",
|
| 31 |
+
};
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
return {
|
| 35 |
+
...snapshot,
|
| 36 |
+
canRunDemo: true,
|
| 37 |
+
failureReason: null,
|
| 38 |
+
};
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
export class BrowserCapabilitiesService implements CapabilitiesService {
|
| 42 |
+
async detect(): Promise<AppCapability> {
|
| 43 |
+
const snapshot: CapabilitySnapshot = {
|
| 44 |
+
hasWebGPU: typeof navigator !== "undefined" && "gpu" in navigator,
|
| 45 |
+
hasMediaDevices:
|
| 46 |
+
typeof navigator !== "undefined" &&
|
| 47 |
+
typeof navigator.mediaDevices?.getUserMedia === "function",
|
| 48 |
+
hasAudioWorklet:
|
| 49 |
+
typeof window !== "undefined" &&
|
| 50 |
+
typeof window.AudioWorkletNode !== "undefined",
|
| 51 |
+
hasCrossOriginIsolation:
|
| 52 |
+
typeof globalThis !== "undefined" &&
|
| 53 |
+
globalThis.crossOriginIsolated === true,
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
return evaluateCapabilitySnapshot(snapshot);
|
| 57 |
+
}
|
| 58 |
+
}
|
src/services/persistence.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { PersistedSession, PersistenceService } from "../app/types";
|
| 2 |
+
import { PERSISTENCE_VERSION } from "../app/types";
|
| 3 |
+
|
| 4 |
+
const STORAGE_KEY = "private-voice-agent/session";
|
| 5 |
+
|
| 6 |
+
interface StorageLike {
|
| 7 |
+
getItem(key: string): string | null;
|
| 8 |
+
setItem(key: string, value: string): void;
|
| 9 |
+
removeItem(key: string): void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export class LocalPersistenceService implements PersistenceService {
|
| 13 |
+
#storage: StorageLike | null;
|
| 14 |
+
|
| 15 |
+
constructor(storage: StorageLike | null = typeof localStorage !== "undefined" ? localStorage : null) {
|
| 16 |
+
this.#storage = storage;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
load(): PersistedSession | null {
|
| 20 |
+
if (!this.#storage) {
|
| 21 |
+
return null;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const raw = this.#storage.getItem(STORAGE_KEY);
|
| 25 |
+
if (!raw) {
|
| 26 |
+
return null;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
try {
|
| 30 |
+
const parsed = JSON.parse(raw) as PersistedSession;
|
| 31 |
+
if (parsed.version !== PERSISTENCE_VERSION) {
|
| 32 |
+
this.clear();
|
| 33 |
+
return null;
|
| 34 |
+
}
|
| 35 |
+
return parsed;
|
| 36 |
+
} catch {
|
| 37 |
+
this.clear();
|
| 38 |
+
return null;
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
save(value: PersistedSession): void {
|
| 43 |
+
if (!this.#storage) {
|
| 44 |
+
return;
|
| 45 |
+
}
|
| 46 |
+
this.#storage.setItem(STORAGE_KEY, JSON.stringify(value));
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
clear(): void {
|
| 50 |
+
this.#storage?.removeItem(STORAGE_KEY);
|
| 51 |
+
}
|
| 52 |
+
}
|
src/services/playback.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { PlaybackService, PlaybackStream } from "../app/types";
|
| 2 |
+
|
| 3 |
+
const PCM_SAMPLE_RATE = 24_000;
|
| 4 |
+
const INITIAL_BUFFER_SECONDS = 0.45;
|
| 5 |
+
|
| 6 |
+
const createDeferred = <T>() => {
|
| 7 |
+
let resolve!: (value: T | PromiseLike<T>) => void;
|
| 8 |
+
let reject!: (reason?: unknown) => void;
|
| 9 |
+
|
| 10 |
+
const promise = new Promise<T>((innerResolve, innerReject) => {
|
| 11 |
+
resolve = innerResolve;
|
| 12 |
+
reject = innerReject;
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
return { promise, resolve, reject };
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
class WebAudioPlaybackStream implements PlaybackStream {
|
| 19 |
+
#context: AudioContext | null = null;
|
| 20 |
+
#nextStartTime = 0;
|
| 21 |
+
#stopped = false;
|
| 22 |
+
#finishRequested = false;
|
| 23 |
+
#started = false;
|
| 24 |
+
#sources = new Set<AudioBufferSourceNode>();
|
| 25 |
+
#pendingChunks: Float32Array[] = [];
|
| 26 |
+
#pendingDurationSeconds = 0;
|
| 27 |
+
#completion = createDeferred<void>();
|
| 28 |
+
#onEnded: (() => void) | null;
|
| 29 |
+
|
| 30 |
+
constructor(
|
| 31 |
+
options: {
|
| 32 |
+
signal?: AbortSignal;
|
| 33 |
+
onEnded?: () => void;
|
| 34 |
+
} = {},
|
| 35 |
+
) {
|
| 36 |
+
this.#onEnded = options.onEnded ?? null;
|
| 37 |
+
|
| 38 |
+
options.signal?.addEventListener(
|
| 39 |
+
"abort",
|
| 40 |
+
() => {
|
| 41 |
+
this.stop();
|
| 42 |
+
},
|
| 43 |
+
{ once: true },
|
| 44 |
+
);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
async enqueue(chunk: Float32Array): Promise<void> {
|
| 48 |
+
if (this.#stopped || this.#finishRequested || chunk.length === 0) {
|
| 49 |
+
return;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
this.#pendingChunks.push(chunk);
|
| 53 |
+
this.#pendingDurationSeconds += chunk.length / PCM_SAMPLE_RATE;
|
| 54 |
+
this.#flushPending();
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
async finish(): Promise<void> {
|
| 58 |
+
if (this.#stopped || this.#finishRequested) {
|
| 59 |
+
return this.#completion.promise;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
this.#finishRequested = true;
|
| 63 |
+
this.#flushPending(true);
|
| 64 |
+
this.#finalizeIfComplete();
|
| 65 |
+
return this.#completion.promise;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
stop(): void {
|
| 69 |
+
if (this.#stopped) {
|
| 70 |
+
return;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
this.#stopped = true;
|
| 74 |
+
this.#pendingChunks = [];
|
| 75 |
+
this.#pendingDurationSeconds = 0;
|
| 76 |
+
for (const source of this.#sources) {
|
| 77 |
+
try {
|
| 78 |
+
source.stop();
|
| 79 |
+
} catch {
|
| 80 |
+
// Ignore stop races for already-finished sources.
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
this.#sources.clear();
|
| 84 |
+
this.#finalizeIfComplete();
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
#flushPending(force = false): void {
|
| 88 |
+
if (this.#pendingChunks.length === 0 || this.#stopped) {
|
| 89 |
+
return;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
if (!this.#started && !force && this.#pendingDurationSeconds < INITIAL_BUFFER_SECONDS) {
|
| 93 |
+
return;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
this.#started = true;
|
| 97 |
+
while (this.#pendingChunks.length > 0) {
|
| 98 |
+
const chunk = this.#pendingChunks.shift();
|
| 99 |
+
if (!chunk) {
|
| 100 |
+
continue;
|
| 101 |
+
}
|
| 102 |
+
this.#pendingDurationSeconds = Math.max(
|
| 103 |
+
0,
|
| 104 |
+
this.#pendingDurationSeconds - chunk.length / PCM_SAMPLE_RATE,
|
| 105 |
+
);
|
| 106 |
+
this.#scheduleChunk(chunk);
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
#scheduleChunk(chunk: Float32Array): void {
|
| 111 |
+
const context = this.#getContext();
|
| 112 |
+
if (context.state === "suspended") {
|
| 113 |
+
void context.resume().catch(() => {});
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
const buffer = context.createBuffer(1, chunk.length, PCM_SAMPLE_RATE);
|
| 117 |
+
buffer.getChannelData(0).set(chunk);
|
| 118 |
+
|
| 119 |
+
const source = context.createBufferSource();
|
| 120 |
+
source.buffer = buffer;
|
| 121 |
+
source.connect(context.destination);
|
| 122 |
+
|
| 123 |
+
const startTime = Math.max(this.#nextStartTime, context.currentTime + 0.02);
|
| 124 |
+
this.#nextStartTime = startTime + buffer.duration;
|
| 125 |
+
|
| 126 |
+
source.onended = () => {
|
| 127 |
+
this.#sources.delete(source);
|
| 128 |
+
this.#finalizeIfComplete();
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
this.#sources.add(source);
|
| 132 |
+
source.start(startTime);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
#finalizeIfComplete(): void {
|
| 136 |
+
const canFinalize =
|
| 137 |
+
this.#stopped ||
|
| 138 |
+
(this.#finishRequested &&
|
| 139 |
+
this.#pendingChunks.length === 0 &&
|
| 140 |
+
this.#sources.size === 0);
|
| 141 |
+
|
| 142 |
+
if (!canFinalize) {
|
| 143 |
+
return;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
this.#onEnded?.();
|
| 147 |
+
this.#onEnded = null;
|
| 148 |
+
const context = this.#context;
|
| 149 |
+
this.#context = null;
|
| 150 |
+
if (context && context.state !== "closed") {
|
| 151 |
+
void context.close().catch(() => {});
|
| 152 |
+
}
|
| 153 |
+
this.#completion.resolve();
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
#getContext(): AudioContext {
|
| 157 |
+
if (this.#context) {
|
| 158 |
+
return this.#context;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
this.#context = new AudioContext({ sampleRate: PCM_SAMPLE_RATE });
|
| 162 |
+
return this.#context;
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
export class BrowserPlaybackService implements PlaybackService {
|
| 167 |
+
#audio: HTMLAudioElement | null = null;
|
| 168 |
+
#url: string | null = null;
|
| 169 |
+
#resolveCurrent: (() => void) | null = null;
|
| 170 |
+
#cleanupCurrent: (() => void) | null = null;
|
| 171 |
+
#stream: WebAudioPlaybackStream | null = null;
|
| 172 |
+
|
| 173 |
+
async play(
|
| 174 |
+
audio: Blob,
|
| 175 |
+
options: { signal?: AbortSignal; onEnded?: () => void } = {},
|
| 176 |
+
): Promise<void> {
|
| 177 |
+
this.stop();
|
| 178 |
+
|
| 179 |
+
const url = URL.createObjectURL(audio);
|
| 180 |
+
const element = new Audio(url);
|
| 181 |
+
this.#audio = element;
|
| 182 |
+
this.#url = url;
|
| 183 |
+
|
| 184 |
+
const cleanup = () => {
|
| 185 |
+
if (this.#audio === element) {
|
| 186 |
+
this.#audio = null;
|
| 187 |
+
}
|
| 188 |
+
if (this.#url === url) {
|
| 189 |
+
URL.revokeObjectURL(url);
|
| 190 |
+
this.#url = null;
|
| 191 |
+
}
|
| 192 |
+
if (this.#cleanupCurrent === cleanup) {
|
| 193 |
+
this.#cleanupCurrent = null;
|
| 194 |
+
}
|
| 195 |
+
if (this.#resolveCurrent) {
|
| 196 |
+
this.#resolveCurrent = null;
|
| 197 |
+
}
|
| 198 |
+
};
|
| 199 |
+
this.#cleanupCurrent = cleanup;
|
| 200 |
+
|
| 201 |
+
if (options.signal) {
|
| 202 |
+
options.signal.addEventListener(
|
| 203 |
+
"abort",
|
| 204 |
+
() => {
|
| 205 |
+
this.stop();
|
| 206 |
+
},
|
| 207 |
+
{ once: true },
|
| 208 |
+
);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
await new Promise<void>((resolve, reject) => {
|
| 212 |
+
this.#resolveCurrent = () => {
|
| 213 |
+
options.onEnded?.();
|
| 214 |
+
cleanup();
|
| 215 |
+
resolve();
|
| 216 |
+
};
|
| 217 |
+
element.onended = () => {
|
| 218 |
+
this.#resolveCurrent?.();
|
| 219 |
+
};
|
| 220 |
+
element.onerror = () => {
|
| 221 |
+
cleanup();
|
| 222 |
+
reject(new Error("Audio playback failed."));
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
void element.play().catch((error) => {
|
| 226 |
+
cleanup();
|
| 227 |
+
reject(error);
|
| 228 |
+
});
|
| 229 |
+
});
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
createStream(
|
| 233 |
+
options: { signal?: AbortSignal; onEnded?: () => void } = {},
|
| 234 |
+
): PlaybackStream {
|
| 235 |
+
this.stop();
|
| 236 |
+
this.#stream = new WebAudioPlaybackStream(options);
|
| 237 |
+
return this.#stream;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
stop(): void {
|
| 241 |
+
this.#stream?.stop();
|
| 242 |
+
this.#stream = null;
|
| 243 |
+
|
| 244 |
+
if (this.#audio) {
|
| 245 |
+
this.#audio.pause();
|
| 246 |
+
this.#audio.currentTime = 0;
|
| 247 |
+
this.#audio = null;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
const cleanup = this.#cleanupCurrent;
|
| 251 |
+
const resolve = this.#resolveCurrent;
|
| 252 |
+
this.#cleanupCurrent = null;
|
| 253 |
+
this.#resolveCurrent = null;
|
| 254 |
+
cleanup?.();
|
| 255 |
+
resolve?.();
|
| 256 |
+
}
|
| 257 |
+
}
|
src/services/pocket-runtime.ts
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const SAMPLE_RATE = 24_000;
|
| 2 |
+
const MAX_REFERENCE_SAMPLES = SAMPLE_RATE * 10;
|
| 3 |
+
const DEFAULT_MODEL_BASE_URL = "/pocket-tts";
|
| 4 |
+
const WORKER_MODULE_URL = new URL("../workers/pocket-bootstrap.ts", import.meta.url);
|
| 5 |
+
|
| 6 |
+
type Deferred<T> = {
|
| 7 |
+
promise: Promise<T>;
|
| 8 |
+
resolve: (value: T | PromiseLike<T>) => void;
|
| 9 |
+
reject: (reason?: unknown) => void;
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
type WorkerMessage =
|
| 13 |
+
| { type: "loaded" }
|
| 14 |
+
| { type: "voices_loaded"; defaultVoice?: string | null }
|
| 15 |
+
| { type: "voice_encoded"; voiceName?: string | null }
|
| 16 |
+
| { type: "voice_set"; voiceName?: string | null }
|
| 17 |
+
| { type: "audio_chunk"; data?: ArrayLike<number> }
|
| 18 |
+
| { type: "stream_ended" }
|
| 19 |
+
| { type: "error"; error?: string }
|
| 20 |
+
| { type: "status"; status?: string; state?: string };
|
| 21 |
+
|
| 22 |
+
type PendingGeneration = {
|
| 23 |
+
mode: "blob" | "stream";
|
| 24 |
+
chunks: Float32Array[];
|
| 25 |
+
chunkTask: Promise<void>;
|
| 26 |
+
onAudioChunk?: (chunk: Float32Array) => void | Promise<void>;
|
| 27 |
+
resolve: (value?: Blob) => void;
|
| 28 |
+
reject: (error: Error) => void;
|
| 29 |
+
abortCleanup?: () => void;
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
const createDeferred = <T>(): Deferred<T> => {
|
| 33 |
+
let resolve!: Deferred<T>["resolve"];
|
| 34 |
+
let reject!: Deferred<T>["reject"];
|
| 35 |
+
|
| 36 |
+
const promise = new Promise<T>((innerResolve, innerReject) => {
|
| 37 |
+
resolve = innerResolve;
|
| 38 |
+
reject = innerReject;
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
return { promise, resolve, reject };
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const resolveModelBaseUrl = (modelId?: string): string => {
|
| 45 |
+
if (!modelId) {
|
| 46 |
+
return DEFAULT_MODEL_BASE_URL;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
if (modelId.startsWith("https://") || modelId.startsWith("http://")) {
|
| 50 |
+
return modelId.replace(/\/+$/, "");
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
if (
|
| 54 |
+
modelId.startsWith("/") ||
|
| 55 |
+
modelId.startsWith("./") ||
|
| 56 |
+
modelId.startsWith("../")
|
| 57 |
+
) {
|
| 58 |
+
return modelId.replace(/\/+$/, "");
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
return `https://huggingface.co/spaces/${modelId.replace(/^spaces\//, "")}/resolve/main`;
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
const isWindowsPlatform = (): boolean => {
|
| 65 |
+
if (typeof navigator === "undefined") {
|
| 66 |
+
return false;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const platform =
|
| 70 |
+
navigator.userAgentData?.platform ??
|
| 71 |
+
navigator.platform ??
|
| 72 |
+
navigator.userAgent ??
|
| 73 |
+
"";
|
| 74 |
+
|
| 75 |
+
return /win/i.test(platform);
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
const toMono = (audioBuffer: AudioBuffer): Float32Array => {
|
| 79 |
+
if (audioBuffer.numberOfChannels === 1) {
|
| 80 |
+
return audioBuffer.getChannelData(0).slice();
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
const length = audioBuffer.length;
|
| 84 |
+
const mono = new Float32Array(length);
|
| 85 |
+
|
| 86 |
+
for (let channel = 0; channel < audioBuffer.numberOfChannels; channel += 1) {
|
| 87 |
+
const input = audioBuffer.getChannelData(channel);
|
| 88 |
+
for (let index = 0; index < length; index += 1) {
|
| 89 |
+
mono[index] += input[index] / audioBuffer.numberOfChannels;
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
return mono;
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
const resample = (
|
| 97 |
+
input: Float32Array,
|
| 98 |
+
fromRate: number,
|
| 99 |
+
toRate: number,
|
| 100 |
+
): Float32Array => {
|
| 101 |
+
if (fromRate === toRate) {
|
| 102 |
+
return input;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
const ratio = fromRate / toRate;
|
| 106 |
+
const outputLength = Math.max(1, Math.round(input.length / ratio));
|
| 107 |
+
const output = new Float32Array(outputLength);
|
| 108 |
+
|
| 109 |
+
for (let index = 0; index < outputLength; index += 1) {
|
| 110 |
+
const sourceIndex = index * ratio;
|
| 111 |
+
const lower = Math.floor(sourceIndex);
|
| 112 |
+
const upper = Math.min(lower + 1, input.length - 1);
|
| 113 |
+
const weight = sourceIndex - lower;
|
| 114 |
+
output[index] = input[lower] * (1 - weight) + input[upper] * weight;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
return output;
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
const encodeWav = (chunks: Float32Array[]): Blob => {
|
| 121 |
+
const totalSamples = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
| 122 |
+
const pcm = new Float32Array(totalSamples);
|
| 123 |
+
let offset = 0;
|
| 124 |
+
|
| 125 |
+
for (const chunk of chunks) {
|
| 126 |
+
pcm.set(chunk, offset);
|
| 127 |
+
offset += chunk.length;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
const buffer = new ArrayBuffer(44 + pcm.length * 2);
|
| 131 |
+
const view = new DataView(buffer);
|
| 132 |
+
const writeString = (start: number, value: string) => {
|
| 133 |
+
for (let index = 0; index < value.length; index += 1) {
|
| 134 |
+
view.setUint8(start + index, value.charCodeAt(index));
|
| 135 |
+
}
|
| 136 |
+
};
|
| 137 |
+
|
| 138 |
+
writeString(0, "RIFF");
|
| 139 |
+
view.setUint32(4, 36 + pcm.length * 2, true);
|
| 140 |
+
writeString(8, "WAVE");
|
| 141 |
+
writeString(12, "fmt ");
|
| 142 |
+
view.setUint32(16, 16, true);
|
| 143 |
+
view.setUint16(20, 1, true);
|
| 144 |
+
view.setUint16(22, 1, true);
|
| 145 |
+
view.setUint32(24, SAMPLE_RATE, true);
|
| 146 |
+
view.setUint32(28, SAMPLE_RATE * 2, true);
|
| 147 |
+
view.setUint16(32, 2, true);
|
| 148 |
+
view.setUint16(34, 16, true);
|
| 149 |
+
writeString(36, "data");
|
| 150 |
+
view.setUint32(40, pcm.length * 2, true);
|
| 151 |
+
|
| 152 |
+
for (let index = 0; index < pcm.length; index += 1) {
|
| 153 |
+
const sample = Math.max(-1, Math.min(1, pcm[index] ?? 0));
|
| 154 |
+
view.setInt16(
|
| 155 |
+
44 + index * 2,
|
| 156 |
+
sample < 0 ? sample * 0x8000 : sample * 0x7fff,
|
| 157 |
+
true,
|
| 158 |
+
);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
return new Blob([buffer], { type: "audio/wav" });
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
export class BrowserPocketTTSRuntime {
|
| 165 |
+
#worker: Worker | null = null;
|
| 166 |
+
#audioContext: AudioContext | null = null;
|
| 167 |
+
#initializePromise: Promise<void> | null = null;
|
| 168 |
+
#readyDeferred: Deferred<void> | null = null;
|
| 169 |
+
#voiceDeferred: Deferred<void> | null = null;
|
| 170 |
+
#generation: PendingGeneration | null = null;
|
| 171 |
+
#modelBaseUrl = resolveModelBaseUrl();
|
| 172 |
+
#warmed = false;
|
| 173 |
+
#customVoiceReady = false;
|
| 174 |
+
#defaultVoiceName: string | null = null;
|
| 175 |
+
#threadCount = 1;
|
| 176 |
+
#retriedSingleThread = false;
|
| 177 |
+
#statusCallback: ((message: string) => void) | null = null;
|
| 178 |
+
#lastWorkerStatus = "Worker not started.";
|
| 179 |
+
|
| 180 |
+
async initialize(options: {
|
| 181 |
+
modelId: string;
|
| 182 |
+
onProgress?: (message: string) => void;
|
| 183 |
+
}): Promise<void> {
|
| 184 |
+
this.#modelBaseUrl = resolveModelBaseUrl(options.modelId);
|
| 185 |
+
this.#threadCount = this.#resolveInitialThreadCount();
|
| 186 |
+
this.#statusCallback = options.onProgress ?? null;
|
| 187 |
+
|
| 188 |
+
if (this.#initializePromise) {
|
| 189 |
+
return this.#initializePromise;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
this.#initializePromise = this.#startWorkerWithFallback().catch((error) => {
|
| 193 |
+
this.#initializePromise = null;
|
| 194 |
+
throw error;
|
| 195 |
+
});
|
| 196 |
+
return this.#initializePromise;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
async warmup(options: { onProgress?: (message: string) => void } = {}): Promise<void> {
|
| 200 |
+
await this.initialize({
|
| 201 |
+
modelId: this.#modelBaseUrl,
|
| 202 |
+
onProgress: options.onProgress,
|
| 203 |
+
});
|
| 204 |
+
|
| 205 |
+
if (this.#warmed) {
|
| 206 |
+
return;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
options.onProgress?.("Compiling voice runtime...");
|
| 210 |
+
await this.#generateBlob("Benchmark.");
|
| 211 |
+
this.#warmed = true;
|
| 212 |
+
options.onProgress?.("Voice runtime warmed.");
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
async bootstrapFromUtterance(
|
| 216 |
+
audio: Blob,
|
| 217 |
+
): Promise<{ embeddingId?: string }> {
|
| 218 |
+
await this.initialize({ modelId: this.#modelBaseUrl });
|
| 219 |
+
|
| 220 |
+
if (!this.#worker) {
|
| 221 |
+
throw new Error("Pocket TTS worker failed to initialize.");
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
const audioData = await this.#decodeReferenceAudio(audio);
|
| 225 |
+
const deferred = createDeferred<void>();
|
| 226 |
+
this.#voiceDeferred = deferred;
|
| 227 |
+
|
| 228 |
+
this.#worker.postMessage(
|
| 229 |
+
{
|
| 230 |
+
type: "encode_voice",
|
| 231 |
+
data: {
|
| 232 |
+
audio: audioData,
|
| 233 |
+
},
|
| 234 |
+
},
|
| 235 |
+
[audioData.buffer],
|
| 236 |
+
);
|
| 237 |
+
|
| 238 |
+
await deferred.promise;
|
| 239 |
+
this.#customVoiceReady = true;
|
| 240 |
+
|
| 241 |
+
return {
|
| 242 |
+
embeddingId: `custom-${Date.now()}`,
|
| 243 |
+
};
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
async synthesize(options: {
|
| 247 |
+
text: string;
|
| 248 |
+
signal?: AbortSignal;
|
| 249 |
+
referenceAudio?: Blob;
|
| 250 |
+
}): Promise<Blob> {
|
| 251 |
+
await this.initialize({ modelId: this.#modelBaseUrl });
|
| 252 |
+
|
| 253 |
+
if (options.signal?.aborted) {
|
| 254 |
+
throw new Error("Speech synthesis was cancelled.");
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
if (!this.#customVoiceReady && options.referenceAudio) {
|
| 258 |
+
await this.bootstrapFromUtterance(options.referenceAudio);
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
if (!this.#customVoiceReady && !this.#defaultVoiceName) {
|
| 262 |
+
throw new Error("No Pocket TTS voice is ready.");
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
return this.#generateBlob(options.text, options.signal);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
async stream(options: {
|
| 269 |
+
text: string;
|
| 270 |
+
signal?: AbortSignal;
|
| 271 |
+
referenceAudio?: Blob;
|
| 272 |
+
onAudioChunk: (chunk: Float32Array) => void | Promise<void>;
|
| 273 |
+
}): Promise<void> {
|
| 274 |
+
await this.initialize({ modelId: this.#modelBaseUrl });
|
| 275 |
+
|
| 276 |
+
if (options.signal?.aborted) {
|
| 277 |
+
throw new Error("Speech synthesis was cancelled.");
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
if (!this.#customVoiceReady && options.referenceAudio) {
|
| 281 |
+
await this.bootstrapFromUtterance(options.referenceAudio);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
if (!this.#customVoiceReady && !this.#defaultVoiceName) {
|
| 285 |
+
throw new Error("No Pocket TTS voice is ready.");
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
await this.#generateStream(options.text, options.onAudioChunk, options.signal);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
async #startWorkerWithFallback(): Promise<void> {
|
| 292 |
+
try {
|
| 293 |
+
await this.#startWorker();
|
| 294 |
+
} catch (error) {
|
| 295 |
+
if (this.#threadCount > 1 && !this.#retriedSingleThread) {
|
| 296 |
+
console.warn(
|
| 297 |
+
"Pocket TTS worker failed with multithreaded WASM. Retrying single-threaded.",
|
| 298 |
+
error,
|
| 299 |
+
);
|
| 300 |
+
this.#retriedSingleThread = true;
|
| 301 |
+
this.#threadCount = 1;
|
| 302 |
+
this.#resetWorkerState();
|
| 303 |
+
await this.#startWorker();
|
| 304 |
+
return;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
throw error;
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
async #startWorker(): Promise<void> {
|
| 312 |
+
this.#readyDeferred = createDeferred<void>();
|
| 313 |
+
this.#lastWorkerStatus = `Starting worker (${this.#threadCount} thread${this.#threadCount === 1 ? "" : "s"})`;
|
| 314 |
+
console.info("[PocketRuntime] starting worker", {
|
| 315 |
+
modelBaseUrl: this.#modelBaseUrl,
|
| 316 |
+
threadCount: this.#threadCount,
|
| 317 |
+
crossOriginIsolated: globalThis.crossOriginIsolated === true,
|
| 318 |
+
});
|
| 319 |
+
this.#worker = new Worker(WORKER_MODULE_URL, { type: "module" });
|
| 320 |
+
|
| 321 |
+
this.#worker.onmessage = (event: MessageEvent<WorkerMessage>) => {
|
| 322 |
+
this.#handleWorkerMessage(event.data);
|
| 323 |
+
};
|
| 324 |
+
|
| 325 |
+
this.#worker.onmessageerror = (event) => {
|
| 326 |
+
console.error("[PocketRuntime] worker message error", event);
|
| 327 |
+
this.#rejectPending(
|
| 328 |
+
new Error(
|
| 329 |
+
`Pocket TTS worker message error. Last status: ${this.#lastWorkerStatus}.`,
|
| 330 |
+
),
|
| 331 |
+
);
|
| 332 |
+
};
|
| 333 |
+
|
| 334 |
+
this.#worker.onerror = (event) => {
|
| 335 |
+
const location = [event.filename, event.lineno, event.colno]
|
| 336 |
+
.filter(Boolean)
|
| 337 |
+
.join(":");
|
| 338 |
+
const error = new Error(
|
| 339 |
+
[
|
| 340 |
+
event.message || "Pocket TTS worker crashed.",
|
| 341 |
+
location ? `at ${location}` : null,
|
| 342 |
+
`last status: ${this.#lastWorkerStatus}`,
|
| 343 |
+
`thread count: ${this.#threadCount}`,
|
| 344 |
+
]
|
| 345 |
+
.filter(Boolean)
|
| 346 |
+
.join(" "),
|
| 347 |
+
);
|
| 348 |
+
console.error("[PocketRuntime] worker crashed", {
|
| 349 |
+
message: event.message,
|
| 350 |
+
filename: event.filename,
|
| 351 |
+
lineno: event.lineno,
|
| 352 |
+
colno: event.colno,
|
| 353 |
+
lastStatus: this.#lastWorkerStatus,
|
| 354 |
+
threadCount: this.#threadCount,
|
| 355 |
+
});
|
| 356 |
+
this.#rejectPending(error);
|
| 357 |
+
};
|
| 358 |
+
|
| 359 |
+
console.info("[PocketRuntime] posting load request", {
|
| 360 |
+
modelBaseUrl: this.#modelBaseUrl,
|
| 361 |
+
threadCount: this.#threadCount,
|
| 362 |
+
});
|
| 363 |
+
this.#worker.postMessage({
|
| 364 |
+
type: "load",
|
| 365 |
+
data: {
|
| 366 |
+
modelBaseUrl: this.#modelBaseUrl,
|
| 367 |
+
threadCount: this.#threadCount,
|
| 368 |
+
},
|
| 369 |
+
});
|
| 370 |
+
|
| 371 |
+
return this.#readyDeferred.promise;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
#handleWorkerMessage(message: WorkerMessage): void {
|
| 375 |
+
if (message.type === "status" && message.status) {
|
| 376 |
+
this.#lastWorkerStatus = message.status;
|
| 377 |
+
console.info("[PocketRuntime] worker status", message.status, message.state);
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
switch (message.type) {
|
| 381 |
+
case "loaded":
|
| 382 |
+
this.#lastWorkerStatus = "Worker loaded";
|
| 383 |
+
console.info("[PocketRuntime] worker loaded");
|
| 384 |
+
this.#readyDeferred?.resolve();
|
| 385 |
+
this.#readyDeferred = null;
|
| 386 |
+
break;
|
| 387 |
+
case "voices_loaded":
|
| 388 |
+
this.#lastWorkerStatus = `Voices loaded (${message.defaultVoice ?? "none"})`;
|
| 389 |
+
this.#defaultVoiceName = message.defaultVoice ?? null;
|
| 390 |
+
if (this.#defaultVoiceName) {
|
| 391 |
+
this.#statusCallback?.(`Voice ready (${this.#defaultVoiceName}).`);
|
| 392 |
+
}
|
| 393 |
+
break;
|
| 394 |
+
case "voice_encoded":
|
| 395 |
+
this.#lastWorkerStatus = `Voice encoded (${message.voiceName ?? "custom"})`;
|
| 396 |
+
this.#voiceDeferred?.resolve();
|
| 397 |
+
this.#voiceDeferred = null;
|
| 398 |
+
this.#customVoiceReady = true;
|
| 399 |
+
this.#statusCallback?.("Voice profile encoded.");
|
| 400 |
+
break;
|
| 401 |
+
case "voice_set":
|
| 402 |
+
this.#lastWorkerStatus = `Voice selected (${message.voiceName ?? "unknown"})`;
|
| 403 |
+
if (message.voiceName === "custom") {
|
| 404 |
+
this.#customVoiceReady = true;
|
| 405 |
+
}
|
| 406 |
+
if (message.voiceName) {
|
| 407 |
+
this.#statusCallback?.(`Voice selected (${message.voiceName}).`);
|
| 408 |
+
}
|
| 409 |
+
break;
|
| 410 |
+
case "audio_chunk":
|
| 411 |
+
if (this.#generation?.chunks && message.data) {
|
| 412 |
+
const generation = this.#generation;
|
| 413 |
+
const audioChunk = Float32Array.from(message.data);
|
| 414 |
+
if (generation.mode === "blob") {
|
| 415 |
+
generation.chunks.push(audioChunk);
|
| 416 |
+
}
|
| 417 |
+
if (generation.onAudioChunk) {
|
| 418 |
+
generation.chunkTask = generation.chunkTask
|
| 419 |
+
.then(() => generation.onAudioChunk?.(audioChunk))
|
| 420 |
+
.catch((error) => {
|
| 421 |
+
this.#rejectPending(
|
| 422 |
+
error instanceof Error
|
| 423 |
+
? error
|
| 424 |
+
: new Error("Pocket TTS audio streaming failed."),
|
| 425 |
+
);
|
| 426 |
+
});
|
| 427 |
+
}
|
| 428 |
+
}
|
| 429 |
+
break;
|
| 430 |
+
case "stream_ended":
|
| 431 |
+
this.#lastWorkerStatus = "Audio stream ended";
|
| 432 |
+
if (this.#generation) {
|
| 433 |
+
const generation = this.#generation;
|
| 434 |
+
this.#generation = null;
|
| 435 |
+
generation.abortCleanup?.();
|
| 436 |
+
void generation.chunkTask.finally(() => {
|
| 437 |
+
if (generation.mode === "blob") {
|
| 438 |
+
generation.resolve(encodeWav(generation.chunks));
|
| 439 |
+
return;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
generation.resolve();
|
| 443 |
+
});
|
| 444 |
+
}
|
| 445 |
+
break;
|
| 446 |
+
case "error":
|
| 447 |
+
this.#lastWorkerStatus = message.error || "Pocket TTS failed";
|
| 448 |
+
console.error("[PocketRuntime] worker error", message.error);
|
| 449 |
+
this.#rejectPending(new Error(message.error || "Pocket TTS failed."));
|
| 450 |
+
break;
|
| 451 |
+
case "status":
|
| 452 |
+
if (message.status) {
|
| 453 |
+
this.#statusCallback?.(message.status);
|
| 454 |
+
}
|
| 455 |
+
break;
|
| 456 |
+
default:
|
| 457 |
+
break;
|
| 458 |
+
}
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
#rejectPending(error: Error): void {
|
| 462 |
+
console.error("[PocketRuntime] rejecting pending work", error);
|
| 463 |
+
this.#readyDeferred?.reject(error);
|
| 464 |
+
this.#readyDeferred = null;
|
| 465 |
+
|
| 466 |
+
this.#voiceDeferred?.reject(error);
|
| 467 |
+
this.#voiceDeferred = null;
|
| 468 |
+
|
| 469 |
+
if (this.#generation) {
|
| 470 |
+
const generation = this.#generation;
|
| 471 |
+
this.#generation = null;
|
| 472 |
+
generation.abortCleanup?.();
|
| 473 |
+
generation.reject(error);
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
this.#worker?.terminate();
|
| 477 |
+
this.#worker = null;
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
async #generateBlob(text: string, signal?: AbortSignal): Promise<Blob> {
|
| 481 |
+
return new Promise<Blob>((resolve, reject) => {
|
| 482 |
+
this.#startGeneration({
|
| 483 |
+
text,
|
| 484 |
+
signal,
|
| 485 |
+
mode: "blob",
|
| 486 |
+
resolve,
|
| 487 |
+
reject,
|
| 488 |
+
});
|
| 489 |
+
});
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
async #generateStream(
|
| 493 |
+
text: string,
|
| 494 |
+
onAudioChunk: (chunk: Float32Array) => void | Promise<void>,
|
| 495 |
+
signal?: AbortSignal,
|
| 496 |
+
): Promise<void> {
|
| 497 |
+
return new Promise<void>((resolve, reject) => {
|
| 498 |
+
this.#startGeneration({
|
| 499 |
+
text,
|
| 500 |
+
signal,
|
| 501 |
+
mode: "stream",
|
| 502 |
+
resolve,
|
| 503 |
+
reject,
|
| 504 |
+
onAudioChunk,
|
| 505 |
+
});
|
| 506 |
+
});
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
#startGeneration(options: {
|
| 510 |
+
text: string;
|
| 511 |
+
signal?: AbortSignal;
|
| 512 |
+
mode: "blob" | "stream";
|
| 513 |
+
resolve: (value?: Blob) => void;
|
| 514 |
+
reject: (error: Error) => void;
|
| 515 |
+
onAudioChunk?: (chunk: Float32Array) => void | Promise<void>;
|
| 516 |
+
}): void {
|
| 517 |
+
if (!this.#worker) {
|
| 518 |
+
throw new Error("Pocket TTS worker failed to initialize.");
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
if (this.#generation) {
|
| 522 |
+
throw new Error("Pocket TTS only supports one generation at a time.");
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
const pending: PendingGeneration = {
|
| 526 |
+
mode: options.mode,
|
| 527 |
+
chunks: [],
|
| 528 |
+
chunkTask: Promise.resolve(),
|
| 529 |
+
onAudioChunk: options.onAudioChunk,
|
| 530 |
+
resolve: options.resolve,
|
| 531 |
+
reject: options.reject,
|
| 532 |
+
};
|
| 533 |
+
|
| 534 |
+
if (options.signal) {
|
| 535 |
+
const handleAbort = () => {
|
| 536 |
+
if (this.#generation !== pending) {
|
| 537 |
+
return;
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
this.#worker?.postMessage({ type: "stop" });
|
| 541 |
+
this.#generation = null;
|
| 542 |
+
pending.abortCleanup?.();
|
| 543 |
+
options.reject(new Error("Speech synthesis was cancelled."));
|
| 544 |
+
};
|
| 545 |
+
|
| 546 |
+
options.signal.addEventListener("abort", handleAbort, { once: true });
|
| 547 |
+
pending.abortCleanup = () => {
|
| 548 |
+
options.signal?.removeEventListener("abort", handleAbort);
|
| 549 |
+
};
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
this.#generation = pending;
|
| 553 |
+
this.#worker.postMessage({
|
| 554 |
+
type: "generate",
|
| 555 |
+
data: {
|
| 556 |
+
text: options.text,
|
| 557 |
+
},
|
| 558 |
+
});
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
async #decodeReferenceAudio(audio: Blob): Promise<Float32Array> {
|
| 562 |
+
const audioContext = await this.#getAudioContext();
|
| 563 |
+
const input = await audio.arrayBuffer();
|
| 564 |
+
const decoded = await audioContext.decodeAudioData(input.slice(0));
|
| 565 |
+
const mono = toMono(decoded);
|
| 566 |
+
const resampled = resample(mono, decoded.sampleRate, SAMPLE_RATE);
|
| 567 |
+
|
| 568 |
+
return resampled.slice(0, MAX_REFERENCE_SAMPLES);
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
async #getAudioContext(): Promise<AudioContext> {
|
| 572 |
+
if (this.#audioContext) {
|
| 573 |
+
return this.#audioContext;
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
this.#audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
|
| 577 |
+
return this.#audioContext;
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
#resetWorkerState(): void {
|
| 581 |
+
this.#worker?.terminate();
|
| 582 |
+
this.#worker = null;
|
| 583 |
+
this.#readyDeferred = null;
|
| 584 |
+
this.#voiceDeferred = null;
|
| 585 |
+
this.#generation = null;
|
| 586 |
+
this.#lastWorkerStatus = "Worker reset";
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
#resolveInitialThreadCount(): number {
|
| 590 |
+
if (typeof navigator === "undefined") {
|
| 591 |
+
return 1;
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
if (globalThis.crossOriginIsolated !== true) {
|
| 595 |
+
return 1;
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
// ORT's threaded WASM worker path is currently unstable here on Windows.
|
| 599 |
+
// Start single-threaded instead of crashing during benchmark and retrying.
|
| 600 |
+
if (isWindowsPlatform()) {
|
| 601 |
+
this.#statusCallback?.(
|
| 602 |
+
"Voice runtime is using single-threaded mode on Windows for compatibility.",
|
| 603 |
+
);
|
| 604 |
+
return 1;
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
return Math.min(navigator.hardwareConcurrency || 4, 8);
|
| 608 |
+
}
|
| 609 |
+
}
|
src/styles.css
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
color-scheme: light;
|
| 3 |
+
--bg: #f5f4fa;
|
| 4 |
+
--card: rgba(255, 255, 255, 0.96);
|
| 5 |
+
--border: rgba(124, 99, 186, 0.14);
|
| 6 |
+
--text: #1e1a2e;
|
| 7 |
+
--muted: #6e687f;
|
| 8 |
+
--accent: #7457cf;
|
| 9 |
+
--accent-soft: rgba(116, 87, 207, 0.12);
|
| 10 |
+
--danger: #9c3951;
|
| 11 |
+
--shadow: 0 22px 40px rgba(63, 47, 111, 0.12);
|
| 12 |
+
--radius-xl: 28px;
|
| 13 |
+
--radius-lg: 20px;
|
| 14 |
+
--radius-md: 14px;
|
| 15 |
+
font-family:
|
| 16 |
+
"IBM Plex Sans",
|
| 17 |
+
"Segoe UI",
|
| 18 |
+
sans-serif;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
* {
|
| 22 |
+
box-sizing: border-box;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
html,
|
| 26 |
+
body {
|
| 27 |
+
min-height: 100%;
|
| 28 |
+
margin: 0;
|
| 29 |
+
background:
|
| 30 |
+
radial-gradient(circle at top, rgba(138, 115, 221, 0.16), transparent 28%),
|
| 31 |
+
linear-gradient(180deg, #faf9fd 0%, var(--bg) 100%);
|
| 32 |
+
color: var(--text);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
body {
|
| 36 |
+
padding: 24px 16px;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
button {
|
| 40 |
+
font: inherit;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.app-shell {
|
| 44 |
+
min-height: calc(100vh - 48px);
|
| 45 |
+
display: grid;
|
| 46 |
+
place-items: center;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.view {
|
| 50 |
+
width: min(720px, 100%);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.card {
|
| 54 |
+
background: var(--card);
|
| 55 |
+
border: 1px solid var(--border);
|
| 56 |
+
border-radius: var(--radius-xl);
|
| 57 |
+
box-shadow: var(--shadow);
|
| 58 |
+
padding: 28px;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.calibration-card {
|
| 62 |
+
display: grid;
|
| 63 |
+
gap: 16px;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.assistant-card {
|
| 67 |
+
display: grid;
|
| 68 |
+
gap: 18px;
|
| 69 |
+
min-height: min(78vh, 760px);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.eyebrow {
|
| 73 |
+
margin: 0;
|
| 74 |
+
color: var(--accent);
|
| 75 |
+
font-size: 0.78rem;
|
| 76 |
+
font-weight: 700;
|
| 77 |
+
letter-spacing: 0.08em;
|
| 78 |
+
text-transform: uppercase;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.card-title {
|
| 82 |
+
margin: 0;
|
| 83 |
+
font-size: clamp(2rem, 4vw, 3rem);
|
| 84 |
+
line-height: 0.95;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.card-copy,
|
| 88 |
+
.timer-text,
|
| 89 |
+
.summary-text,
|
| 90 |
+
.transcript-empty,
|
| 91 |
+
.turn-header {
|
| 92 |
+
color: var(--muted);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.card-copy,
|
| 96 |
+
.prompt-block,
|
| 97 |
+
.timer-text,
|
| 98 |
+
.summary-text {
|
| 99 |
+
margin: 0;
|
| 100 |
+
line-height: 1.5;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.prompt-block {
|
| 104 |
+
padding: 18px;
|
| 105 |
+
border-radius: var(--radius-lg);
|
| 106 |
+
background: var(--accent-soft);
|
| 107 |
+
color: var(--text);
|
| 108 |
+
font-weight: 600;
|
| 109 |
+
white-space: pre-wrap;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.meter {
|
| 113 |
+
height: 12px;
|
| 114 |
+
border-radius: 999px;
|
| 115 |
+
background: rgba(116, 87, 207, 0.12);
|
| 116 |
+
overflow: hidden;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.meter-fill {
|
| 120 |
+
width: 0;
|
| 121 |
+
height: 100%;
|
| 122 |
+
border-radius: inherit;
|
| 123 |
+
background: linear-gradient(180deg, #8b71dd 0%, #6842c3 100%);
|
| 124 |
+
transition: width 120ms linear;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.button-row {
|
| 128 |
+
display: flex;
|
| 129 |
+
justify-content: center;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.primary-button {
|
| 133 |
+
appearance: none;
|
| 134 |
+
border: 0;
|
| 135 |
+
border-radius: 999px;
|
| 136 |
+
padding: 14px 24px;
|
| 137 |
+
min-width: 188px;
|
| 138 |
+
background: linear-gradient(180deg, #8b71dd 0%, #6842c3 100%);
|
| 139 |
+
color: white;
|
| 140 |
+
font-weight: 700;
|
| 141 |
+
cursor: pointer;
|
| 142 |
+
box-shadow: 0 14px 24px rgba(104, 66, 195, 0.24);
|
| 143 |
+
transition:
|
| 144 |
+
opacity 120ms ease,
|
| 145 |
+
transform 120ms ease;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.primary-button:hover {
|
| 149 |
+
transform: translateY(-1px);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.primary-button:disabled {
|
| 153 |
+
opacity: 0.5;
|
| 154 |
+
cursor: not-allowed;
|
| 155 |
+
transform: none;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.transcript-log {
|
| 159 |
+
min-height: 420px;
|
| 160 |
+
max-height: 58vh;
|
| 161 |
+
overflow: auto;
|
| 162 |
+
display: flex;
|
| 163 |
+
flex-direction: column;
|
| 164 |
+
gap: 12px;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.transcript-empty {
|
| 168 |
+
min-height: 100%;
|
| 169 |
+
display: grid;
|
| 170 |
+
place-items: center;
|
| 171 |
+
text-align: center;
|
| 172 |
+
padding: 32px 24px;
|
| 173 |
+
border-radius: var(--radius-lg);
|
| 174 |
+
background: rgba(116, 87, 207, 0.06);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.turn {
|
| 178 |
+
padding: 14px 16px;
|
| 179 |
+
border-radius: var(--radius-md);
|
| 180 |
+
background: rgba(116, 87, 207, 0.05);
|
| 181 |
+
border: 1px solid rgba(124, 99, 186, 0.1);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.turn[data-role="assistant"] {
|
| 185 |
+
background: rgba(116, 87, 207, 0.09);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.turn-header {
|
| 189 |
+
display: flex;
|
| 190 |
+
justify-content: space-between;
|
| 191 |
+
gap: 12px;
|
| 192 |
+
margin-bottom: 8px;
|
| 193 |
+
font-size: 0.76rem;
|
| 194 |
+
text-transform: uppercase;
|
| 195 |
+
letter-spacing: 0.06em;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.turn-body {
|
| 199 |
+
white-space: pre-wrap;
|
| 200 |
+
line-height: 1.5;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.error-box {
|
| 204 |
+
padding: 14px 16px;
|
| 205 |
+
border-radius: var(--radius-md);
|
| 206 |
+
background: rgba(156, 57, 81, 0.08);
|
| 207 |
+
border: 1px solid rgba(156, 57, 81, 0.16);
|
| 208 |
+
color: var(--danger);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
@media (max-width: 720px) {
|
| 212 |
+
body {
|
| 213 |
+
padding: 18px 12px;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.card {
|
| 217 |
+
padding: 22px 18px;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.transcript-log {
|
| 221 |
+
min-height: 360px;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.primary-button {
|
| 225 |
+
width: 100%;
|
| 226 |
+
}
|
| 227 |
+
}
|
src/vendor/onnxruntime-web/ort-wasm-simd-threaded.jsep.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
var ortWasmThreaded = (() => {
|
| 2 |
+
var _scriptName = import.meta.url;
|
| 3 |
+
|
| 4 |
+
return (
|
| 5 |
+
async function(moduleArg = {}) {
|
| 6 |
+
var moduleRtn;
|
| 7 |
+
|
| 8 |
+
var e=moduleArg,aa,ca,da=new Promise((a,b)=>{aa=a;ca=b}),ea="object"==typeof window,k="undefined"!=typeof WorkerGlobalScope,n="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node&&"renderer"!=process.type,q=k&&self.name?.startsWith("em-pthread");if(n){const {createRequire:a}=await import("module");var require=a(import.meta.url),fa=require("worker_threads");global.Worker=fa.Worker;q=(k=!fa.oc)&&"em-pthread"==fa.workerData}
|
| 9 |
+
e.mountExternalData=(a,b)=>{a.startsWith("./")&&(a=a.substring(2));(e.Eb||(e.Eb=new Map)).set(a,b)};e.unmountExternalData=()=>{delete e.Eb};var SharedArrayBuffer=globalThis.SharedArrayBuffer??(new WebAssembly.Memory({initial:0,maximum:0,pc:!0})).buffer.constructor;
|
| 10 |
+
const ha=a=>async(...b)=>{try{if(e.Fb)throw Error("Session already started");const c=e.Fb={dc:b[0],errors:[]},d=await a(...b);if(e.Fb!==c)throw Error("Session mismatch");e.Jb?.flush();const f=c.errors;if(0<f.length){let g=await Promise.all(f);g=g.filter(h=>h);if(0<g.length)throw Error(g.join("\n"));}return d}finally{e.Fb=null}};
|
| 11 |
+
e.jsepInit=(a,b)=>{if("webgpu"===a){[e.Jb,e.Ub,e.Yb,e.Kb,e.Xb,e.jb,e.Zb,e.ac,e.Vb,e.Wb,e.$b]=b;const c=e.Jb;e.jsepRegisterBuffer=(d,f,g,h)=>c.registerBuffer(d,f,g,h);e.jsepGetBuffer=d=>c.getBuffer(d);e.jsepCreateDownloader=(d,f,g)=>c.createDownloader(d,f,g);e.jsepOnCreateSession=d=>{c.onCreateSession(d)};e.jsepOnReleaseSession=d=>{c.onReleaseSession(d)};e.jsepOnRunStart=d=>c.onRunStart(d);e.bc=(d,f)=>{c.upload(d,f)}}else if("webnn"===a){const c=b[0];[e.nc,e.Nb,e.webnnEnsureTensor,e.Ob,e.webnnDownloadTensor]=
|
| 12 |
+
b.slice(1);e.webnnReleaseTensorId=e.Nb;e.webnnUploadTensor=e.Ob;e.webnnOnRunStart=d=>c.onRunStart(d);e.webnnOnRunEnd=c.onRunEnd.bind(c);e.webnnRegisterMLContext=(d,f)=>{c.registerMLContext(d,f)};e.webnnOnReleaseSession=d=>{c.onReleaseSession(d)};e.webnnCreateMLTensorDownloader=(d,f)=>c.createMLTensorDownloader(d,f);e.webnnRegisterMLTensor=(d,f,g,h)=>c.registerMLTensor(d,f,g,h);e.webnnCreateMLContext=d=>c.createMLContext(d);e.webnnRegisterMLConstant=(d,f,g,h,l,m)=>c.registerMLConstant(d,f,g,h,l,e.Eb,
|
| 13 |
+
m);e.webnnRegisterGraphInput=c.registerGraphInput.bind(c);e.webnnIsGraphInput=c.isGraphInput.bind(c);e.webnnCreateTemporaryTensor=c.createTemporaryTensor.bind(c);e.webnnIsInt64Supported=c.isInt64Supported.bind(c)}};
|
| 14 |
+
let ja=()=>{const a=(b,c,d)=>(...f)=>{const g=t,h=c?.();f=b(...f);const l=c?.();h!==l&&(b=l,d(h),c=d=null);return t!=g?ia():f};(b=>{for(const c of b)e[c]=a(e[c],()=>e[c],d=>e[c]=d)})(["_OrtAppendExecutionProvider","_OrtCreateSession","_OrtRun","_OrtRunWithBinding","_OrtBindInput"]);"undefined"!==typeof ha&&(e._OrtRun=ha(e._OrtRun),e._OrtRunWithBinding=ha(e._OrtRunWithBinding));ja=void 0};e.asyncInit=()=>{ja?.()};var ka=Object.assign({},e),la="./this.program",ma=(a,b)=>{throw b;},v="",na,oa;
|
| 15 |
+
if(n){var fs=require("fs"),pa=require("path");import.meta.url.startsWith("data:")||(v=pa.dirname(require("url").fileURLToPath(import.meta.url))+"/");oa=a=>{a=qa(a)?new URL(a):a;return fs.readFileSync(a)};na=async a=>{a=qa(a)?new URL(a):a;return fs.readFileSync(a,void 0)};!e.thisProgram&&1<process.argv.length&&(la=process.argv[1].replace(/\\/g,"/"));process.argv.slice(2);ma=(a,b)=>{process.exitCode=a;throw b;}}else if(ea||k)k?v=self.location.href:"undefined"!=typeof document&&
|
| 16 |
+
document.currentScript&&(v=document.currentScript.src),_scriptName&&(v=_scriptName),v.startsWith("blob:")?v="":v=v.slice(0,v.replace(/[?#].*/,"").lastIndexOf("/")+1),n||(k&&(oa=a=>{var b=new XMLHttpRequest;b.open("GET",a,!1);b.responseType="arraybuffer";b.send(null);return new Uint8Array(b.response)}),na=async a=>{if(qa(a))return new Promise((c,d)=>{var f=new XMLHttpRequest;f.open("GET",a,!0);f.responseType="arraybuffer";f.onload=()=>{200==f.status||0==f.status&&f.response?c(f.response):d(f.status)};
|
| 17 |
+
f.onerror=d;f.send(null)});var b=await fetch(a,{credentials:"same-origin"});if(b.ok)return b.arrayBuffer();throw Error(b.status+" : "+b.url);});var ra=console.log.bind(console),sa=console.error.bind(console);n&&(ra=(...a)=>fs.writeSync(1,a.join(" ")+"\n"),sa=(...a)=>fs.writeSync(2,a.join(" ")+"\n"));var ta=ra,x=sa;Object.assign(e,ka);ka=null;var ua=e.wasmBinary,z,va,A=!1,wa,B,xa,ya,za,Aa,Ba,Ca,C,Da,Ea,qa=a=>a.startsWith("file://");function D(){z.buffer!=B.buffer&&E();return B}
|
| 18 |
+
function F(){z.buffer!=B.buffer&&E();return xa}function G(){z.buffer!=B.buffer&&E();return ya}function Fa(){z.buffer!=B.buffer&&E();return za}function H(){z.buffer!=B.buffer&&E();return Aa}function I(){z.buffer!=B.buffer&&E();return Ba}function Ga(){z.buffer!=B.buffer&&E();return Ca}function J(){z.buffer!=B.buffer&&E();return Ea}
|
| 19 |
+
if(q){var Ha;if(n){var Ia=fa.parentPort;Ia.on("message",b=>onmessage({data:b}));Object.assign(globalThis,{self:global,postMessage:b=>Ia.postMessage(b)})}var Ja=!1;x=function(...b){b=b.join(" ");n?fs.writeSync(2,b+"\n"):console.error(b)};self.alert=function(...b){postMessage({Bb:"alert",text:b.join(" "),ic:Ka()})};self.onunhandledrejection=b=>{throw b.reason||b;};function a(b){try{var c=b.data,d=c.Bb;if("load"===d){let f=[];self.onmessage=g=>f.push(g);self.startWorker=()=>{postMessage({Bb:"loaded"});
|
| 20 |
+
for(let g of f)a(g);self.onmessage=a};for(const g of c.Rb)if(!e[g]||e[g].proxy)e[g]=(...h)=>{postMessage({Bb:"callHandler",Qb:g,args:h})},"print"==g&&(ta=e[g]),"printErr"==g&&(x=e[g]);z=c.kc;E();Ha(c.lc)}else if("run"===d){La(c.Ab);Ma(c.Ab,0,0,1,0,0);Na();Oa(c.Ab);Ja||(Pa(),Ja=!0);try{Qa(c.fc,c.Hb)}catch(f){if("unwind"!=f)throw f;}}else"setimmediate"!==c.target&&("checkMailbox"===d?Ja&&Ra():d&&(x(`worker: received unknown command ${d}`),x(c)))}catch(f){throw Sa(),f;}}self.onmessage=a}
|
| 21 |
+
function E(){var a=z.buffer;e.HEAP8=B=new Int8Array(a);e.HEAP16=ya=new Int16Array(a);e.HEAPU8=xa=new Uint8Array(a);e.HEAPU16=za=new Uint16Array(a);e.HEAP32=Aa=new Int32Array(a);e.HEAPU32=Ba=new Uint32Array(a);e.HEAPF32=Ca=new Float32Array(a);e.HEAPF64=Ea=new Float64Array(a);e.HEAP64=C=new BigInt64Array(a);e.HEAPU64=Da=new BigUint64Array(a)}q||(z=new WebAssembly.Memory({initial:256,maximum:65536,shared:!0}),E());function Ta(){q?startWorker(e):K.Ca()}var Ua=0,Va=null;
|
| 22 |
+
function Wa(){Ua--;if(0==Ua&&Va){var a=Va;Va=null;a()}}function L(a){a="Aborted("+a+")";x(a);A=!0;a=new WebAssembly.RuntimeError(a+". Build with -sASSERTIONS for more info.");ca(a);throw a;}var Xa;async function Ya(a){if(!ua)try{var b=await na(a);return new Uint8Array(b)}catch{}if(a==Xa&&ua)a=new Uint8Array(ua);else if(oa)a=oa(a);else throw"both async and sync fetching of the wasm failed";return a}
|
| 23 |
+
async function Za(a,b){try{var c=await Ya(a);return await WebAssembly.instantiate(c,b)}catch(d){x(`failed to asynchronously prepare wasm: ${d}`),L(d)}}async function $a(a){var b=Xa;if(!ua&&"function"==typeof WebAssembly.instantiateStreaming&&!qa(b)&&!n)try{var c=fetch(b,{credentials:"same-origin"});return await WebAssembly.instantiateStreaming(c,a)}catch(d){x(`wasm streaming compile failed: ${d}`),x("falling back to ArrayBuffer instantiation")}return Za(b,a)}
|
| 24 |
+
function ab(){bb={L:cb,Aa:db,b:eb,$:fb,A:gb,pa:hb,X:ib,Z:jb,qa:kb,na:lb,ga:mb,ma:nb,J:ob,Y:pb,V:qb,oa:rb,W:sb,va:tb,E:ub,Q:vb,O:wb,D:xb,u:yb,r:zb,P:Ab,z:Bb,R:Cb,ja:Db,T:Eb,aa:Fb,M:Gb,F:Hb,ia:Oa,sa:Ib,t:Jb,Ba:Kb,w:Lb,o:Mb,l:Nb,c:Ob,n:Pb,j:Qb,v:Rb,p:Sb,f:Tb,s:Ub,m:Vb,e:Wb,k:Xb,i:Yb,g:Zb,d:$b,da:ac,ea:bc,fa:cc,ba:dc,ca:ec,N:fc,xa:gc,ua:hc,h:ic,C:jc,G:kc,ta:lc,x:mc,ra:nc,U:oc,q:pc,y:qc,K:rc,S:sc,za:tc,ya:uc,ka:vc,la:wc,_:xc,B:yc,I:zc,ha:Ac,H:Bc,a:z,wa:Cc};return{a:bb}}
|
| 25 |
+
var Dc={829644:(a,b,c,d,f)=>{if("undefined"==typeof e||!e.Eb)return 1;a=M(Number(a>>>0));a.startsWith("./")&&(a=a.substring(2));a=e.Eb.get(a);if(!a)return 2;b=Number(b>>>0);c=Number(c>>>0);d=Number(d>>>0);if(b+c>a.byteLength)return 3;try{const g=a.subarray(b,b+c);switch(f){case 0:F().set(g,d>>>0);break;case 1:e.mc?e.mc(d,g):e.bc(d,g);break;default:return 4}return 0}catch{return 4}},830468:(a,b,c)=>{e.Ob(a,F().subarray(b>>>0,b+c>>>0))},830532:()=>e.nc(),830574:a=>{e.Nb(a)},830611:()=>{e.Vb()},830642:()=>
|
| 26 |
+
{e.Wb()},830671:()=>{e.$b()},830696:a=>e.Ub(a),830729:a=>e.Yb(a),830761:(a,b,c)=>{e.Kb(Number(a),Number(b),Number(c),!0)},830824:(a,b,c)=>{e.Kb(Number(a),Number(b),Number(c))},830881:()=>"undefined"!==typeof wasmOffsetConverter,830938:a=>{e.jb("Abs",a,void 0)},830989:a=>{e.jb("Neg",a,void 0)},831040:a=>{e.jb("Floor",a,void 0)},831093:a=>{e.jb("Ceil",a,void 0)},831145:a=>{e.jb("Reciprocal",a,void 0)},831203:a=>{e.jb("Sqrt",a,void 0)},831255:a=>{e.jb("Exp",a,void 0)},831306:a=>{e.jb("Erf",a,void 0)},
|
| 27 |
+
831357:a=>{e.jb("Sigmoid",a,void 0)},831412:(a,b,c)=>{e.jb("HardSigmoid",a,{alpha:b,beta:c})},831491:a=>{e.jb("Log",a,void 0)},831542:a=>{e.jb("Sin",a,void 0)},831593:a=>{e.jb("Cos",a,void 0)},831644:a=>{e.jb("Tan",a,void 0)},831695:a=>{e.jb("Asin",a,void 0)},831747:a=>{e.jb("Acos",a,void 0)},831799:a=>{e.jb("Atan",a,void 0)},831851:a=>{e.jb("Sinh",a,void 0)},831903:a=>{e.jb("Cosh",a,void 0)},831955:a=>{e.jb("Asinh",a,void 0)},832008:a=>{e.jb("Acosh",a,void 0)},832061:a=>{e.jb("Atanh",a,void 0)},
|
| 28 |
+
832114:a=>{e.jb("Tanh",a,void 0)},832166:a=>{e.jb("Not",a,void 0)},832217:(a,b,c)=>{e.jb("Clip",a,{min:b,max:c})},832286:a=>{e.jb("Clip",a,void 0)},832338:(a,b)=>{e.jb("Elu",a,{alpha:b})},832396:a=>{e.jb("Gelu",a,void 0)},832448:a=>{e.jb("Relu",a,void 0)},832500:(a,b)=>{e.jb("LeakyRelu",a,{alpha:b})},832564:(a,b)=>{e.jb("ThresholdedRelu",a,{alpha:b})},832634:(a,b)=>{e.jb("Cast",a,{to:b})},832692:a=>{e.jb("Add",a,void 0)},832743:a=>{e.jb("Sub",a,void 0)},832794:a=>{e.jb("Mul",a,void 0)},832845:a=>
|
| 29 |
+
{e.jb("Div",a,void 0)},832896:a=>{e.jb("Pow",a,void 0)},832947:a=>{e.jb("Equal",a,void 0)},833E3:a=>{e.jb("Greater",a,void 0)},833055:a=>{e.jb("GreaterOrEqual",a,void 0)},833117:a=>{e.jb("Less",a,void 0)},833169:a=>{e.jb("LessOrEqual",a,void 0)},833228:(a,b,c,d,f)=>{e.jb("ReduceMean",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},833403:(a,b,c,d,f)=>{e.jb("ReduceMax",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>
|
| 30 |
+
0,Number(f)>>>0)):[]})},833577:(a,b,c,d,f)=>{e.jb("ReduceMin",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},833751:(a,b,c,d,f)=>{e.jb("ReduceProd",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},833926:(a,b,c,d,f)=>{e.jb("ReduceSum",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},834100:(a,b,c,d,f)=>{e.jb("ReduceL1",a,{keepDims:!!b,
|
| 31 |
+
noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},834273:(a,b,c,d,f)=>{e.jb("ReduceL2",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},834446:(a,b,c,d,f)=>{e.jb("ReduceLogSum",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},834623:(a,b,c,d,f)=>{e.jb("ReduceSumSquare",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>
|
| 32 |
+
0,Number(f)>>>0)):[]})},834803:(a,b,c,d,f)=>{e.jb("ReduceLogSumExp",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},834983:a=>{e.jb("Where",a,void 0)},835036:(a,b,c)=>{e.jb("Transpose",a,{perm:b?Array.from(H().subarray(Number(b)>>>0,Number(c)>>>0)):[]})},835160:(a,b,c,d)=>{e.jb("DepthToSpace",a,{blocksize:b,mode:M(c),format:d?"NHWC":"NCHW"})},835293:(a,b,c,d)=>{e.jb("DepthToSpace",a,{blocksize:b,mode:M(c),format:d?"NHWC":"NCHW"})},835426:(a,
|
| 33 |
+
b,c,d,f,g,h,l,m,p,r,u,w,y,ba)=>{e.jb("ConvTranspose",a,{format:m?"NHWC":"NCHW",autoPad:b,dilations:[c],group:d,kernelShape:[f],pads:[g,h],strides:[l],wIsConst:()=>!!D()[p>>>0],outputPadding:r?Array.from(H().subarray(Number(r)>>>0,Number(u)>>>0)):[],outputShape:w?Array.from(H().subarray(Number(w)>>>0,Number(y)>>>0)):[],activation:M(ba)})},835859:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.jb("ConvTranspose",a,{format:l?"NHWC":"NCHW",autoPad:b,dilations:Array.from(H().subarray(Number(c)>>>0,(Number(c)>>>0)+2>>>
|
| 34 |
+
0)),group:d,kernelShape:Array.from(H().subarray(Number(f)>>>0,(Number(f)>>>0)+2>>>0)),pads:Array.from(H().subarray(Number(g)>>>0,(Number(g)>>>0)+4>>>0)),strides:Array.from(H().subarray(Number(h)>>>0,(Number(h)>>>0)+2>>>0)),wIsConst:()=>!!D()[m>>>0],outputPadding:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],outputShape:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[],activation:M(y)})},836520:(a,b,c,d,f,g,h,l,m,p,r,u,w,y,ba)=>{e.jb("ConvTranspose",a,{format:m?"NHWC":"NCHW",
|
| 35 |
+
autoPad:b,dilations:[c],group:d,kernelShape:[f],pads:[g,h],strides:[l],wIsConst:()=>!!D()[p>>>0],outputPadding:r?Array.from(H().subarray(Number(r)>>>0,Number(u)>>>0)):[],outputShape:w?Array.from(H().subarray(Number(w)>>>0,Number(y)>>>0)):[],activation:M(ba)})},836953:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.jb("ConvTranspose",a,{format:l?"NHWC":"NCHW",autoPad:b,dilations:Array.from(H().subarray(Number(c)>>>0,(Number(c)>>>0)+2>>>0)),group:d,kernelShape:Array.from(H().subarray(Number(f)>>>0,(Number(f)>>>0)+
|
| 36 |
+
2>>>0)),pads:Array.from(H().subarray(Number(g)>>>0,(Number(g)>>>0)+4>>>0)),strides:Array.from(H().subarray(Number(h)>>>0,(Number(h)>>>0)+2>>>0)),wIsConst:()=>!!D()[m>>>0],outputPadding:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],outputShape:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[],activation:M(y)})},837614:(a,b)=>{e.jb("GlobalAveragePool",a,{format:b?"NHWC":"NCHW"})},837705:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.jb("AveragePool",a,{format:y?"NHWC":"NCHW",auto_pad:b,ceil_mode:c,
|
| 37 |
+
count_include_pad:d,storage_order:f,dilations:g?Array.from(H().subarray(Number(g)>>>0,Number(h)>>>0)):[],kernel_shape:l?Array.from(H().subarray(Number(l)>>>0,Number(m)>>>0)):[],pads:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],strides:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[]})},838184:(a,b)=>{e.jb("GlobalAveragePool",a,{format:b?"NHWC":"NCHW"})},838275:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.jb("AveragePool",a,{format:y?"NHWC":"NCHW",auto_pad:b,ceil_mode:c,count_include_pad:d,
|
| 38 |
+
storage_order:f,dilations:g?Array.from(H().subarray(Number(g)>>>0,Number(h)>>>0)):[],kernel_shape:l?Array.from(H().subarray(Number(l)>>>0,Number(m)>>>0)):[],pads:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],strides:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[]})},838754:(a,b)=>{e.jb("GlobalMaxPool",a,{format:b?"NHWC":"NCHW"})},838841:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.jb("MaxPool",a,{format:y?"NHWC":"NCHW",auto_pad:b,ceil_mode:c,count_include_pad:d,storage_order:f,dilations:g?
|
| 39 |
+
Array.from(H().subarray(Number(g)>>>0,Number(h)>>>0)):[],kernel_shape:l?Array.from(H().subarray(Number(l)>>>0,Number(m)>>>0)):[],pads:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],strides:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[]})},839316:(a,b)=>{e.jb("GlobalMaxPool",a,{format:b?"NHWC":"NCHW"})},839403:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.jb("MaxPool",a,{format:y?"NHWC":"NCHW",auto_pad:b,ceil_mode:c,count_include_pad:d,storage_order:f,dilations:g?Array.from(H().subarray(Number(g)>>>
|
| 40 |
+
0,Number(h)>>>0)):[],kernel_shape:l?Array.from(H().subarray(Number(l)>>>0,Number(m)>>>0)):[],pads:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],strides:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[]})},839878:(a,b,c,d,f)=>{e.jb("Gemm",a,{alpha:b,beta:c,transA:d,transB:f})},839982:a=>{e.jb("MatMul",a,void 0)},840036:(a,b,c,d)=>{e.jb("ArgMax",a,{keepDims:!!b,selectLastIndex:!!c,axis:d})},840144:(a,b,c,d)=>{e.jb("ArgMin",a,{keepDims:!!b,selectLastIndex:!!c,axis:d})},840252:(a,
|
| 41 |
+
b)=>{e.jb("Softmax",a,{axis:b})},840315:(a,b)=>{e.jb("Concat",a,{axis:b})},840375:(a,b,c,d,f)=>{e.jb("Split",a,{axis:b,numOutputs:c,splitSizes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},840531:a=>{e.jb("Expand",a,void 0)},840585:(a,b)=>{e.jb("Gather",a,{axis:Number(b)})},840656:(a,b)=>{e.jb("GatherElements",a,{axis:Number(b)})},840735:(a,b)=>{e.jb("GatherND",a,{batch_dims:Number(b)})},840814:(a,b,c,d,f,g,h,l,m,p,r)=>{e.jb("Resize",a,{antialias:b,axes:c?Array.from(H().subarray(Number(c)>>>
|
| 42 |
+
0,Number(d)>>>0)):[],coordinateTransformMode:M(f),cubicCoeffA:g,excludeOutside:h,extrapolationValue:l,keepAspectRatioPolicy:M(m),mode:M(p),nearestMode:M(r)})},841176:(a,b,c,d,f,g,h)=>{e.jb("Slice",a,{starts:b?Array.from(H().subarray(Number(b)>>>0,Number(c)>>>0)):[],ends:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[],axes:g?Array.from(H().subarray(Number(g)>>>0,Number(h)>>>0)):[]})},841440:a=>{e.jb("Tile",a,void 0)},841492:(a,b,c)=>{e.jb("InstanceNormalization",a,{epsilon:b,format:c?"NHWC":
|
| 43 |
+
"NCHW"})},841606:(a,b,c)=>{e.jb("InstanceNormalization",a,{epsilon:b,format:c?"NHWC":"NCHW"})},841720:a=>{e.jb("Range",a,void 0)},841773:(a,b)=>{e.jb("Einsum",a,{equation:M(b)})},841854:(a,b,c,d,f)=>{e.jb("Pad",a,{mode:b,value:c,pads:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},841997:(a,b,c,d,f,g)=>{e.jb("BatchNormalization",a,{epsilon:b,momentum:c,spatial:!!f,trainingMode:!!d,format:g?"NHWC":"NCHW"})},842166:(a,b,c,d,f,g)=>{e.jb("BatchNormalization",a,{epsilon:b,momentum:c,spatial:!!f,
|
| 44 |
+
trainingMode:!!d,format:g?"NHWC":"NCHW"})},842335:(a,b,c)=>{e.jb("CumSum",a,{exclusive:Number(b),reverse:Number(c)})},842432:(a,b,c)=>{e.jb("DequantizeLinear",a,{axis:b,blockSize:c})},842522:(a,b,c,d,f)=>{e.jb("GridSample",a,{align_corners:b,mode:M(c),padding_mode:M(d),format:f?"NHWC":"NCHW"})},842692:(a,b,c,d,f)=>{e.jb("GridSample",a,{align_corners:b,mode:M(c),padding_mode:M(d),format:f?"NHWC":"NCHW"})},842862:(a,b)=>{e.jb("ScatterND",a,{reduction:M(b)})},842947:(a,b,c,d,f,g,h,l,m)=>{e.jb("Attention",
|
| 45 |
+
a,{numHeads:b,isUnidirectional:c,maskFilterValue:d,scale:f,doRotary:g,qkvHiddenSizes:h?Array.from(H().subarray(Number(l)>>>0,Number(l)+h>>>0)):[],pastPresentShareBuffer:!!m})},843219:a=>{e.jb("BiasAdd",a,void 0)},843274:a=>{e.jb("BiasSplitGelu",a,void 0)},843335:a=>{e.jb("FastGelu",a,void 0)},843391:(a,b,c,d,f,g,h,l,m,p,r,u,w,y,ba,Vd)=>{e.jb("Conv",a,{format:u?"NHWC":"NCHW",auto_pad:b,dilations:c?Array.from(H().subarray(Number(c)>>>0,Number(d)>>>0)):[],group:f,kernel_shape:g?Array.from(H().subarray(Number(g)>>>
|
| 46 |
+
0,Number(h)>>>0)):[],pads:l?Array.from(H().subarray(Number(l)>>>0,Number(m)>>>0)):[],strides:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],w_is_const:()=>!!D()[Number(w)>>>0],activation:M(y),activation_params:ba?Array.from(Ga().subarray(Number(ba)>>>0,Number(Vd)>>>0)):[]})},843975:a=>{e.jb("Gelu",a,void 0)},844027:(a,b,c,d,f,g,h,l,m)=>{e.jb("GroupQueryAttention",a,{numHeads:b,kvNumHeads:c,scale:d,softcap:f,doRotary:g,rotaryInterleaved:h,smoothSoftmax:l,localWindowSize:m})},844244:(a,
|
| 47 |
+
b,c,d)=>{e.jb("LayerNormalization",a,{axis:b,epsilon:c,simplified:!!d})},844355:(a,b,c,d)=>{e.jb("LayerNormalization",a,{axis:b,epsilon:c,simplified:!!d})},844466:(a,b,c,d,f,g)=>{e.jb("MatMulNBits",a,{k:b,n:c,accuracyLevel:d,bits:f,blockSize:g})},844593:(a,b,c,d,f,g)=>{e.jb("MultiHeadAttention",a,{numHeads:b,isUnidirectional:c,maskFilterValue:d,scale:f,doRotary:g})},844752:(a,b)=>{e.jb("QuickGelu",a,{alpha:b})},844816:(a,b,c,d,f)=>{e.jb("RotaryEmbedding",a,{interleaved:!!b,numHeads:c,rotaryEmbeddingDim:d,
|
| 48 |
+
scale:f})},844955:(a,b,c)=>{e.jb("SkipLayerNormalization",a,{epsilon:b,simplified:!!c})},845057:(a,b,c)=>{e.jb("SkipLayerNormalization",a,{epsilon:b,simplified:!!c})},845159:(a,b,c,d)=>{e.jb("GatherBlockQuantized",a,{gatherAxis:b,quantizeAxis:c,blockSize:d})},845280:a=>{e.Zb(a)},845314:(a,b)=>e.ac(Number(a),Number(b),e.Fb.dc,e.Fb.errors)};function db(a,b,c){return Ec(async()=>{await e.Xb(Number(a),Number(b),Number(c))})}function cb(){return"undefined"!==typeof wasmOffsetConverter}
|
| 49 |
+
class Fc{name="ExitStatus";constructor(a){this.message=`Program terminated with exit(${a})`;this.status=a}}
|
| 50 |
+
var Gc=a=>{a.terminate();a.onmessage=()=>{}},Hc=[],Lc=a=>{0==N.length&&(Ic(),Jc(N[0]));var b=N.pop();if(!b)return 6;Kc.push(b);O[a.Ab]=b;b.Ab=a.Ab;var c={Bb:"run",fc:a.ec,Hb:a.Hb,Ab:a.Ab};n&&b.unref();b.postMessage(c,a.Mb);return 0},P=0,Q=(a,b,...c)=>{for(var d=2*c.length,f=Mc(),g=Nc(8*d),h=g>>>3,l=0;l<c.length;l++){var m=c[l];"bigint"==typeof m?(C[h+2*l]=1n,C[h+2*l+1]=m):(C[h+2*l]=0n,J()[h+2*l+1>>>0]=m)}a=Oc(a,0,d,g,b);Pc(f);return a};
|
| 51 |
+
function Cc(a){if(q)return Q(0,1,a);wa=a;if(!(0<P)){for(var b of Kc)Gc(b);for(b of N)Gc(b);N=[];Kc=[];O={};A=!0}ma(a,new Fc(a))}function Qc(a){if(q)return Q(1,0,a);xc(a)}var xc=a=>{wa=a;if(q)throw Qc(a),"unwind";Cc(a)},N=[],Kc=[],Rc=[],O={};function Sc(){for(var a=e.numThreads-1;a--;)Ic();Hc.unshift(()=>{Ua++;Tc(()=>Wa())})}var Vc=a=>{var b=a.Ab;delete O[b];N.push(a);Kc.splice(Kc.indexOf(a),1);a.Ab=0;Uc(b)};function Na(){Rc.forEach(a=>a())}
|
| 52 |
+
var Jc=a=>new Promise(b=>{a.onmessage=g=>{g=g.data;var h=g.Bb;if(g.Gb&&g.Gb!=Ka()){var l=O[g.Gb];l?l.postMessage(g,g.Mb):x(`Internal error! Worker sent a message "${h}" to target pthread ${g.Gb}, but that thread no longer exists!`)}else if("checkMailbox"===h)Ra();else if("spawnThread"===h)Lc(g);else if("cleanupThread"===h)Vc(O[g.hc]);else if("loaded"===h)a.loaded=!0,n&&!a.Ab&&a.unref(),b(a);else if("alert"===h)alert(`Thread ${g.ic}: ${g.text}`);else if("setimmediate"===g.target)a.postMessage(g);else if("callHandler"===
|
| 53 |
+
h)e[g.Qb](...g.args);else h&&x(`worker sent an unknown command ${h}`)};a.onerror=g=>{x(`${"worker sent an error!"} ${g.filename}:${g.lineno}: ${g.message}`);throw g;};n&&(a.on("message",g=>a.onmessage({data:g})),a.on("error",g=>a.onerror(g)));var c=[],d=[],f;for(f of d)e.propertyIsEnumerable(f)&&c.push(f);a.postMessage({Bb:"load",Rb:c,kc:z,lc:va})});function Tc(a){q?a():Promise.all(N.map(Jc)).then(a)}
|
| 54 |
+
function Ic(){var a=new Worker(new URL(import.meta.url),{type:"module",workerData:"em-pthread",name:"em-pthread"});N.push(a)}var La=a=>{E();var b=I()[a+52>>>2>>>0];a=I()[a+56>>>2>>>0];Wc(b,b-a);Pc(b)},Qa=(a,b)=>{P=0;a=Xc(a,b);0<P?wa=a:Yc(a)};class Zc{constructor(a){this.Ib=a-24}}var $c=0,ad=0;function eb(a,b,c){a>>>=0;var d=new Zc(a);b>>>=0;c>>>=0;I()[d.Ib+16>>>2>>>0]=0;I()[d.Ib+4>>>2>>>0]=b;I()[d.Ib+8>>>2>>>0]=c;$c=a;ad++;throw $c;}
|
| 55 |
+
function bd(a,b,c,d){return q?Q(2,1,a,b,c,d):fb(a,b,c,d)}function fb(a,b,c,d){a>>>=0;b>>>=0;c>>>=0;d>>>=0;if("undefined"==typeof SharedArrayBuffer)return 6;var f=[];if(q&&0===f.length)return bd(a,b,c,d);a={ec:c,Ab:a,Hb:d,Mb:f};return q?(a.Bb="spawnThread",postMessage(a,f),0):Lc(a)}
|
| 56 |
+
var cd="undefined"!=typeof TextDecoder?new TextDecoder:void 0,dd=(a,b=0,c=NaN)=>{b>>>=0;var d=b+c;for(c=b;a[c]&&!(c>=d);)++c;if(16<c-b&&a.buffer&&cd)return cd.decode(a.buffer instanceof ArrayBuffer?a.subarray(b,c):a.slice(b,c));for(d="";b<c;){var f=a[b++];if(f&128){var g=a[b++]&63;if(192==(f&224))d+=String.fromCharCode((f&31)<<6|g);else{var h=a[b++]&63;f=224==(f&240)?(f&15)<<12|g<<6|h:(f&7)<<18|g<<12|h<<6|a[b++]&63;65536>f?d+=String.fromCharCode(f):(f-=65536,d+=String.fromCharCode(55296|f>>10,56320|
|
| 57 |
+
f&1023))}}else d+=String.fromCharCode(f)}return d},M=(a,b)=>(a>>>=0)?dd(F(),a,b):"";function gb(a,b,c){return q?Q(3,1,a,b,c):0}function hb(a,b){if(q)return Q(4,1,a,b)}
|
| 58 |
+
var ed=a=>{for(var b=0,c=0;c<a.length;++c){var d=a.charCodeAt(c);127>=d?b++:2047>=d?b+=2:55296<=d&&57343>=d?(b+=4,++c):b+=3}return b},fd=(a,b,c)=>{var d=F();b>>>=0;if(0<c){var f=b;c=b+c-1;for(var g=0;g<a.length;++g){var h=a.charCodeAt(g);if(55296<=h&&57343>=h){var l=a.charCodeAt(++g);h=65536+((h&1023)<<10)|l&1023}if(127>=h){if(b>=c)break;d[b++>>>0]=h}else{if(2047>=h){if(b+1>=c)break;d[b++>>>0]=192|h>>6}else{if(65535>=h){if(b+2>=c)break;d[b++>>>0]=224|h>>12}else{if(b+3>=c)break;d[b++>>>0]=240|h>>18;
|
| 59 |
+
d[b++>>>0]=128|h>>12&63}d[b++>>>0]=128|h>>6&63}d[b++>>>0]=128|h&63}}d[b>>>0]=0;a=b-f}else a=0;return a};function ib(a,b){if(q)return Q(5,1,a,b)}function jb(a,b,c){if(q)return Q(6,1,a,b,c)}function kb(a,b,c){return q?Q(7,1,a,b,c):0}function lb(a,b){if(q)return Q(8,1,a,b)}function mb(a,b,c){if(q)return Q(9,1,a,b,c)}function nb(a,b,c,d){if(q)return Q(10,1,a,b,c,d)}function ob(a,b,c,d){if(q)return Q(11,1,a,b,c,d)}function pb(a,b,c,d){if(q)return Q(12,1,a,b,c,d)}function qb(a){if(q)return Q(13,1,a)}
|
| 60 |
+
function rb(a,b){if(q)return Q(14,1,a,b)}function sb(a,b,c){if(q)return Q(15,1,a,b,c)}var tb=()=>L(""),gd,R=a=>{for(var b="";F()[a>>>0];)b+=gd[F()[a++>>>0]];return b},hd={},jd={},kd={},S;function ld(a,b,c={}){var d=b.name;if(!a)throw new S(`type "${d}" must have a positive integer typeid pointer`);if(jd.hasOwnProperty(a)){if(c.Sb)return;throw new S(`Cannot register type '${d}' twice`);}jd[a]=b;delete kd[a];hd.hasOwnProperty(a)&&(b=hd[a],delete hd[a],b.forEach(f=>f()))}
|
| 61 |
+
function T(a,b,c={}){return ld(a,b,c)}var md=(a,b,c)=>{switch(b){case 1:return c?d=>D()[d>>>0]:d=>F()[d>>>0];case 2:return c?d=>G()[d>>>1>>>0]:d=>Fa()[d>>>1>>>0];case 4:return c?d=>H()[d>>>2>>>0]:d=>I()[d>>>2>>>0];case 8:return c?d=>C[d>>>3]:d=>Da[d>>>3];default:throw new TypeError(`invalid integer width (${b}): ${a}`);}};
|
| 62 |
+
function ub(a,b,c){a>>>=0;c>>>=0;b=R(b>>>0);T(a,{name:b,fromWireType:d=>d,toWireType:function(d,f){if("bigint"!=typeof f&&"number"!=typeof f)throw null===f?f="null":(d=typeof f,f="object"===d||"array"===d||"function"===d?f.toString():""+f),new TypeError(`Cannot convert "${f}" to ${this.name}`);"number"==typeof f&&(f=BigInt(f));return f},Cb:U,readValueFromPointer:md(b,c,-1==b.indexOf("u")),Db:null})}var U=8;
|
| 63 |
+
function vb(a,b,c,d){a>>>=0;b=R(b>>>0);T(a,{name:b,fromWireType:function(f){return!!f},toWireType:function(f,g){return g?c:d},Cb:U,readValueFromPointer:function(f){return this.fromWireType(F()[f>>>0])},Db:null})}var nd=[],V=[];function Ob(a){a>>>=0;9<a&&0===--V[a+1]&&(V[a]=void 0,nd.push(a))}
|
| 64 |
+
var W=a=>{if(!a)throw new S("Cannot use deleted val. handle = "+a);return V[a]},X=a=>{switch(a){case void 0:return 2;case null:return 4;case !0:return 6;case !1:return 8;default:const b=nd.pop()||V.length;V[b]=a;V[b+1]=1;return b}};function od(a){return this.fromWireType(I()[a>>>2>>>0])}var pd={name:"emscripten::val",fromWireType:a=>{var b=W(a);Ob(a);return b},toWireType:(a,b)=>X(b),Cb:U,readValueFromPointer:od,Db:null};function wb(a){return T(a>>>0,pd)}
|
| 65 |
+
var qd=(a,b)=>{switch(b){case 4:return function(c){return this.fromWireType(Ga()[c>>>2>>>0])};case 8:return function(c){return this.fromWireType(J()[c>>>3>>>0])};default:throw new TypeError(`invalid float width (${b}): ${a}`);}};function xb(a,b,c){a>>>=0;c>>>=0;b=R(b>>>0);T(a,{name:b,fromWireType:d=>d,toWireType:(d,f)=>f,Cb:U,readValueFromPointer:qd(b,c),Db:null})}
|
| 66 |
+
function yb(a,b,c,d,f){a>>>=0;c>>>=0;b=R(b>>>0);-1===f&&(f=4294967295);f=l=>l;if(0===d){var g=32-8*c;f=l=>l<<g>>>g}var h=b.includes("unsigned")?function(l,m){return m>>>0}:function(l,m){return m};T(a,{name:b,fromWireType:f,toWireType:h,Cb:U,readValueFromPointer:md(b,c,0!==d),Db:null})}
|
| 67 |
+
function zb(a,b,c){function d(g){var h=I()[g>>>2>>>0];g=I()[g+4>>>2>>>0];return new f(D().buffer,g,h)}a>>>=0;var f=[Int8Array,Uint8Array,Int16Array,Uint16Array,Int32Array,Uint32Array,Float32Array,Float64Array,BigInt64Array,BigUint64Array][b];c=R(c>>>0);T(a,{name:c,fromWireType:d,Cb:U,readValueFromPointer:d},{Sb:!0})}
|
| 68 |
+
function Ab(a,b){a>>>=0;b=R(b>>>0);T(a,{name:b,fromWireType:function(c){for(var d=I()[c>>>2>>>0],f=c+4,g,h=f,l=0;l<=d;++l){var m=f+l;if(l==d||0==F()[m>>>0])h=M(h,m-h),void 0===g?g=h:(g+=String.fromCharCode(0),g+=h),h=m+1}Y(c);return g},toWireType:function(c,d){d instanceof ArrayBuffer&&(d=new Uint8Array(d));var f="string"==typeof d;if(!(f||d instanceof Uint8Array||d instanceof Uint8ClampedArray||d instanceof Int8Array))throw new S("Cannot pass non-string to std::string");var g=f?ed(d):d.length;var h=
|
| 69 |
+
rd(4+g+1),l=h+4;I()[h>>>2>>>0]=g;if(f)fd(d,l,g+1);else if(f)for(f=0;f<g;++f){var m=d.charCodeAt(f);if(255<m)throw Y(h),new S("String has UTF-16 code units that do not fit in 8 bits");F()[l+f>>>0]=m}else for(f=0;f<g;++f)F()[l+f>>>0]=d[f];null!==c&&c.push(Y,h);return h},Cb:U,readValueFromPointer:od,Db(c){Y(c)}})}
|
| 70 |
+
var sd="undefined"!=typeof TextDecoder?new TextDecoder("utf-16le"):void 0,td=(a,b)=>{var c=a>>1;for(var d=c+b/2;!(c>=d)&&Fa()[c>>>0];)++c;c<<=1;if(32<c-a&&sd)return sd.decode(F().slice(a,c));c="";for(d=0;!(d>=b/2);++d){var f=G()[a+2*d>>>1>>>0];if(0==f)break;c+=String.fromCharCode(f)}return c},ud=(a,b,c)=>{c??=2147483647;if(2>c)return 0;c-=2;var d=b;c=c<2*a.length?c/2:a.length;for(var f=0;f<c;++f){var g=a.charCodeAt(f);G()[b>>>1>>>0]=g;b+=2}G()[b>>>1>>>0]=0;return b-d},vd=a=>2*a.length,wd=(a,b)=>{for(var c=
|
| 71 |
+
0,d="";!(c>=b/4);){var f=H()[a+4*c>>>2>>>0];if(0==f)break;++c;65536<=f?(f-=65536,d+=String.fromCharCode(55296|f>>10,56320|f&1023)):d+=String.fromCharCode(f)}return d},xd=(a,b,c)=>{b>>>=0;c??=2147483647;if(4>c)return 0;var d=b;c=d+c-4;for(var f=0;f<a.length;++f){var g=a.charCodeAt(f);if(55296<=g&&57343>=g){var h=a.charCodeAt(++f);g=65536+((g&1023)<<10)|h&1023}H()[b>>>2>>>0]=g;b+=4;if(b+4>c)break}H()[b>>>2>>>0]=0;return b-d},yd=a=>{for(var b=0,c=0;c<a.length;++c){var d=a.charCodeAt(c);55296<=d&&57343>=
|
| 72 |
+
d&&++c;b+=4}return b};
|
| 73 |
+
function Bb(a,b,c){a>>>=0;b>>>=0;c>>>=0;c=R(c);if(2===b){var d=td;var f=ud;var g=vd;var h=l=>Fa()[l>>>1>>>0]}else 4===b&&(d=wd,f=xd,g=yd,h=l=>I()[l>>>2>>>0]);T(a,{name:c,fromWireType:l=>{for(var m=I()[l>>>2>>>0],p,r=l+4,u=0;u<=m;++u){var w=l+4+u*b;if(u==m||0==h(w))r=d(r,w-r),void 0===p?p=r:(p+=String.fromCharCode(0),p+=r),r=w+b}Y(l);return p},toWireType:(l,m)=>{if("string"!=typeof m)throw new S(`Cannot pass non-string to C++ string type ${c}`);var p=g(m),r=rd(4+p+b);I()[r>>>2>>>0]=p/b;f(m,r+4,p+b);
|
| 74 |
+
null!==l&&l.push(Y,r);return r},Cb:U,readValueFromPointer:od,Db(l){Y(l)}})}function Cb(a,b){a>>>=0;b=R(b>>>0);T(a,{Tb:!0,name:b,Cb:0,fromWireType:()=>{},toWireType:()=>{}})}function Db(a){Ma(a>>>0,!k,1,!ea,131072,!1);Na()}var zd=a=>{if(!A)try{if(a(),!(0<P))try{q?Yc(wa):xc(wa)}catch(b){b instanceof Fc||"unwind"==b||ma(1,b)}}catch(b){b instanceof Fc||"unwind"==b||ma(1,b)}};
|
| 75 |
+
function Oa(a){a>>>=0;"function"===typeof Atomics.jc&&(Atomics.jc(H(),a>>>2,a).value.then(Ra),a+=128,Atomics.store(H(),a>>>2,1))}var Ra=()=>{var a=Ka();a&&(Oa(a),zd(Ad))};function Eb(a,b){a>>>=0;a==b>>>0?setTimeout(Ra):q?postMessage({Gb:a,Bb:"checkMailbox"}):(a=O[a])&&a.postMessage({Bb:"checkMailbox"})}var Bd=[];function Fb(a,b,c,d,f){b>>>=0;d/=2;Bd.length=d;c=f>>>0>>>3;for(f=0;f<d;f++)Bd[f]=C[c+2*f]?C[c+2*f+1]:J()[c+2*f+1>>>0];return(b?Dc[b]:Cd[a])(...Bd)}var Gb=()=>{P=0};
|
| 76 |
+
function Hb(a){a>>>=0;q?postMessage({Bb:"cleanupThread",hc:a}):Vc(O[a])}function Ib(a){n&&O[a>>>0].ref()}var Ed=(a,b)=>{var c=jd[a];if(void 0===c)throw a=Dd(a),c=R(a),Y(a),new S(`${b} has unknown type ${c}`);return c},Fd=(a,b,c)=>{var d=[];a=a.toWireType(d,c);d.length&&(I()[b>>>2>>>0]=X(d));return a};function Jb(a,b,c){b>>>=0;c>>>=0;a=W(a>>>0);b=Ed(b,"emval::as");return Fd(b,c,a)}function Kb(a,b){b>>>=0;a=W(a>>>0);b=Ed(b,"emval::as");return b.toWireType(null,a)}var Gd=a=>{try{a()}catch(b){L(b)}};
|
| 77 |
+
function Hd(){var a=K,b={};for(let [c,d]of Object.entries(a))b[c]="function"==typeof d?(...f)=>{Id.push(c);try{return d(...f)}finally{A||(Id.pop(),t&&1===Z&&0===Id.length&&(Z=0,P+=1,Gd(Jd),"undefined"!=typeof Fibers&&Fibers.rc()))}}:d;return b}var Z=0,t=null,Kd=0,Id=[],Ld={},Md={},Nd=0,Od=null,Pd=[];function ia(){return new Promise((a,b)=>{Od={resolve:a,reject:b}})}
|
| 78 |
+
function Qd(){var a=rd(65548),b=a+12;I()[a>>>2>>>0]=b;I()[a+4>>>2>>>0]=b+65536;b=Id[0];var c=Ld[b];void 0===c&&(c=Nd++,Ld[b]=c,Md[c]=b);b=c;H()[a+8>>>2>>>0]=b;return a}function Rd(){var a=H()[t+8>>>2>>>0];a=K[Md[a]];--P;return a()}
|
| 79 |
+
function Sd(a){if(!A){if(0===Z){var b=!1,c=!1;a((d=0)=>{if(!A&&(Kd=d,b=!0,c)){Z=2;Gd(()=>Td(t));"undefined"!=typeof MainLoop&&MainLoop.Pb&&MainLoop.resume();d=!1;try{var f=Rd()}catch(l){f=l,d=!0}var g=!1;if(!t){var h=Od;h&&(Od=null,(d?h.reject:h.resolve)(f),g=!0)}if(d&&!g)throw f;}});c=!0;b||(Z=1,t=Qd(),"undefined"!=typeof MainLoop&&MainLoop.Pb&&MainLoop.pause(),Gd(()=>Ud(t)))}else 2===Z?(Z=0,Gd(Wd),Y(t),t=null,Pd.forEach(zd)):L(`invalid state: ${Z}`);return Kd}}
|
| 80 |
+
function Ec(a){return Sd(b=>{a().then(b)})}function Lb(a){a>>>=0;return Ec(async()=>{var b=await W(a);return X(b)})}var Xd=[];function Mb(a,b,c,d){c>>>=0;d>>>=0;a=Xd[a>>>0];b=W(b>>>0);return a(null,b,c,d)}var Yd={},Zd=a=>{var b=Yd[a];return void 0===b?R(a):b};function Nb(a,b,c,d,f){c>>>=0;d>>>=0;f>>>=0;a=Xd[a>>>0];b=W(b>>>0);c=Zd(c);return a(b,b[c],d,f)}var $d=()=>"object"==typeof globalThis?globalThis:Function("return this")();
|
| 81 |
+
function Pb(a){a>>>=0;if(0===a)return X($d());a=Zd(a);return X($d()[a])}var ae=a=>{var b=Xd.length;Xd.push(a);return b},be=(a,b)=>{for(var c=Array(a),d=0;d<a;++d)c[d]=Ed(I()[b+4*d>>>2>>>0],"parameter "+d);return c},ce=(a,b)=>Object.defineProperty(b,"name",{value:a});
|
| 82 |
+
function de(a){var b=Function;if(!(b instanceof Function))throw new TypeError(`new_ called with constructor type ${typeof b} which is not a function`);var c=ce(b.name||"unknownFunctionName",function(){});c.prototype=b.prototype;c=new c;a=b.apply(c,a);return a instanceof Object?a:c}
|
| 83 |
+
function Qb(a,b,c){b=be(a,b>>>0);var d=b.shift();a--;var f="return function (obj, func, destructorsRef, args) {\n",g=0,h=[];0===c&&h.push("obj");for(var l=["retType"],m=[d],p=0;p<a;++p)h.push("arg"+p),l.push("argType"+p),m.push(b[p]),f+=` var arg${p} = argType${p}.readValueFromPointer(args${g?"+"+g:""});\n`,g+=b[p].Cb;f+=` var rv = ${1===c?"new func":"func.call"}(${h.join(", ")});\n`;d.Tb||(l.push("emval_returnValue"),m.push(Fd),f+=" return emval_returnValue(retType, destructorsRef, rv);\n");l.push(f+
|
| 84 |
+
"};\n");a=de(l)(...m);c=`methodCaller<(${b.map(r=>r.name).join(", ")}) => ${d.name}>`;return ae(ce(c,a))}function Rb(a){a=Zd(a>>>0);return X(e[a])}function Sb(a,b){b>>>=0;a=W(a>>>0);b=W(b);return X(a[b])}function Tb(a){a>>>=0;9<a&&(V[a+1]+=1)}function Ub(){return X([])}function Vb(a){a=W(a>>>0);for(var b=Array(a.length),c=0;c<a.length;c++)b[c]=a[c];return X(b)}function Wb(a){return X(Zd(a>>>0))}function Xb(){return X({})}
|
| 85 |
+
function Yb(a){a>>>=0;for(var b=W(a);b.length;){var c=b.pop();b.pop()(c)}Ob(a)}function Zb(a,b,c){b>>>=0;c>>>=0;a=W(a>>>0);b=W(b);c=W(c);a[b]=c}function $b(a,b){b>>>=0;a=Ed(a>>>0,"_emval_take_value");a=a.readValueFromPointer(b);return X(a)}
|
| 86 |
+
function ac(a,b){a=-9007199254740992>a||9007199254740992<a?NaN:Number(a);b>>>=0;a=new Date(1E3*a);H()[b>>>2>>>0]=a.getUTCSeconds();H()[b+4>>>2>>>0]=a.getUTCMinutes();H()[b+8>>>2>>>0]=a.getUTCHours();H()[b+12>>>2>>>0]=a.getUTCDate();H()[b+16>>>2>>>0]=a.getUTCMonth();H()[b+20>>>2>>>0]=a.getUTCFullYear()-1900;H()[b+24>>>2>>>0]=a.getUTCDay();a=(a.getTime()-Date.UTC(a.getUTCFullYear(),0,1,0,0,0,0))/864E5|0;H()[b+28>>>2>>>0]=a}
|
| 87 |
+
var ee=a=>0===a%4&&(0!==a%100||0===a%400),fe=[0,31,60,91,121,152,182,213,244,274,305,335],ge=[0,31,59,90,120,151,181,212,243,273,304,334];
|
| 88 |
+
function bc(a,b){a=-9007199254740992>a||9007199254740992<a?NaN:Number(a);b>>>=0;a=new Date(1E3*a);H()[b>>>2>>>0]=a.getSeconds();H()[b+4>>>2>>>0]=a.getMinutes();H()[b+8>>>2>>>0]=a.getHours();H()[b+12>>>2>>>0]=a.getDate();H()[b+16>>>2>>>0]=a.getMonth();H()[b+20>>>2>>>0]=a.getFullYear()-1900;H()[b+24>>>2>>>0]=a.getDay();var c=(ee(a.getFullYear())?fe:ge)[a.getMonth()]+a.getDate()-1|0;H()[b+28>>>2>>>0]=c;H()[b+36>>>2>>>0]=-(60*a.getTimezoneOffset());c=(new Date(a.getFullYear(),6,1)).getTimezoneOffset();
|
| 89 |
+
var d=(new Date(a.getFullYear(),0,1)).getTimezoneOffset();a=(c!=d&&a.getTimezoneOffset()==Math.min(d,c))|0;H()[b+32>>>2>>>0]=a}
|
| 90 |
+
function cc(a){a>>>=0;var b=new Date(H()[a+20>>>2>>>0]+1900,H()[a+16>>>2>>>0],H()[a+12>>>2>>>0],H()[a+8>>>2>>>0],H()[a+4>>>2>>>0],H()[a>>>2>>>0],0),c=H()[a+32>>>2>>>0],d=b.getTimezoneOffset(),f=(new Date(b.getFullYear(),6,1)).getTimezoneOffset(),g=(new Date(b.getFullYear(),0,1)).getTimezoneOffset(),h=Math.min(g,f);0>c?H()[a+32>>>2>>>0]=Number(f!=g&&h==d):0<c!=(h==d)&&(f=Math.max(g,f),b.setTime(b.getTime()+6E4*((0<c?h:f)-d)));H()[a+24>>>2>>>0]=b.getDay();c=(ee(b.getFullYear())?fe:ge)[b.getMonth()]+
|
| 91 |
+
b.getDate()-1|0;H()[a+28>>>2>>>0]=c;H()[a>>>2>>>0]=b.getSeconds();H()[a+4>>>2>>>0]=b.getMinutes();H()[a+8>>>2>>>0]=b.getHours();H()[a+12>>>2>>>0]=b.getDate();H()[a+16>>>2>>>0]=b.getMonth();H()[a+20>>>2>>>0]=b.getYear();a=b.getTime();return BigInt(isNaN(a)?-1:a/1E3)}function dc(a,b,c,d,f,g,h){return q?Q(16,1,a,b,c,d,f,g,h):-52}function ec(a,b,c,d,f,g){if(q)return Q(17,1,a,b,c,d,f,g)}var he={},pc=()=>performance.timeOrigin+performance.now();
|
| 92 |
+
function fc(a,b){if(q)return Q(18,1,a,b);he[a]&&(clearTimeout(he[a].id),delete he[a]);if(!b)return 0;var c=setTimeout(()=>{delete he[a];zd(()=>ie(a,performance.timeOrigin+performance.now()))},b);he[a]={id:c,qc:b};return 0}
|
| 93 |
+
function gc(a,b,c,d){a>>>=0;b>>>=0;c>>>=0;d>>>=0;var f=(new Date).getFullYear(),g=(new Date(f,0,1)).getTimezoneOffset();f=(new Date(f,6,1)).getTimezoneOffset();var h=Math.max(g,f);I()[a>>>2>>>0]=60*h;H()[b>>>2>>>0]=Number(g!=f);b=l=>{var m=Math.abs(l);return`UTC${0<=l?"-":"+"}${String(Math.floor(m/60)).padStart(2,"0")}${String(m%60).padStart(2,"0")}`};a=b(g);b=b(f);f<g?(fd(a,c,17),fd(b,d,17)):(fd(a,d,17),fd(b,c,17))}var lc=()=>Date.now(),je=1;
|
| 94 |
+
function hc(a,b,c){if(!(0<=a&&3>=a))return 28;if(0===a)a=Date.now();else if(je)a=performance.timeOrigin+performance.now();else return 52;C[c>>>0>>>3]=BigInt(Math.round(1E6*a));return 0}var ke=[],le=(a,b)=>{ke.length=0;for(var c;c=F()[a++>>>0];){var d=105!=c;d&=112!=c;b+=d&&b%8?4:0;ke.push(112==c?I()[b>>>2>>>0]:106==c?C[b>>>3]:105==c?H()[b>>>2>>>0]:J()[b>>>3>>>0]);b+=d?8:4}return ke};function ic(a,b,c){a>>>=0;b=le(b>>>0,c>>>0);return Dc[a](...b)}
|
| 95 |
+
function jc(a,b,c){a>>>=0;b=le(b>>>0,c>>>0);return Dc[a](...b)}var kc=()=>{};function mc(a,b){return x(M(a>>>0,b>>>0))}var nc=()=>{P+=1;throw"unwind";};function oc(){return 4294901760}var qc=()=>n?require("os").cpus().length:navigator.hardwareConcurrency;function rc(){L("Cannot use emscripten_pc_get_function without -sUSE_OFFSET_CONVERTER");return 0}
|
| 96 |
+
function sc(a){a>>>=0;var b=F().length;if(a<=b||4294901760<a)return!1;for(var c=1;4>=c;c*=2){var d=b*(1+.2/c);d=Math.min(d,a+100663296);a:{d=(Math.min(4294901760,65536*Math.ceil(Math.max(a,d)/65536))-z.buffer.byteLength+65535)/65536|0;try{z.grow(d);E();var f=1;break a}catch(g){}f=void 0}if(f)return!0}return!1}var me=()=>{L("Cannot use convertFrameToPC (needed by __builtin_return_address) without -sUSE_OFFSET_CONVERTER");return 0},ne={},oe=a=>{a.forEach(b=>{var c=me();c&&(ne[c]=b)})};
|
| 97 |
+
function tc(){var a=Error().stack.toString().split("\n");"Error"==a[0]&&a.shift();oe(a);ne.Lb=me();ne.cc=a;return ne.Lb}function uc(a,b,c){a>>>=0;b>>>=0;if(ne.Lb==a)var d=ne.cc;else d=Error().stack.toString().split("\n"),"Error"==d[0]&&d.shift(),oe(d);for(var f=3;d[f]&&me()!=a;)++f;for(a=0;a<c&&d[a+f];++a)H()[b+4*a>>>2>>>0]=me();return a}
|
| 98 |
+
var pe={},re=()=>{if(!qe){var a={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:("object"==typeof navigator&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8",_:la||"./this.program"},b;for(b in pe)void 0===pe[b]?delete a[b]:a[b]=pe[b];var c=[];for(b in a)c.push(`${b}=${a[b]}`);qe=c}return qe},qe;
|
| 99 |
+
function vc(a,b){if(q)return Q(19,1,a,b);a>>>=0;b>>>=0;var c=0;re().forEach((d,f)=>{var g=b+c;f=I()[a+4*f>>>2>>>0]=g;for(g=0;g<d.length;++g)D()[f++>>>0]=d.charCodeAt(g);D()[f>>>0]=0;c+=d.length+1});return 0}function wc(a,b){if(q)return Q(20,1,a,b);a>>>=0;b>>>=0;var c=re();I()[a>>>2>>>0]=c.length;var d=0;c.forEach(f=>d+=f.length+1);I()[b>>>2>>>0]=d;return 0}function yc(a){return q?Q(21,1,a):52}function zc(a,b,c,d){return q?Q(22,1,a,b,c,d):52}function Ac(a,b,c,d){return q?Q(23,1,a,b,c,d):70}
|
| 100 |
+
var se=[null,[],[]];function Bc(a,b,c,d){if(q)return Q(24,1,a,b,c,d);b>>>=0;c>>>=0;d>>>=0;for(var f=0,g=0;g<c;g++){var h=I()[b>>>2>>>0],l=I()[b+4>>>2>>>0];b+=8;for(var m=0;m<l;m++){var p=F()[h+m>>>0],r=se[a];0===p||10===p?((1===a?ta:x)(dd(r)),r.length=0):r.push(p)}f+=l}I()[d>>>2>>>0]=f;return 0}q||Sc();for(var te=Array(256),ue=0;256>ue;++ue)te[ue]=String.fromCharCode(ue);gd=te;S=e.BindingError=class extends Error{constructor(a){super(a);this.name="BindingError"}};
|
| 101 |
+
e.InternalError=class extends Error{constructor(a){super(a);this.name="InternalError"}};V.push(0,1,void 0,1,null,1,!0,1,!1,1);e.count_emval_handles=()=>V.length/2-5-nd.length;var Cd=[Cc,Qc,bd,gb,hb,ib,jb,kb,lb,mb,nb,ob,pb,qb,rb,sb,dc,ec,fc,vc,wc,yc,zc,Ac,Bc],bb,K;
|
| 102 |
+
(async function(){function a(d,f){K=d.exports;K=Hd();K=ve();Rc.push(K.ib);va=f;Wa();return K}Ua++;var b=ab();if(e.instantiateWasm)return new Promise(d=>{e.instantiateWasm(b,(f,g)=>{a(f,g);d(f.exports)})});if(q)return new Promise(d=>{Ha=f=>{var g=new WebAssembly.Instance(f,ab());d(a(g,f))}});Xa??=e.locateFile?e.locateFile?e.locateFile("ort-wasm-simd-threaded.jsep.wasm",v):v+"ort-wasm-simd-threaded.jsep.wasm":(new URL("ort-wasm-simd-threaded.jsep.wasm",import.meta.url)).href;try{var c=await $a(b);
|
| 103 |
+
return a(c.instance,c.module)}catch(d){return ca(d),Promise.reject(d)}})();var Dd=a=>(Dd=K.Da)(a),Pa=()=>(Pa=K.Ea)();e._OrtInit=(a,b)=>(e._OrtInit=K.Fa)(a,b);e._OrtGetLastError=(a,b)=>(e._OrtGetLastError=K.Ga)(a,b);e._OrtCreateSessionOptions=(a,b,c,d,f,g,h,l,m,p)=>(e._OrtCreateSessionOptions=K.Ha)(a,b,c,d,f,g,h,l,m,p);e._OrtAppendExecutionProvider=(a,b,c,d,f)=>(e._OrtAppendExecutionProvider=K.Ia)(a,b,c,d,f);e._OrtAddFreeDimensionOverride=(a,b,c)=>(e._OrtAddFreeDimensionOverride=K.Ja)(a,b,c);
|
| 104 |
+
e._OrtAddSessionConfigEntry=(a,b,c)=>(e._OrtAddSessionConfigEntry=K.Ka)(a,b,c);e._OrtReleaseSessionOptions=a=>(e._OrtReleaseSessionOptions=K.La)(a);e._OrtCreateSession=(a,b,c)=>(e._OrtCreateSession=K.Ma)(a,b,c);e._OrtReleaseSession=a=>(e._OrtReleaseSession=K.Na)(a);e._OrtGetInputOutputCount=(a,b,c)=>(e._OrtGetInputOutputCount=K.Oa)(a,b,c);e._OrtGetInputOutputMetadata=(a,b,c,d)=>(e._OrtGetInputOutputMetadata=K.Pa)(a,b,c,d);e._OrtFree=a=>(e._OrtFree=K.Qa)(a);
|
| 105 |
+
e._OrtCreateTensor=(a,b,c,d,f,g)=>(e._OrtCreateTensor=K.Ra)(a,b,c,d,f,g);e._OrtGetTensorData=(a,b,c,d,f)=>(e._OrtGetTensorData=K.Sa)(a,b,c,d,f);e._OrtReleaseTensor=a=>(e._OrtReleaseTensor=K.Ta)(a);e._OrtCreateRunOptions=(a,b,c,d)=>(e._OrtCreateRunOptions=K.Ua)(a,b,c,d);e._OrtAddRunConfigEntry=(a,b,c)=>(e._OrtAddRunConfigEntry=K.Va)(a,b,c);e._OrtReleaseRunOptions=a=>(e._OrtReleaseRunOptions=K.Wa)(a);e._OrtCreateBinding=a=>(e._OrtCreateBinding=K.Xa)(a);
|
| 106 |
+
e._OrtBindInput=(a,b,c)=>(e._OrtBindInput=K.Ya)(a,b,c);e._OrtBindOutput=(a,b,c,d)=>(e._OrtBindOutput=K.Za)(a,b,c,d);e._OrtClearBoundOutputs=a=>(e._OrtClearBoundOutputs=K._a)(a);e._OrtReleaseBinding=a=>(e._OrtReleaseBinding=K.$a)(a);e._OrtRunWithBinding=(a,b,c,d,f)=>(e._OrtRunWithBinding=K.ab)(a,b,c,d,f);e._OrtRun=(a,b,c,d,f,g,h,l)=>(e._OrtRun=K.bb)(a,b,c,d,f,g,h,l);e._OrtEndProfiling=a=>(e._OrtEndProfiling=K.cb)(a);e._JsepOutput=(a,b,c)=>(e._JsepOutput=K.db)(a,b,c);
|
| 107 |
+
e._JsepGetNodeName=a=>(e._JsepGetNodeName=K.eb)(a);
|
| 108 |
+
var Ka=()=>(Ka=K.fb)(),Y=e._free=a=>(Y=e._free=K.gb)(a),rd=e._malloc=a=>(rd=e._malloc=K.hb)(a),Ma=(a,b,c,d,f,g)=>(Ma=K.kb)(a,b,c,d,f,g),Sa=()=>(Sa=K.lb)(),Oc=(a,b,c,d,f)=>(Oc=K.mb)(a,b,c,d,f),Uc=a=>(Uc=K.nb)(a),Yc=a=>(Yc=K.ob)(a),ie=(a,b)=>(ie=K.pb)(a,b),Ad=()=>(Ad=K.qb)(),Wc=(a,b)=>(Wc=K.rb)(a,b),Pc=a=>(Pc=K.sb)(a),Nc=a=>(Nc=K.tb)(a),Mc=()=>(Mc=K.ub)(),Xc=e.dynCall_ii=(a,b)=>(Xc=e.dynCall_ii=K.vb)(a,b),Ud=a=>(Ud=K.wb)(a),Jd=()=>(Jd=K.xb)(),Td=a=>(Td=K.yb)(a),Wd=()=>(Wd=K.zb)();
|
| 109 |
+
function ve(){var a=K;a=Object.assign({},a);var b=d=>f=>d(f)>>>0,c=d=>()=>d()>>>0;a.Da=b(a.Da);a.fb=c(a.fb);a.hb=b(a.hb);a.tb=b(a.tb);a.ub=c(a.ub);a.__cxa_get_exception_ptr=b(a.__cxa_get_exception_ptr);return a}e.stackSave=()=>Mc();e.stackRestore=a=>Pc(a);e.stackAlloc=a=>Nc(a);
|
| 110 |
+
e.setValue=function(a,b,c="i8"){c.endsWith("*")&&(c="*");switch(c){case "i1":D()[a>>>0]=b;break;case "i8":D()[a>>>0]=b;break;case "i16":G()[a>>>1>>>0]=b;break;case "i32":H()[a>>>2>>>0]=b;break;case "i64":C[a>>>3]=BigInt(b);break;case "float":Ga()[a>>>2>>>0]=b;break;case "double":J()[a>>>3>>>0]=b;break;case "*":I()[a>>>2>>>0]=b;break;default:L(`invalid type for setValue: ${c}`)}};
|
| 111 |
+
e.getValue=function(a,b="i8"){b.endsWith("*")&&(b="*");switch(b){case "i1":return D()[a>>>0];case "i8":return D()[a>>>0];case "i16":return G()[a>>>1>>>0];case "i32":return H()[a>>>2>>>0];case "i64":return C[a>>>3];case "float":return Ga()[a>>>2>>>0];case "double":return J()[a>>>3>>>0];case "*":return I()[a>>>2>>>0];default:L(`invalid type for getValue: ${b}`)}};e.UTF8ToString=M;e.stringToUTF8=fd;e.lengthBytesUTF8=ed;
|
| 112 |
+
function we(){if(0<Ua)Va=we;else if(q)aa(e),Ta();else{for(;0<Hc.length;)Hc.shift()(e);0<Ua?Va=we:(e.calledRun=!0,A||(Ta(),aa(e)))}}we();e.PTR_SIZE=4;moduleRtn=da;
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
return moduleRtn;
|
| 116 |
+
}
|
| 117 |
+
);
|
| 118 |
+
})();
|
| 119 |
+
export default ortWasmThreaded;
|
| 120 |
+
var isPthread = globalThis.self?.name?.startsWith('em-pthread');
|
| 121 |
+
var isNode = typeof globalThis.process?.versions?.node == 'string';
|
| 122 |
+
if (isNode) isPthread = (await import('worker_threads')).workerData === 'em-pthread';
|
| 123 |
+
|
| 124 |
+
// When running as a pthread, construct a new instance on startup
|
| 125 |
+
isPthread && ortWasmThreaded();
|
src/vendor/onnxruntime-web/ort-wasm-simd-threaded.jsep.wasm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c46655e8a94afc45338d4cb2b840475f88e5012d524509916e505079c00bfa39
|
| 3 |
+
size 21596019
|
src/vendor/onnxruntime-web/ort-wasm-simd-threaded.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
var ortWasmThreaded = (() => {
|
| 2 |
+
var _scriptName = import.meta.url;
|
| 3 |
+
|
| 4 |
+
return (
|
| 5 |
+
async function(moduleArg = {}) {
|
| 6 |
+
var moduleRtn;
|
| 7 |
+
|
| 8 |
+
var f=moduleArg,aa,ba,ca=new Promise((a,b)=>{aa=a;ba=b}),da="object"==typeof window,k="undefined"!=typeof WorkerGlobalScope,l="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node&&"renderer"!=process.type,m=k&&self.name?.startsWith("em-pthread");if(l){const {createRequire:a}=await import("module");var require=a(import.meta.url),n=require("worker_threads");global.Worker=n.Worker;m=(k=!n.jb)&&"em-pthread"==n.workerData}
|
| 9 |
+
f.mountExternalData=(a,b)=>{a.startsWith("./")&&(a=a.substring(2));(f.Sa||(f.Sa=new Map)).set(a,b)};f.unmountExternalData=()=>{delete f.Sa};var SharedArrayBuffer=globalThis.SharedArrayBuffer??(new WebAssembly.Memory({initial:0,maximum:0,lb:!0})).buffer.constructor,ea=Object.assign({},f),fa="./this.program",q=(a,b)=>{throw b;},r="",ha,t;
|
| 10 |
+
if(l){var fs=require("fs"),ia=require("path");import.meta.url.startsWith("data:")||(r=ia.dirname(require("url").fileURLToPath(import.meta.url))+"/");t=a=>{a=u(a)?new URL(a):a;return fs.readFileSync(a)};ha=async a=>{a=u(a)?new URL(a):a;return fs.readFileSync(a,void 0)};!f.thisProgram&&1<process.argv.length&&(fa=process.argv[1].replace(/\\/g,"/"));process.argv.slice(2);q=(a,b)=>{process.exitCode=a;throw b;}}else if(da||k)k?r=self.location.href:"undefined"!=typeof document&&document.currentScript&&
|
| 11 |
+
(r=document.currentScript.src),_scriptName&&(r=_scriptName),r.startsWith("blob:")?r="":r=r.slice(0,r.replace(/[?#].*/,"").lastIndexOf("/")+1),l||(k&&(t=a=>{var b=new XMLHttpRequest;b.open("GET",a,!1);b.responseType="arraybuffer";b.send(null);return new Uint8Array(b.response)}),ha=async a=>{if(u(a))return new Promise((c,d)=>{var e=new XMLHttpRequest;e.open("GET",a,!0);e.responseType="arraybuffer";e.onload=()=>{200==e.status||0==e.status&&e.response?c(e.response):d(e.status)};e.onerror=d;e.send(null)});
|
| 12 |
+
var b=await fetch(a,{credentials:"same-origin"});if(b.ok)return b.arrayBuffer();throw Error(b.status+" : "+b.url);});var ja=console.log.bind(console),ka=console.error.bind(console);l&&(ja=(...a)=>fs.writeSync(1,a.join(" ")+"\n"),ka=(...a)=>fs.writeSync(2,a.join(" ")+"\n"));var la=ja,w=ka;Object.assign(f,ea);ea=null;var x=f.wasmBinary,y,ma,z=!1,A,B,na,oa,pa,qa,ra,C,sa,u=a=>a.startsWith("file://");function D(){y.buffer!=B.buffer&&E();return B}function F(){y.buffer!=B.buffer&&E();return na}
|
| 13 |
+
function ta(){y.buffer!=B.buffer&&E();return oa}function G(){y.buffer!=B.buffer&&E();return pa}function H(){y.buffer!=B.buffer&&E();return qa}function va(){y.buffer!=B.buffer&&E();return ra}function I(){y.buffer!=B.buffer&&E();return sa}
|
| 14 |
+
if(m){var wa;if(l){var xa=n.parentPort;xa.on("message",b=>onmessage({data:b}));Object.assign(globalThis,{self:global,postMessage:b=>xa.postMessage(b)})}var ya=!1;w=function(...b){b=b.join(" ");l?fs.writeSync(2,b+"\n"):console.error(b)};self.alert=function(...b){postMessage({Ra:"alert",text:b.join(" "),eb:J()})};self.onunhandledrejection=b=>{throw b.reason||b;};function a(b){try{var c=b.data,d=c.Ra;if("load"===d){let e=[];self.onmessage=g=>e.push(g);self.startWorker=()=>{postMessage({Ra:"loaded"});
|
| 15 |
+
for(let g of e)a(g);self.onmessage=a};for(const g of c.Za)if(!f[g]||f[g].proxy)f[g]=(...h)=>{postMessage({Ra:"callHandler",Ya:g,args:h})},"print"==g&&(la=f[g]),"printErr"==g&&(w=f[g]);y=c.gb;E();wa(c.hb)}else if("run"===d){za(c.Qa);Aa(c.Qa,0,0,1,0,0);Ba();Ca(c.Qa);ya||=!0;try{Da(c.bb,c.Va)}catch(e){if("unwind"!=e)throw e;}}else"setimmediate"!==c.target&&("checkMailbox"===d?ya&&K():d&&(w(`worker: received unknown command ${d}`),w(c)))}catch(e){throw Ea(),e;}}self.onmessage=a}
|
| 16 |
+
function E(){var a=y.buffer;f.HEAP8=B=new Int8Array(a);f.HEAP16=oa=new Int16Array(a);f.HEAPU8=na=new Uint8Array(a);f.HEAPU16=new Uint16Array(a);f.HEAP32=pa=new Int32Array(a);f.HEAPU32=qa=new Uint32Array(a);f.HEAPF32=ra=new Float32Array(a);f.HEAPF64=sa=new Float64Array(a);f.HEAP64=C=new BigInt64Array(a);f.HEAPU64=new BigUint64Array(a)}m||(y=new WebAssembly.Memory({initial:256,maximum:65536,shared:!0}),E());function Fa(){m?startWorker(f):L.$()}var M=0,N=null;
|
| 17 |
+
function Ga(){M--;if(0==M&&N){var a=N;N=null;a()}}function O(a){a="Aborted("+a+")";w(a);z=!0;a=new WebAssembly.RuntimeError(a+". Build with -sASSERTIONS for more info.");ba(a);throw a;}var Ha;async function Ia(a){if(!x)try{var b=await ha(a);return new Uint8Array(b)}catch{}if(a==Ha&&x)a=new Uint8Array(x);else if(t)a=t(a);else throw"both async and sync fetching of the wasm failed";return a}
|
| 18 |
+
async function Ja(a,b){try{var c=await Ia(a);return await WebAssembly.instantiate(c,b)}catch(d){w(`failed to asynchronously prepare wasm: ${d}`),O(d)}}async function Ka(a){var b=Ha;if(!x&&"function"==typeof WebAssembly.instantiateStreaming&&!u(b)&&!l)try{var c=fetch(b,{credentials:"same-origin"});return await WebAssembly.instantiateStreaming(c,a)}catch(d){w(`wasm streaming compile failed: ${d}`),w("falling back to ArrayBuffer instantiation")}return Ja(b,a)}
|
| 19 |
+
function La(){Ma={j:Na,b:Oa,E:Pa,f:Qa,U:Ra,A:Sa,C:Ta,V:Ua,S:Va,L:Wa,R:Xa,n:Ya,B:Za,y:$a,T:ab,z:bb,_:cb,O:db,w:eb,F:fb,t:gb,i:hb,N:Ca,X:ib,I:jb,J:kb,K:lb,G:mb,H:nb,u:ob,q:pb,Z:qb,o:rb,k:sb,Y:tb,d:ub,W:vb,x:wb,c:xb,e:yb,h:zb,v:Ab,s:Bb,r:Cb,P:Db,Q:Eb,D:Fb,g:Gb,m:Hb,M:Ib,l:Jb,a:y,p:Kb};return{a:Ma}}
|
| 20 |
+
var Mb={794988:(a,b,c,d,e)=>{if("undefined"==typeof f||!f.Sa)return 1;a=Lb(Number(a>>>0));a.startsWith("./")&&(a=a.substring(2));a=f.Sa.get(a);if(!a)return 2;b=Number(b>>>0);c=Number(c>>>0);d=Number(d>>>0);if(b+c>a.byteLength)return 3;try{const g=a.subarray(b,b+c);switch(e){case 0:F().set(g,d>>>0);break;case 1:f.ib?f.ib(d,g):f.kb(d,g);break;default:return 4}return 0}catch{return 4}},795812:()=>"undefined"!==typeof wasmOffsetConverter};function Na(){return"undefined"!==typeof wasmOffsetConverter}
|
| 21 |
+
class Nb{name="ExitStatus";constructor(a){this.message=`Program terminated with exit(${a})`;this.status=a}}
|
| 22 |
+
var Ob=a=>{a.terminate();a.onmessage=()=>{}},Pb=[],Sb=a=>{0==Q.length&&(Qb(),Rb(Q[0]));var b=Q.pop();if(!b)return 6;R.push(b);S[a.Qa]=b;b.Qa=a.Qa;var c={Ra:"run",bb:a.ab,Va:a.Va,Qa:a.Qa};l&&b.unref();b.postMessage(c,a.Xa);return 0},T=0,V=(a,b,...c)=>{for(var d=2*c.length,e=Tb(),g=Ub(8*d),h=g>>>3,p=0;p<c.length;p++){var v=c[p];"bigint"==typeof v?(C[h+2*p]=1n,C[h+2*p+1]=v):(C[h+2*p]=0n,I()[h+2*p+1>>>0]=v)}a=Vb(a,0,d,g,b);U(e);return a};
|
| 23 |
+
function Kb(a){if(m)return V(0,1,a);A=a;if(!(0<T)){for(var b of R)Ob(b);for(b of Q)Ob(b);Q=[];R=[];S={};z=!0}q(a,new Nb(a))}function Wb(a){if(m)return V(1,0,a);Fb(a)}var Fb=a=>{A=a;if(m)throw Wb(a),"unwind";Kb(a)},Q=[],R=[],Xb=[],S={};function Yb(){for(var a=f.numThreads-1;a--;)Qb();Pb.unshift(()=>{M++;Zb(()=>Ga())})}var ac=a=>{var b=a.Qa;delete S[b];Q.push(a);R.splice(R.indexOf(a),1);a.Qa=0;$b(b)};function Ba(){Xb.forEach(a=>a())}
|
| 24 |
+
var Rb=a=>new Promise(b=>{a.onmessage=g=>{g=g.data;var h=g.Ra;if(g.Ta&&g.Ta!=J()){var p=S[g.Ta];p?p.postMessage(g,g.Xa):w(`Internal error! Worker sent a message "${h}" to target pthread ${g.Ta}, but that thread no longer exists!`)}else if("checkMailbox"===h)K();else if("spawnThread"===h)Sb(g);else if("cleanupThread"===h)ac(S[g.cb]);else if("loaded"===h)a.loaded=!0,l&&!a.Qa&&a.unref(),b(a);else if("alert"===h)alert(`Thread ${g.eb}: ${g.text}`);else if("setimmediate"===g.target)a.postMessage(g);else if("callHandler"===
|
| 25 |
+
h)f[g.Ya](...g.args);else h&&w(`worker sent an unknown command ${h}`)};a.onerror=g=>{w(`${"worker sent an error!"} ${g.filename}:${g.lineno}: ${g.message}`);throw g;};l&&(a.on("message",g=>a.onmessage({data:g})),a.on("error",g=>a.onerror(g)));var c=[],d=[],e;for(e of d)f.propertyIsEnumerable(e)&&c.push(e);a.postMessage({Ra:"load",Za:c,gb:y,hb:ma})});function Zb(a){m?a():Promise.all(Q.map(Rb)).then(a)}
|
| 26 |
+
function Qb(){var a=new Worker(new URL(import.meta.url),{type:"module",workerData:"em-pthread",name:"em-pthread"});Q.push(a)}var za=a=>{E();var b=H()[a+52>>>2>>>0];a=H()[a+56>>>2>>>0];bc(b,b-a);U(b)},W=[],cc,Da=(a,b)=>{T=0;var c=W[a];c||(a>=W.length&&(W.length=a+1),W[a]=c=cc.get(a));a=c(b);0<T?A=a:dc(a)};class ec{constructor(a){this.Ua=a-24}}var fc=0,gc=0;
|
| 27 |
+
function Oa(a,b,c){a>>>=0;var d=new ec(a);b>>>=0;c>>>=0;H()[d.Ua+16>>>2>>>0]=0;H()[d.Ua+4>>>2>>>0]=b;H()[d.Ua+8>>>2>>>0]=c;fc=a;gc++;throw fc;}function hc(a,b,c,d){return m?V(2,1,a,b,c,d):Pa(a,b,c,d)}function Pa(a,b,c,d){a>>>=0;b>>>=0;c>>>=0;d>>>=0;if("undefined"==typeof SharedArrayBuffer)return 6;var e=[];if(m&&0===e.length)return hc(a,b,c,d);a={ab:c,Qa:a,Va:d,Xa:e};return m?(a.Ra="spawnThread",postMessage(a,e),0):Sb(a)}
|
| 28 |
+
var ic="undefined"!=typeof TextDecoder?new TextDecoder:void 0,jc=(a,b=0,c=NaN)=>{b>>>=0;var d=b+c;for(c=b;a[c]&&!(c>=d);)++c;if(16<c-b&&a.buffer&&ic)return ic.decode(a.buffer instanceof ArrayBuffer?a.subarray(b,c):a.slice(b,c));for(d="";b<c;){var e=a[b++];if(e&128){var g=a[b++]&63;if(192==(e&224))d+=String.fromCharCode((e&31)<<6|g);else{var h=a[b++]&63;e=224==(e&240)?(e&15)<<12|g<<6|h:(e&7)<<18|g<<12|h<<6|a[b++]&63;65536>e?d+=String.fromCharCode(e):(e-=65536,d+=String.fromCharCode(55296|e>>10,56320|
|
| 29 |
+
e&1023))}}else d+=String.fromCharCode(e)}return d},Lb=(a,b)=>(a>>>=0)?jc(F(),a,b):"";function Qa(a,b,c){return m?V(3,1,a,b,c):0}function Ra(a,b){if(m)return V(4,1,a,b)}
|
| 30 |
+
var X=(a,b,c)=>{var d=F();b>>>=0;if(0<c){var e=b;c=b+c-1;for(var g=0;g<a.length;++g){var h=a.charCodeAt(g);if(55296<=h&&57343>=h){var p=a.charCodeAt(++g);h=65536+((h&1023)<<10)|p&1023}if(127>=h){if(b>=c)break;d[b++>>>0]=h}else{if(2047>=h){if(b+1>=c)break;d[b++>>>0]=192|h>>6}else{if(65535>=h){if(b+2>=c)break;d[b++>>>0]=224|h>>12}else{if(b+3>=c)break;d[b++>>>0]=240|h>>18;d[b++>>>0]=128|h>>12&63}d[b++>>>0]=128|h>>6&63}d[b++>>>0]=128|h&63}}d[b>>>0]=0;a=b-e}else a=0;return a};
|
| 31 |
+
function Sa(a,b){if(m)return V(5,1,a,b)}function Ta(a,b,c){if(m)return V(6,1,a,b,c)}function Ua(a,b,c){return m?V(7,1,a,b,c):0}function Va(a,b){if(m)return V(8,1,a,b)}function Wa(a,b,c){if(m)return V(9,1,a,b,c)}function Xa(a,b,c,d){if(m)return V(10,1,a,b,c,d)}function Ya(a,b,c,d){if(m)return V(11,1,a,b,c,d)}function Za(a,b,c,d){if(m)return V(12,1,a,b,c,d)}function $a(a){if(m)return V(13,1,a)}function ab(a,b){if(m)return V(14,1,a,b)}function bb(a,b,c){if(m)return V(15,1,a,b,c)}var cb=()=>O("");
|
| 32 |
+
function db(a){Aa(a>>>0,!k,1,!da,131072,!1);Ba()}var kc=a=>{if(!z)try{if(a(),!(0<T))try{m?dc(A):Fb(A)}catch(b){b instanceof Nb||"unwind"==b||q(1,b)}}catch(b){b instanceof Nb||"unwind"==b||q(1,b)}};function Ca(a){a>>>=0;"function"===typeof Atomics.fb&&(Atomics.fb(G(),a>>>2,a).value.then(K),a+=128,Atomics.store(G(),a>>>2,1))}var K=()=>{var a=J();a&&(Ca(a),kc(lc))};function eb(a,b){a>>>=0;a==b>>>0?setTimeout(K):m?postMessage({Ta:a,Ra:"checkMailbox"}):(a=S[a])&&a.postMessage({Ra:"checkMailbox"})}
|
| 33 |
+
var mc=[];function fb(a,b,c,d,e){b>>>=0;d/=2;mc.length=d;c=e>>>0>>>3;for(e=0;e<d;e++)mc[e]=C[c+2*e]?C[c+2*e+1]:I()[c+2*e+1>>>0];return(b?Mb[b]:nc[a])(...mc)}var gb=()=>{T=0};function hb(a){a>>>=0;m?postMessage({Ra:"cleanupThread",cb:a}):ac(S[a])}function ib(a){l&&S[a>>>0].ref()}
|
| 34 |
+
function jb(a,b){a=-9007199254740992>a||9007199254740992<a?NaN:Number(a);b>>>=0;a=new Date(1E3*a);G()[b>>>2>>>0]=a.getUTCSeconds();G()[b+4>>>2>>>0]=a.getUTCMinutes();G()[b+8>>>2>>>0]=a.getUTCHours();G()[b+12>>>2>>>0]=a.getUTCDate();G()[b+16>>>2>>>0]=a.getUTCMonth();G()[b+20>>>2>>>0]=a.getUTCFullYear()-1900;G()[b+24>>>2>>>0]=a.getUTCDay();a=(a.getTime()-Date.UTC(a.getUTCFullYear(),0,1,0,0,0,0))/864E5|0;G()[b+28>>>2>>>0]=a}
|
| 35 |
+
var oc=a=>0===a%4&&(0!==a%100||0===a%400),pc=[0,31,60,91,121,152,182,213,244,274,305,335],qc=[0,31,59,90,120,151,181,212,243,273,304,334];
|
| 36 |
+
function kb(a,b){a=-9007199254740992>a||9007199254740992<a?NaN:Number(a);b>>>=0;a=new Date(1E3*a);G()[b>>>2>>>0]=a.getSeconds();G()[b+4>>>2>>>0]=a.getMinutes();G()[b+8>>>2>>>0]=a.getHours();G()[b+12>>>2>>>0]=a.getDate();G()[b+16>>>2>>>0]=a.getMonth();G()[b+20>>>2>>>0]=a.getFullYear()-1900;G()[b+24>>>2>>>0]=a.getDay();var c=(oc(a.getFullYear())?pc:qc)[a.getMonth()]+a.getDate()-1|0;G()[b+28>>>2>>>0]=c;G()[b+36>>>2>>>0]=-(60*a.getTimezoneOffset());c=(new Date(a.getFullYear(),6,1)).getTimezoneOffset();
|
| 37 |
+
var d=(new Date(a.getFullYear(),0,1)).getTimezoneOffset();a=(c!=d&&a.getTimezoneOffset()==Math.min(d,c))|0;G()[b+32>>>2>>>0]=a}
|
| 38 |
+
function lb(a){a>>>=0;var b=new Date(G()[a+20>>>2>>>0]+1900,G()[a+16>>>2>>>0],G()[a+12>>>2>>>0],G()[a+8>>>2>>>0],G()[a+4>>>2>>>0],G()[a>>>2>>>0],0),c=G()[a+32>>>2>>>0],d=b.getTimezoneOffset(),e=(new Date(b.getFullYear(),6,1)).getTimezoneOffset(),g=(new Date(b.getFullYear(),0,1)).getTimezoneOffset(),h=Math.min(g,e);0>c?G()[a+32>>>2>>>0]=Number(e!=g&&h==d):0<c!=(h==d)&&(e=Math.max(g,e),b.setTime(b.getTime()+6E4*((0<c?h:e)-d)));G()[a+24>>>2>>>0]=b.getDay();c=(oc(b.getFullYear())?pc:qc)[b.getMonth()]+
|
| 39 |
+
b.getDate()-1|0;G()[a+28>>>2>>>0]=c;G()[a>>>2>>>0]=b.getSeconds();G()[a+4>>>2>>>0]=b.getMinutes();G()[a+8>>>2>>>0]=b.getHours();G()[a+12>>>2>>>0]=b.getDate();G()[a+16>>>2>>>0]=b.getMonth();G()[a+20>>>2>>>0]=b.getYear();a=b.getTime();return BigInt(isNaN(a)?-1:a/1E3)}function mb(a,b,c,d,e,g,h){return m?V(16,1,a,b,c,d,e,g,h):-52}function nb(a,b,c,d,e,g){if(m)return V(17,1,a,b,c,d,e,g)}var Y={},xb=()=>performance.timeOrigin+performance.now();
|
| 40 |
+
function ob(a,b){if(m)return V(18,1,a,b);Y[a]&&(clearTimeout(Y[a].id),delete Y[a]);if(!b)return 0;var c=setTimeout(()=>{delete Y[a];kc(()=>rc(a,performance.timeOrigin+performance.now()))},b);Y[a]={id:c,mb:b};return 0}
|
| 41 |
+
function pb(a,b,c,d){a>>>=0;b>>>=0;c>>>=0;d>>>=0;var e=(new Date).getFullYear(),g=(new Date(e,0,1)).getTimezoneOffset();e=(new Date(e,6,1)).getTimezoneOffset();var h=Math.max(g,e);H()[a>>>2>>>0]=60*h;G()[b>>>2>>>0]=Number(g!=e);b=p=>{var v=Math.abs(p);return`UTC${0<=p?"-":"+"}${String(Math.floor(v/60)).padStart(2,"0")}${String(v%60).padStart(2,"0")}`};a=b(g);b=b(e);e<g?(X(a,c,17),X(b,d,17)):(X(a,d,17),X(b,c,17))}var tb=()=>Date.now(),sc=1;
|
| 42 |
+
function qb(a,b,c){if(!(0<=a&&3>=a))return 28;if(0===a)a=Date.now();else if(sc)a=performance.timeOrigin+performance.now();else return 52;C[c>>>0>>>3]=BigInt(Math.round(1E6*a));return 0}var tc=[];function rb(a,b,c){a>>>=0;b>>>=0;c>>>=0;tc.length=0;for(var d;d=F()[b++>>>0];){var e=105!=d;e&=112!=d;c+=e&&c%8?4:0;tc.push(112==d?H()[c>>>2>>>0]:106==d?C[c>>>3]:105==d?G()[c>>>2>>>0]:I()[c>>>3>>>0]);c+=e?8:4}return Mb[a](...tc)}var sb=()=>{};function ub(a,b){return w(Lb(a>>>0,b>>>0))}
|
| 43 |
+
var vb=()=>{T+=1;throw"unwind";};function wb(){return 4294901760}var yb=()=>l?require("os").cpus().length:navigator.hardwareConcurrency;function zb(){O("Cannot use emscripten_pc_get_function without -sUSE_OFFSET_CONVERTER");return 0}
|
| 44 |
+
function Ab(a){a>>>=0;var b=F().length;if(a<=b||4294901760<a)return!1;for(var c=1;4>=c;c*=2){var d=b*(1+.2/c);d=Math.min(d,a+100663296);a:{d=(Math.min(4294901760,65536*Math.ceil(Math.max(a,d)/65536))-y.buffer.byteLength+65535)/65536|0;try{y.grow(d);E();var e=1;break a}catch(g){}e=void 0}if(e)return!0}return!1}var uc=()=>{O("Cannot use convertFrameToPC (needed by __builtin_return_address) without -sUSE_OFFSET_CONVERTER");return 0},Z={},vc=a=>{a.forEach(b=>{var c=uc();c&&(Z[c]=b)})};
|
| 45 |
+
function Bb(){var a=Error().stack.toString().split("\n");"Error"==a[0]&&a.shift();vc(a);Z.Wa=uc();Z.$a=a;return Z.Wa}function Cb(a,b,c){a>>>=0;b>>>=0;if(Z.Wa==a)var d=Z.$a;else d=Error().stack.toString().split("\n"),"Error"==d[0]&&d.shift(),vc(d);for(var e=3;d[e]&&uc()!=a;)++e;for(a=0;a<c&&d[a+e];++a)G()[b+4*a>>>2>>>0]=uc();return a}
|
| 46 |
+
var wc={},yc=()=>{if(!xc){var a={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:("object"==typeof navigator&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8",_:fa||"./this.program"},b;for(b in wc)void 0===wc[b]?delete a[b]:a[b]=wc[b];var c=[];for(b in a)c.push(`${b}=${a[b]}`);xc=c}return xc},xc;
|
| 47 |
+
function Db(a,b){if(m)return V(19,1,a,b);a>>>=0;b>>>=0;var c=0;yc().forEach((d,e)=>{var g=b+c;e=H()[a+4*e>>>2>>>0]=g;for(g=0;g<d.length;++g)D()[e++>>>0]=d.charCodeAt(g);D()[e>>>0]=0;c+=d.length+1});return 0}function Eb(a,b){if(m)return V(20,1,a,b);a>>>=0;b>>>=0;var c=yc();H()[a>>>2>>>0]=c.length;var d=0;c.forEach(e=>d+=e.length+1);H()[b>>>2>>>0]=d;return 0}function Gb(a){return m?V(21,1,a):52}function Hb(a,b,c,d){return m?V(22,1,a,b,c,d):52}function Ib(a,b,c,d){return m?V(23,1,a,b,c,d):70}
|
| 48 |
+
var zc=[null,[],[]];function Jb(a,b,c,d){if(m)return V(24,1,a,b,c,d);b>>>=0;c>>>=0;d>>>=0;for(var e=0,g=0;g<c;g++){var h=H()[b>>>2>>>0],p=H()[b+4>>>2>>>0];b+=8;for(var v=0;v<p;v++){var P=F()[h+v>>>0],ua=zc[a];0===P||10===P?((1===a?la:w)(jc(ua)),ua.length=0):ua.push(P)}e+=p}H()[d>>>2>>>0]=e;return 0}m||Yb();var nc=[Kb,Wb,hc,Qa,Ra,Sa,Ta,Ua,Va,Wa,Xa,Ya,Za,$a,ab,bb,mb,nb,ob,Db,Eb,Gb,Hb,Ib,Jb],Ma,L;
|
| 49 |
+
(async function(){function a(d,e){L=d.exports;L=Ac();Xb.push(L.Da);cc=L.Ea;ma=e;Ga();return L}M++;var b=La();if(f.instantiateWasm)return new Promise(d=>{f.instantiateWasm(b,(e,g)=>{a(e,g);d(e.exports)})});if(m)return new Promise(d=>{wa=e=>{var g=new WebAssembly.Instance(e,La());d(a(g,e))}});Ha??=f.locateFile?f.locateFile?f.locateFile("ort-wasm-simd-threaded.wasm",r):r+"ort-wasm-simd-threaded.wasm":(new URL("ort-wasm-simd-threaded.wasm",import.meta.url)).href;try{var c=await Ka(b);return a(c.instance,
|
| 50 |
+
c.module)}catch(d){return ba(d),Promise.reject(d)}})();f._OrtInit=(a,b)=>(f._OrtInit=L.aa)(a,b);f._OrtGetLastError=(a,b)=>(f._OrtGetLastError=L.ba)(a,b);f._OrtCreateSessionOptions=(a,b,c,d,e,g,h,p,v,P)=>(f._OrtCreateSessionOptions=L.ca)(a,b,c,d,e,g,h,p,v,P);f._OrtAppendExecutionProvider=(a,b,c,d,e)=>(f._OrtAppendExecutionProvider=L.da)(a,b,c,d,e);f._OrtAddFreeDimensionOverride=(a,b,c)=>(f._OrtAddFreeDimensionOverride=L.ea)(a,b,c);
|
| 51 |
+
f._OrtAddSessionConfigEntry=(a,b,c)=>(f._OrtAddSessionConfigEntry=L.fa)(a,b,c);f._OrtReleaseSessionOptions=a=>(f._OrtReleaseSessionOptions=L.ga)(a);f._OrtCreateSession=(a,b,c)=>(f._OrtCreateSession=L.ha)(a,b,c);f._OrtReleaseSession=a=>(f._OrtReleaseSession=L.ia)(a);f._OrtGetInputOutputCount=(a,b,c)=>(f._OrtGetInputOutputCount=L.ja)(a,b,c);f._OrtGetInputOutputMetadata=(a,b,c,d)=>(f._OrtGetInputOutputMetadata=L.ka)(a,b,c,d);f._OrtFree=a=>(f._OrtFree=L.la)(a);
|
| 52 |
+
f._OrtCreateTensor=(a,b,c,d,e,g)=>(f._OrtCreateTensor=L.ma)(a,b,c,d,e,g);f._OrtGetTensorData=(a,b,c,d,e)=>(f._OrtGetTensorData=L.na)(a,b,c,d,e);f._OrtReleaseTensor=a=>(f._OrtReleaseTensor=L.oa)(a);f._OrtCreateRunOptions=(a,b,c,d)=>(f._OrtCreateRunOptions=L.pa)(a,b,c,d);f._OrtAddRunConfigEntry=(a,b,c)=>(f._OrtAddRunConfigEntry=L.qa)(a,b,c);f._OrtReleaseRunOptions=a=>(f._OrtReleaseRunOptions=L.ra)(a);f._OrtCreateBinding=a=>(f._OrtCreateBinding=L.sa)(a);
|
| 53 |
+
f._OrtBindInput=(a,b,c)=>(f._OrtBindInput=L.ta)(a,b,c);f._OrtBindOutput=(a,b,c,d)=>(f._OrtBindOutput=L.ua)(a,b,c,d);f._OrtClearBoundOutputs=a=>(f._OrtClearBoundOutputs=L.va)(a);f._OrtReleaseBinding=a=>(f._OrtReleaseBinding=L.wa)(a);f._OrtRunWithBinding=(a,b,c,d,e)=>(f._OrtRunWithBinding=L.xa)(a,b,c,d,e);f._OrtRun=(a,b,c,d,e,g,h,p)=>(f._OrtRun=L.ya)(a,b,c,d,e,g,h,p);f._OrtEndProfiling=a=>(f._OrtEndProfiling=L.za)(a);var J=()=>(J=L.Aa)();f._free=a=>(f._free=L.Ba)(a);f._malloc=a=>(f._malloc=L.Ca)(a);
|
| 54 |
+
var Aa=(a,b,c,d,e,g)=>(Aa=L.Fa)(a,b,c,d,e,g),Ea=()=>(Ea=L.Ga)(),Vb=(a,b,c,d,e)=>(Vb=L.Ha)(a,b,c,d,e),$b=a=>($b=L.Ia)(a),dc=a=>(dc=L.Ja)(a),rc=(a,b)=>(rc=L.Ka)(a,b),lc=()=>(lc=L.La)(),bc=(a,b)=>(bc=L.Ma)(a,b),U=a=>(U=L.Na)(a),Ub=a=>(Ub=L.Oa)(a),Tb=()=>(Tb=L.Pa)();function Ac(){var a=L;a=Object.assign({},a);var b=d=>()=>d()>>>0,c=d=>e=>d(e)>>>0;a.Aa=b(a.Aa);a.Ca=c(a.Ca);a.Oa=c(a.Oa);a.Pa=b(a.Pa);a.__cxa_get_exception_ptr=c(a.__cxa_get_exception_ptr);return a}f.stackSave=()=>Tb();f.stackRestore=a=>U(a);
|
| 55 |
+
f.stackAlloc=a=>Ub(a);f.setValue=function(a,b,c="i8"){c.endsWith("*")&&(c="*");switch(c){case "i1":D()[a>>>0]=b;break;case "i8":D()[a>>>0]=b;break;case "i16":ta()[a>>>1>>>0]=b;break;case "i32":G()[a>>>2>>>0]=b;break;case "i64":C[a>>>3]=BigInt(b);break;case "float":va()[a>>>2>>>0]=b;break;case "double":I()[a>>>3>>>0]=b;break;case "*":H()[a>>>2>>>0]=b;break;default:O(`invalid type for setValue: ${c}`)}};
|
| 56 |
+
f.getValue=function(a,b="i8"){b.endsWith("*")&&(b="*");switch(b){case "i1":return D()[a>>>0];case "i8":return D()[a>>>0];case "i16":return ta()[a>>>1>>>0];case "i32":return G()[a>>>2>>>0];case "i64":return C[a>>>3];case "float":return va()[a>>>2>>>0];case "double":return I()[a>>>3>>>0];case "*":return H()[a>>>2>>>0];default:O(`invalid type for getValue: ${b}`)}};f.UTF8ToString=Lb;f.stringToUTF8=X;
|
| 57 |
+
f.lengthBytesUTF8=a=>{for(var b=0,c=0;c<a.length;++c){var d=a.charCodeAt(c);127>=d?b++:2047>=d?b+=2:55296<=d&&57343>=d?(b+=4,++c):b+=3}return b};function Bc(){if(0<M)N=Bc;else if(m)aa(f),Fa();else{for(;0<Pb.length;)Pb.shift()(f);0<M?N=Bc:(f.calledRun=!0,z||(Fa(),aa(f)))}}Bc();f.PTR_SIZE=4;moduleRtn=ca;
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
return moduleRtn;
|
| 61 |
+
}
|
| 62 |
+
);
|
| 63 |
+
})();
|
| 64 |
+
export default ortWasmThreaded;
|
| 65 |
+
var isPthread = globalThis.self?.name?.startsWith('em-pthread');
|
| 66 |
+
var isNode = typeof globalThis.process?.versions?.node == 'string';
|
| 67 |
+
if (isNode) isPthread = (await import('worker_threads')).workerData === 'em-pthread';
|
| 68 |
+
|
| 69 |
+
// When running as a pthread, construct a new instance on startup
|
| 70 |
+
isPthread && ortWasmThreaded();
|
src/vendor/onnxruntime-web/ort-wasm-simd-threaded.wasm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f061472c6e77d6d50d079aacdc0ff9b63fee287ddd2cbf46cf62438d3891de2b
|
| 3 |
+
size 11133407
|
src/vendor/onnxruntime-web/ort.min.mjs
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/vendor/pocket-tts/CODE-LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Apache License
|
| 2 |
+
Version 2.0, January 2004
|
| 3 |
+
http://www.apache.org/licenses/
|
| 4 |
+
|
| 5 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 6 |
+
|
| 7 |
+
1. Definitions.
|
| 8 |
+
|
| 9 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 10 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 11 |
+
|
| 12 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 13 |
+
the copyright owner that is granting the License.
|
| 14 |
+
|
| 15 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 16 |
+
other entities that control, are controlled by, or are under common
|
| 17 |
+
control with that entity. For the purposes of this definition,
|
| 18 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 19 |
+
direction or management of such entity, whether by contract or
|
| 20 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 21 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 22 |
+
|
| 23 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 24 |
+
exercising permissions granted by this License.
|
| 25 |
+
|
| 26 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 27 |
+
including but not limited to software source code, documentation
|
| 28 |
+
source, and configuration files.
|
| 29 |
+
|
| 30 |
+
"Object" form shall mean any form resulting from mechanical
|
| 31 |
+
transformation or translation of a Source form, including but
|
| 32 |
+
not limited to compiled object code, generated documentation,
|
| 33 |
+
and conversions to other media types.
|
| 34 |
+
|
| 35 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 36 |
+
Object form, made available under the License, as indicated by a
|
| 37 |
+
copyright notice that is included in or attached to the work
|
| 38 |
+
(an example is provided in the Appendix below).
|
| 39 |
+
|
| 40 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 41 |
+
form, that is based on (or derived from) the Work and for which the
|
| 42 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 43 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 44 |
+
of this License, Derivative Works shall not include works that remain
|
| 45 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 46 |
+
the Work and Derivative Works thereof.
|
| 47 |
+
|
| 48 |
+
"Contribution" shall mean any work of authorship, including
|
| 49 |
+
the original version of the Work and any modifications or additions
|
| 50 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 51 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 52 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 53 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 54 |
+
means any form of electronic, verbal, or written communication sent
|
| 55 |
+
to the Licensor or its representatives, including but not limited to
|
| 56 |
+
communication on electronic mailing lists, source code control systems,
|
| 57 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 58 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 59 |
+
excluding communication that is conspicuously marked or otherwise
|
| 60 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 61 |
+
|
| 62 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 63 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 64 |
+
subsequently incorporated within the Work.
|
| 65 |
+
|
| 66 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 67 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 68 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 69 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 70 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 71 |
+
Work and such Derivative Works in Source or Object form.
|
| 72 |
+
|
| 73 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 74 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 75 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 76 |
+
(except as stated in this section) patent license to make, have made,
|
| 77 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 78 |
+
where such license applies only to those patent claims licensable
|
| 79 |
+
by such Contributor that are necessarily infringed by their
|
| 80 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 81 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 82 |
+
institute patent litigation against any entity (including a
|
| 83 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 84 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 85 |
+
or contributory patent infringement, then any patent licenses
|
| 86 |
+
granted to You under this License for that Work shall terminate
|
| 87 |
+
as of the date such litigation is filed.
|
| 88 |
+
|
| 89 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 90 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 91 |
+
modifications, and in Source or Object form, provided that You
|
| 92 |
+
meet the following conditions:
|
| 93 |
+
|
| 94 |
+
(a) You must give any other recipients of the Work or
|
| 95 |
+
Derivative Works a copy of this License; and
|
| 96 |
+
|
| 97 |
+
(b) You must cause any modified files to carry prominent notices
|
| 98 |
+
stating that You changed the files; and
|
| 99 |
+
|
| 100 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 101 |
+
that You distribute, all copyright, patent, trademark, and
|
| 102 |
+
attribution notices from the Source form of the Work,
|
| 103 |
+
excluding those notices that do not pertain to any part of
|
| 104 |
+
the Derivative Works; and
|
| 105 |
+
|
| 106 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 107 |
+
distribution, then any Derivative Works that You distribute must
|
| 108 |
+
include a readable copy of the attribution notices contained
|
| 109 |
+
within such NOTICE file, excluding those notices that do not
|
| 110 |
+
pertain to any part of the Derivative Works, in at least one
|
| 111 |
+
of the following places: within a NOTICE text file distributed
|
| 112 |
+
as part of the Derivative Works; within the Source form or
|
| 113 |
+
documentation, if provided along with the Derivative Works; or,
|
| 114 |
+
within a display generated by the Derivative Works, if and
|
| 115 |
+
wherever such third-party notices normally appear. The contents
|
| 116 |
+
of the NOTICE file are for informational purposes only and
|
| 117 |
+
do not modify the License. You may add Your own attribution
|
| 118 |
+
notices within Derivative Works that You distribute, alongside
|
| 119 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 120 |
+
that such additional attribution notices cannot be construed
|
| 121 |
+
as modifying the License.
|
| 122 |
+
|
| 123 |
+
You may add Your own copyright statement to Your modifications and
|
| 124 |
+
may provide additional or different license terms and conditions
|
| 125 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 126 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 127 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 128 |
+
the conditions stated in this License.
|
| 129 |
+
|
| 130 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 131 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 132 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 133 |
+
this License, without any additional terms or conditions.
|
| 134 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 135 |
+
the terms of any separate license agreement you may have executed
|
| 136 |
+
with Licensor regarding such Contributions.
|
| 137 |
+
|
| 138 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 139 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 140 |
+
except as required for reasonable and customary use in describing the
|
| 141 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 142 |
+
|
| 143 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 144 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 145 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 146 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 147 |
+
implied, including, without limitation, any warranties or conditions
|
| 148 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 149 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 150 |
+
appropriateness of using or redistributing the Work and assume any
|
| 151 |
+
risks associated with Your exercise of permissions under this License.
|
| 152 |
+
|
| 153 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 154 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 155 |
+
unless required by applicable law (such as deliberate and grossly
|
| 156 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 157 |
+
liable to You for damages, including any direct, indirect, special,
|
| 158 |
+
incidental, or consequential damages of any character arising as a
|
| 159 |
+
result of this License or out of the use or inability to use the
|
| 160 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 161 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 162 |
+
other commercial damages or losses), even if such Contributor
|
| 163 |
+
has been advised of the possibility of such damages.
|
| 164 |
+
|
| 165 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 166 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 167 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 168 |
+
or other liability obligations and/or rights consistent with this
|
| 169 |
+
License. However, in accepting such obligations, You may act only
|
| 170 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 171 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 172 |
+
defend, and hold each Contributor harmless for any liability
|
| 173 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 174 |
+
of your accepting any such warranty or additional liability.
|
| 175 |
+
|
| 176 |
+
END OF TERMS AND CONDITIONS
|
| 177 |
+
|
| 178 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 179 |
+
|
| 180 |
+
To apply the Apache License to your work, attach the following
|
| 181 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
| 182 |
+
replaced with your own identifying information. (Don't include
|
| 183 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 184 |
+
comment syntax for the file format. We also recommend that a
|
| 185 |
+
file or class name and description of purpose be included on the
|
| 186 |
+
same "printed page" as the copyright notice for easier
|
| 187 |
+
identification within third-party archives.
|
| 188 |
+
|
| 189 |
+
Copyright [yyyy] [name of copyright owner]
|
| 190 |
+
|
| 191 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 192 |
+
you may not use this file except in compliance with the License.
|
| 193 |
+
You may obtain a copy of the License at
|
| 194 |
+
|
| 195 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 196 |
+
|
| 197 |
+
Unless required by applicable law or agreed to in writing, software
|
| 198 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 199 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 200 |
+
See the License for the specific language governing permissions and
|
| 201 |
+
limitations under the License.
|
src/vendor/pocket-tts/inference-worker.js
ADDED
|
@@ -0,0 +1,1273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Derived from KevinAHM/pocket-tts-web (Apache-2.0).
|
| 2 |
+
// Modified locally to fetch model assets from Hugging Face and resolve the
|
| 3 |
+
// bundled SentencePiece module through the worker asset graph.
|
| 4 |
+
|
| 5 |
+
const DEBUG_LOGS = false;
|
| 6 |
+
const STARTUP_LOGS = true;
|
| 7 |
+
|
| 8 |
+
const startupLog = (...args) => {
|
| 9 |
+
if (STARTUP_LOGS) {
|
| 10 |
+
console.info('[PocketWorker]', ...args);
|
| 11 |
+
}
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
const startupError = (...args) => {
|
| 15 |
+
console.error('[PocketWorker]', ...args);
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
// Pocket TTS ONNX Web Worker
|
| 19 |
+
startupLog('worker starting');
|
| 20 |
+
self.addEventListener('error', (event) => {
|
| 21 |
+
startupError('unhandled error event', {
|
| 22 |
+
message: event.message,
|
| 23 |
+
filename: event.filename,
|
| 24 |
+
lineno: event.lineno,
|
| 25 |
+
colno: event.colno
|
| 26 |
+
});
|
| 27 |
+
});
|
| 28 |
+
self.addEventListener('unhandledrejection', (event) => {
|
| 29 |
+
startupError('unhandled rejection', event.reason);
|
| 30 |
+
});
|
| 31 |
+
self.postMessage({ type: 'status', status: 'Worker Thread Started', state: 'idle' });
|
| 32 |
+
|
| 33 |
+
// Load ONNX Runtime (will be loaded dynamically in loadModels for module worker)
|
| 34 |
+
let ort = null;
|
| 35 |
+
|
| 36 |
+
// Configuration
|
| 37 |
+
const DEFAULT_MODEL_BASE_URL = new URL('./', import.meta.url).toString().replace(/\/+$/, '');
|
| 38 |
+
const SENTENCEPIECE_MODULE_URL = new URL('./sentencepiece.js', import.meta.url).toString();
|
| 39 |
+
const LOCAL_ORT_BASE_URL = new URL('../onnxruntime-web/', import.meta.url).toString();
|
| 40 |
+
const LOCAL_ORT_MODULE_URL = new URL('../onnxruntime-web/ort.min.mjs', import.meta.url).toString();
|
| 41 |
+
|
| 42 |
+
const normalizeBaseUrl = (value) => value.replace(/\/+$/, '');
|
| 43 |
+
const createModelConfig = (baseUrl) => {
|
| 44 |
+
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
| 45 |
+
return {
|
| 46 |
+
mimi_encoder: `${normalizedBaseUrl}/onnx/mimi_encoder.onnx`,
|
| 47 |
+
text_conditioner: `${normalizedBaseUrl}/onnx/text_conditioner.onnx`,
|
| 48 |
+
flow_lm_main: `${normalizedBaseUrl}/onnx/flow_lm_main_int8.onnx`,
|
| 49 |
+
flow_lm_flow: `${normalizedBaseUrl}/onnx/flow_lm_flow_int8.onnx`,
|
| 50 |
+
mimi_decoder: `${normalizedBaseUrl}/onnx/mimi_decoder_int8.onnx`,
|
| 51 |
+
tokenizer: `${normalizedBaseUrl}/tokenizer.model`,
|
| 52 |
+
voices: `${normalizedBaseUrl}/voices.bin`
|
| 53 |
+
};
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
let MODELS = createModelConfig(DEFAULT_MODEL_BASE_URL);
|
| 57 |
+
|
| 58 |
+
const SAMPLE_RATE = 24000;
|
| 59 |
+
const SAMPLES_PER_FRAME = 1920;
|
| 60 |
+
const MAX_FRAMES = 500;
|
| 61 |
+
// Text chunking target; lower if long passages hit generation limits.
|
| 62 |
+
const CHUNK_TARGET_TOKENS = 50;
|
| 63 |
+
const CHUNK_GAP_SEC = 0.25;
|
| 64 |
+
// If true, re-run voice conditioning per chunk to avoid stale AR state.
|
| 65 |
+
const RESET_FLOW_STATE_EACH_CHUNK = true;
|
| 66 |
+
// If true, reset decoder state per chunk to avoid carry-over artifacts.
|
| 67 |
+
const RESET_MIMI_STATE_EACH_CHUNK = true;
|
| 68 |
+
|
| 69 |
+
// State
|
| 70 |
+
let mimiEncoderSession = null;
|
| 71 |
+
let textConditionerSession = null;
|
| 72 |
+
let flowLmMainSession = null;
|
| 73 |
+
let flowLmFlowSession = null;
|
| 74 |
+
let mimiDecoderSession = null;
|
| 75 |
+
let tokenizerProcessor = null;
|
| 76 |
+
let tokenizerModelB64 = null;
|
| 77 |
+
let predefinedVoices = {};
|
| 78 |
+
let stTensors = []; // Optimization: Pre-allocated s/t tensors for max LSD
|
| 79 |
+
let isGenerating = false;
|
| 80 |
+
let isReady = false;
|
| 81 |
+
let workerLoadConfig = { threadCount: 1 };
|
| 82 |
+
|
| 83 |
+
// Dynamic LSD (Latent Solver/Diffusion steps)
|
| 84 |
+
const MAX_LSD = 10; // Default/max quality
|
| 85 |
+
let currentLSD = MAX_LSD;
|
| 86 |
+
|
| 87 |
+
// Current voice embedding (cached)
|
| 88 |
+
let currentVoiceEmbedding = null;
|
| 89 |
+
let currentVoiceName = null;
|
| 90 |
+
let voiceConditioningCache = new Map();
|
| 91 |
+
|
| 92 |
+
// Text preprocessing utilities
|
| 93 |
+
const ONES = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'];
|
| 94 |
+
const TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
|
| 95 |
+
const ORDINAL_ONES = ['', 'first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth', 'eleventh', 'twelfth', 'thirteenth', 'fourteenth', 'fifteenth', 'sixteenth', 'seventeenth', 'eighteenth', 'nineteenth'];
|
| 96 |
+
const ORDINAL_TENS = ['', '', 'twentieth', 'thirtieth', 'fortieth', 'fiftieth', 'sixtieth', 'seventieth', 'eightieth', 'ninetieth'];
|
| 97 |
+
|
| 98 |
+
function numberToWords(num, options = {}) {
|
| 99 |
+
const { andword = '', zero = 'zero', group = 0 } = options;
|
| 100 |
+
if (num === 0) return zero;
|
| 101 |
+
const convert = (n) => {
|
| 102 |
+
if (n < 20) return ONES[n];
|
| 103 |
+
if (n < 100) return TENS[Math.floor(n / 10)] + (n % 10 ? ' ' + ONES[n % 10] : '');
|
| 104 |
+
if (n < 1000) {
|
| 105 |
+
const remainder = n % 100;
|
| 106 |
+
return ONES[Math.floor(n / 100)] + ' hundred' + (remainder ? (andword ? ' ' + andword + ' ' : ' ') + convert(remainder) : '');
|
| 107 |
+
}
|
| 108 |
+
if (n < 1000000) {
|
| 109 |
+
const thousands = Math.floor(n / 1000);
|
| 110 |
+
const remainder = n % 1000;
|
| 111 |
+
return convert(thousands) + ' thousand' + (remainder ? ' ' + convert(remainder) : '');
|
| 112 |
+
}
|
| 113 |
+
if (n < 1000000000) {
|
| 114 |
+
const millions = Math.floor(n / 1000000);
|
| 115 |
+
const remainder = n % 1000000;
|
| 116 |
+
return convert(millions) + ' million' + (remainder ? ' ' + convert(remainder) : '');
|
| 117 |
+
}
|
| 118 |
+
const billions = Math.floor(n / 1000000000);
|
| 119 |
+
const remainder = n % 1000000000;
|
| 120 |
+
return convert(billions) + ' billion' + (remainder ? ' ' + convert(remainder) : '');
|
| 121 |
+
};
|
| 122 |
+
if (group === 2 && num > 1000 && num < 10000) {
|
| 123 |
+
const high = Math.floor(num / 100);
|
| 124 |
+
const low = num % 100;
|
| 125 |
+
if (low === 0) return convert(high) + ' hundred';
|
| 126 |
+
else if (low < 10) return convert(high) + ' ' + (zero === 'oh' ? 'oh' : zero) + ' ' + ONES[low];
|
| 127 |
+
else return convert(high) + ' ' + convert(low);
|
| 128 |
+
}
|
| 129 |
+
return convert(num);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function ordinalToWords(num) {
|
| 133 |
+
if (num < 20) return ORDINAL_ONES[num] || numberToWords(num) + 'th';
|
| 134 |
+
if (num < 100) {
|
| 135 |
+
const tens = Math.floor(num / 10);
|
| 136 |
+
const ones = num % 10;
|
| 137 |
+
if (ones === 0) return ORDINAL_TENS[tens];
|
| 138 |
+
return TENS[tens] + ' ' + ORDINAL_ONES[ones];
|
| 139 |
+
}
|
| 140 |
+
const cardinal = numberToWords(num);
|
| 141 |
+
if (cardinal.endsWith('y')) return cardinal.slice(0, -1) + 'ieth';
|
| 142 |
+
if (cardinal.endsWith('one')) return cardinal.slice(0, -3) + 'first';
|
| 143 |
+
if (cardinal.endsWith('two')) return cardinal.slice(0, -3) + 'second';
|
| 144 |
+
if (cardinal.endsWith('three')) return cardinal.slice(0, -5) + 'third';
|
| 145 |
+
if (cardinal.endsWith('ve')) return cardinal.slice(0, -2) + 'fth';
|
| 146 |
+
if (cardinal.endsWith('e')) return cardinal.slice(0, -1) + 'th';
|
| 147 |
+
if (cardinal.endsWith('t')) return cardinal + 'h';
|
| 148 |
+
return cardinal + 'th';
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
const UNICODE_MAP = {
|
| 152 |
+
'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'ä': 'a', 'å': 'a', 'æ': 'ae', 'ç': 'c', 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'ñ': 'n', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ö': 'o', 'ø': 'o', 'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', 'ý': 'y', 'ÿ': 'y', 'ß': 'ss', 'œ': 'oe', 'ð': 'd', 'þ': 'th', 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Ä': 'A', 'Å': 'A', 'Æ': 'AE', 'Ç': 'C', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'Ì': 'I', 'Í': 'I', 'Î': 'I', 'Ï': 'I', 'Ñ': 'N', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', 'Õ': 'O', 'Ö': 'O', 'Ø': 'O', 'Ù': 'U', 'Ú': 'U', 'Û': 'U', 'Ü': 'U', 'Ý': 'Y', '\u201C': '"', '\u201D': '"', '\u2018': "'", '\u2019': "'", '\u2026': '...', '\u2013': '-', '\u2014': '-'
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
function convertToAscii(text) {
|
| 156 |
+
return text.split('').map(c => UNICODE_MAP[c] || c).join('').normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
const ABBREVIATIONS = [
|
| 160 |
+
[/\bmrs\./gi, 'misuss'], [/\bms\./gi, 'miss'], [/\bmr\./gi, 'mister'], [/\bdr\./gi, 'doctor'], [/\bst\./gi, 'saint'], [/\bco\./gi, 'company'], [/\bjr\./gi, 'junior'], [/\bmaj\./gi, 'major'], [/\bgen\./gi, 'general'], [/\bdrs\./gi, 'doctors'], [/\brev\./gi, 'reverend'], [/\blt\./gi, 'lieutenant'], [/\bhon\./gi, 'honorable'], [/\bsgt\./gi, 'sergeant'], [/\bcapt\./gi, 'captain'], [/\besq\./gi, 'esquire'], [/\bltd\./gi, 'limited'], [/\bcol\./gi, 'colonel'], [/\bft\./gi, 'fort']
|
| 161 |
+
];
|
| 162 |
+
const CASED_ABBREVIATIONS = [
|
| 163 |
+
[/\bTTS\b/g, 'text to speech'], [/\bHz\b/g, 'hertz'], [/\bkHz\b/g, 'kilohertz'], [/\bKBs\b/g, 'kilobytes'], [/\bKB\b/g, 'kilobyte'], [/\bMBs\b/g, 'megabytes'], [/\bMB\b/g, 'megabyte'], [/\bGBs\b/g, 'gigabytes'], [/\bGB\b/g, 'gigabyte'], [/\bTBs\b/g, 'terabytes'], [/\bTB\b/g, 'terabyte'], [/\bAPIs\b/g, "a p i's"], [/\bAPI\b/g, 'a p i'], [/\bCLIs\b/g, "c l i's"], [/\bCLI\b/g, 'c l i'], [/\bCPUs\b/g, "c p u's"], [/\bCPU\b/g, 'c p u'], [/\bGPUs\b/g, "g p u's"], [/\bGPU\b/g, 'g p u'], [/\bAve\b/g, 'avenue'], [/\betc\b/g, 'etcetera']
|
| 164 |
+
];
|
| 165 |
+
|
| 166 |
+
function expandAbbreviations(text) {
|
| 167 |
+
for (const [regex, replacement] of [...ABBREVIATIONS, ...CASED_ABBREVIATIONS]) text = text.replace(regex, replacement);
|
| 168 |
+
return text;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
const NUM_PREFIX_RE = /#(\d)/g;
|
| 172 |
+
const NUM_SUFFIX_RE = /(\d)([KMBT])/gi;
|
| 173 |
+
const NUM_LETTER_SPLIT_RE = /(\d)([a-z])|([a-z])(\d)/gi;
|
| 174 |
+
const COMMA_NUMBER_RE = /(\d[\d,]+\d)/g;
|
| 175 |
+
const DATE_RE = /(^|[^/])(\d\d?[/-]\d\d?[/-]\d\d(?:\d\d)?)($|[^/])/g;
|
| 176 |
+
const PHONE_NUMBER_RE = /\(?\d{3}\)?[-.\s]\d{3}[-.\s]?\d{4}/g;
|
| 177 |
+
const TIME_RE = /(\d\d?):(\d\d)(?::(\d\d))?/g;
|
| 178 |
+
const POUNDS_RE = /£([\d,]*\d+)/g;
|
| 179 |
+
const DOLLARS_RE = /\$([\d.,]*\d+)/g;
|
| 180 |
+
const DECIMAL_NUMBER_RE = /(\d+(?:\.\d+)+)/g;
|
| 181 |
+
const MULTIPLY_RE = /(\d)\s?\*\s?(\d)/g;
|
| 182 |
+
const DIVIDE_RE = /(\d)\s?\/\s?(\d)/g;
|
| 183 |
+
const ADD_RE = /(\d)\s?\+\s?(\d)/g;
|
| 184 |
+
const SUBTRACT_RE = /(\d)?\s?-\s?(\d)/g;
|
| 185 |
+
const FRACTION_RE = /(\d+)\/(\d+)/g;
|
| 186 |
+
const ORDINAL_RE = /(\d+)(st|nd|rd|th)/gi;
|
| 187 |
+
const NUMBER_RE = /\d+/g;
|
| 188 |
+
|
| 189 |
+
function normalizeNumbers(text) {
|
| 190 |
+
text = text.replace(NUM_PREFIX_RE, (_, d) => `number ${d}`);
|
| 191 |
+
text = text.replace(NUM_SUFFIX_RE, (_, num, suffix) => {
|
| 192 |
+
const map = { k: 'thousand', m: 'million', b: 'billion', t: 'trillion' };
|
| 193 |
+
return `${num} ${map[suffix.toLowerCase()]}`;
|
| 194 |
+
});
|
| 195 |
+
for (let i = 0; i < 2; i++) {
|
| 196 |
+
text = text.replace(NUM_LETTER_SPLIT_RE, (m, d1, l1, l2, d2) => {
|
| 197 |
+
if (d1 && l1) return `${d1} ${l1}`;
|
| 198 |
+
if (l2 && d2) return `${l2} ${d2}`;
|
| 199 |
+
return m;
|
| 200 |
+
});
|
| 201 |
+
}
|
| 202 |
+
text = text.replace(COMMA_NUMBER_RE, m => m.replace(/,/g, ''));
|
| 203 |
+
text = text.replace(DATE_RE, (_, pre, date, post) => pre + date.split(/[./-]/).join(' dash ') + post);
|
| 204 |
+
text = text.replace(PHONE_NUMBER_RE, m => {
|
| 205 |
+
const digits = m.replace(/\D/g, '');
|
| 206 |
+
return digits.length === 10 ? `${digits.slice(0, 3).split('').join(' ')}, ${digits.slice(3, 6).split('').join(' ')}, ${digits.slice(6).split('').join(' ')}` : m;
|
| 207 |
+
});
|
| 208 |
+
text = text.replace(TIME_RE, (_, hours, minutes, seconds) => {
|
| 209 |
+
const h = parseInt(hours), m = parseInt(minutes), s = seconds ? parseInt(seconds) : 0;
|
| 210 |
+
if (!seconds) return m === 0 ? (h === 0 ? '0' : h > 12 ? `${hours} minutes` : `${hours} o'clock`) : minutes.startsWith('0') ? `${hours} oh ${minutes[1]}` : `${hours} ${minutes}`;
|
| 211 |
+
let res = '';
|
| 212 |
+
if (h !== 0) res = hours + ' ' + (m === 0 ? 'oh oh' : minutes.startsWith('0') ? `oh ${minutes[1]}` : minutes);
|
| 213 |
+
else if (m !== 0) res = minutes + ' ' + (s === 0 ? 'oh oh' : seconds.startsWith('0') ? `oh ${seconds[1]}` : seconds);
|
| 214 |
+
else res = seconds;
|
| 215 |
+
return res + ' ' + (s === 0 ? '' : seconds.startsWith('0') ? `oh ${seconds[1]}` : seconds);
|
| 216 |
+
});
|
| 217 |
+
text = text.replace(POUNDS_RE, (_, amount) => `${amount.replace(/,/g, '')} pounds`);
|
| 218 |
+
text = text.replace(DOLLARS_RE, (_, amount) => {
|
| 219 |
+
const parts = amount.replace(/,/g, '').split('.');
|
| 220 |
+
const dollars = parseInt(parts[0]) || 0;
|
| 221 |
+
const cents = parts[1] ? parseInt(parts[1]) : 0;
|
| 222 |
+
if (dollars && cents) return `${dollars} ${dollars === 1 ? 'dollar' : 'dollars'}, ${cents} ${cents === 1 ? 'cent' : 'cents'}`;
|
| 223 |
+
if (dollars) return `${dollars} ${dollars === 1 ? 'dollar' : 'dollars'}`;
|
| 224 |
+
if (cents) return `${cents} ${cents === 1 ? 'cent' : 'cents'}`;
|
| 225 |
+
return 'zero dollars';
|
| 226 |
+
});
|
| 227 |
+
text = text.replace(DECIMAL_NUMBER_RE, m => m.split('.').join(' point ').split('').join(' '));
|
| 228 |
+
text = text.replace(MULTIPLY_RE, '$1 times $2');
|
| 229 |
+
text = text.replace(DIVIDE_RE, '$1 over $2');
|
| 230 |
+
text = text.replace(ADD_RE, '$1 plus $2');
|
| 231 |
+
text = text.replace(SUBTRACT_RE, (_, a, b) => (a ? a : '') + ' minus ' + b);
|
| 232 |
+
text = text.replace(FRACTION_RE, '$1 over $2');
|
| 233 |
+
text = text.replace(ORDINAL_RE, (_, num) => ordinalToWords(parseInt(num)));
|
| 234 |
+
text = text.replace(NUMBER_RE, m => {
|
| 235 |
+
const num = parseInt(m);
|
| 236 |
+
if (num > 1000 && num < 3000) {
|
| 237 |
+
if (num === 2000) return 'two thousand';
|
| 238 |
+
if (num > 2000 && num < 2010) return 'two thousand ' + numberToWords(num % 100);
|
| 239 |
+
if (num % 100 === 0) return numberToWords(Math.floor(num / 100)) + ' hundred';
|
| 240 |
+
return numberToWords(num, { zero: 'oh', group: 2 });
|
| 241 |
+
}
|
| 242 |
+
return numberToWords(num);
|
| 243 |
+
});
|
| 244 |
+
return text;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
const SPECIAL_CHARACTERS = [
|
| 248 |
+
[/@/g, ' at '], [/&/g, ' and '], [/%/g, ' percent '], [/:/g, '.'], [/;/g, ','], [/\+/g, ' plus '], [/\\/g, ' backslash '], [/~/g, ' about '], [/(^| )<3/g, ' heart '], [/<=/g, ' less than or equal to '], [/>=/g, ' greater than or equal to '], [/</g, ' less than '], [/>/g, ' greater than '], [/=/g, ' equals '], [/\//g, ' slash '], [/_/g, ' '],
|
| 249 |
+
];
|
| 250 |
+
const LINK_HEADER_RE = /https?:\/\//gi;
|
| 251 |
+
const DASH_RE = /(.) - (.)/g;
|
| 252 |
+
const DOT_RE = /([A-Z])\.([A-Z])/gi;
|
| 253 |
+
const PARENTHESES_RE = /[\(\[\{][^\)\]\}]*[\)\]\}](.)?/g;
|
| 254 |
+
|
| 255 |
+
function normalizeSpecial(text) {
|
| 256 |
+
text = text.replace(LINK_HEADER_RE, 'h t t p s colon slash slash ');
|
| 257 |
+
text = text.replace(DASH_RE, '$1, $2');
|
| 258 |
+
text = text.replace(DOT_RE, '$1 dot $2');
|
| 259 |
+
text = text.replace(PARENTHESES_RE, (m, after) => {
|
| 260 |
+
let result = m.replace(/[\(\[\{]/g, ', ').replace(/[\)\]\}]/g, ', ');
|
| 261 |
+
if (after && /[$.!?,]/.test(after)) result = result.slice(0, -2) + after;
|
| 262 |
+
return result;
|
| 263 |
+
});
|
| 264 |
+
return text;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
function expandSpecialCharacters(text) {
|
| 268 |
+
for (const [regex, replacement] of SPECIAL_CHARACTERS) text = text.replace(regex, replacement);
|
| 269 |
+
return text;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
function collapseWhitespace(text) {
|
| 273 |
+
return text.replace(/\s+/g, ' ').replace(/ ([.\?!,])/g, '$1');
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
function dedupPunctuation(text) {
|
| 277 |
+
return text.replace(/\.\.\.+/g, '[ELLIPSIS]').replace(/,+/g, ',').replace(/[.,]*\.[.,]*/g, '.').replace(/[.,!]*![.,!]*/g, '!').replace(/[.,!?]*\?[.,!?]*/g, '?').replace(/\[ELLIPSIS\]/g, '...');
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
const SENTENCE_SPLIT_RE = /[^.!?]+[.!?]+|[^.!?]+$/g;
|
| 281 |
+
|
| 282 |
+
function splitTextIntoSentences(text) {
|
| 283 |
+
const matches = text.match(SENTENCE_SPLIT_RE);
|
| 284 |
+
if (!matches) return [];
|
| 285 |
+
return matches.map(sentence => sentence.trim()).filter(Boolean);
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
function splitTokenIdsIntoChunks(tokenIds, maxTokens) {
|
| 289 |
+
const chunks = [];
|
| 290 |
+
for (let i = 0; i < tokenIds.length; i += maxTokens) {
|
| 291 |
+
const chunkText = tokenizerProcessor.decodeIds(tokenIds.slice(i, i + maxTokens)).trim();
|
| 292 |
+
if (chunkText) chunks.push(chunkText);
|
| 293 |
+
}
|
| 294 |
+
return chunks;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
// Split text into sentence chunks (target <= CHUNK_TARGET_TOKENS tokens)
|
| 298 |
+
function splitIntoBestSentences(text) {
|
| 299 |
+
const preparedText = prepareText(text);
|
| 300 |
+
if (!preparedText) return [];
|
| 301 |
+
|
| 302 |
+
const sentences = splitTextIntoSentences(preparedText);
|
| 303 |
+
if (sentences.length === 0) return [];
|
| 304 |
+
|
| 305 |
+
// Merge sentences into chunks that stay within the token target
|
| 306 |
+
const chunks = [];
|
| 307 |
+
let currentChunk = '';
|
| 308 |
+
for (const sentenceText of sentences) {
|
| 309 |
+
const sentenceTokenIds = tokenizerProcessor.encodeIds(sentenceText);
|
| 310 |
+
const sentenceTokens = sentenceTokenIds.length;
|
| 311 |
+
|
| 312 |
+
if (sentenceTokens > CHUNK_TARGET_TOKENS) {
|
| 313 |
+
if (currentChunk !== '') {
|
| 314 |
+
chunks.push(currentChunk.trim());
|
| 315 |
+
currentChunk = '';
|
| 316 |
+
}
|
| 317 |
+
const splitChunks = splitTokenIdsIntoChunks(sentenceTokenIds, CHUNK_TARGET_TOKENS);
|
| 318 |
+
for (const splitChunk of splitChunks) {
|
| 319 |
+
if (splitChunk) chunks.push(splitChunk.trim());
|
| 320 |
+
}
|
| 321 |
+
continue;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
if (currentChunk === '') {
|
| 325 |
+
currentChunk = sentenceText;
|
| 326 |
+
continue;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
const combined = `${currentChunk} ${sentenceText}`;
|
| 330 |
+
const combinedTokens = tokenizerProcessor.encodeIds(combined).length;
|
| 331 |
+
if (combinedTokens > CHUNK_TARGET_TOKENS) {
|
| 332 |
+
chunks.push(currentChunk.trim());
|
| 333 |
+
currentChunk = sentenceText;
|
| 334 |
+
} else {
|
| 335 |
+
currentChunk = combined;
|
| 336 |
+
}
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
if (currentChunk !== '') {
|
| 340 |
+
chunks.push(currentChunk.trim());
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
return chunks;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
// Pocket TTS specific text preprocessing
|
| 347 |
+
function prepareText(text) {
|
| 348 |
+
text = text.trim();
|
| 349 |
+
if (!text) return '';
|
| 350 |
+
|
| 351 |
+
// Convert to ASCII
|
| 352 |
+
text = convertToAscii(text);
|
| 353 |
+
|
| 354 |
+
// Normalize numbers first
|
| 355 |
+
text = normalizeNumbers(text);
|
| 356 |
+
|
| 357 |
+
// Normalize special characters
|
| 358 |
+
text = normalizeSpecial(text);
|
| 359 |
+
|
| 360 |
+
// Expand abbreviations
|
| 361 |
+
text = expandAbbreviations(text);
|
| 362 |
+
|
| 363 |
+
// Expand special characters
|
| 364 |
+
text = expandSpecialCharacters(text);
|
| 365 |
+
|
| 366 |
+
// Collapse whitespace
|
| 367 |
+
text = collapseWhitespace(text);
|
| 368 |
+
|
| 369 |
+
// Deduplicate punctuation
|
| 370 |
+
text = dedupPunctuation(text);
|
| 371 |
+
|
| 372 |
+
// Final cleanup
|
| 373 |
+
text = text.trim();
|
| 374 |
+
|
| 375 |
+
// Ensure proper punctuation at end
|
| 376 |
+
if (text && text[text.length - 1].match(/[a-zA-Z0-9]/)) {
|
| 377 |
+
text = text + '.';
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
// Capitalize first letter
|
| 381 |
+
if (text && !text[0].match(/[A-Z]/)) {
|
| 382 |
+
text = text[0].toUpperCase() + text.slice(1);
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
return text;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
// ----------------------------------------------------------------------------
|
| 389 |
+
// Worker Logic
|
| 390 |
+
// ----------------------------------------------------------------------------
|
| 391 |
+
|
| 392 |
+
self.onmessage = async (e) => {
|
| 393 |
+
const { type, data } = e.data;
|
| 394 |
+
startupLog('received message', type);
|
| 395 |
+
|
| 396 |
+
if (type === 'load') {
|
| 397 |
+
try {
|
| 398 |
+
if (data?.modelBaseUrl) {
|
| 399 |
+
MODELS = createModelConfig(data.modelBaseUrl);
|
| 400 |
+
}
|
| 401 |
+
if (data?.threadCount) {
|
| 402 |
+
workerLoadConfig.threadCount = Math.max(1, data.threadCount);
|
| 403 |
+
}
|
| 404 |
+
startupLog('load requested', {
|
| 405 |
+
modelBaseUrl: data?.modelBaseUrl ?? DEFAULT_MODEL_BASE_URL,
|
| 406 |
+
requestedThreadCount: workerLoadConfig.threadCount,
|
| 407 |
+
crossOriginIsolated: self.crossOriginIsolated === true
|
| 408 |
+
});
|
| 409 |
+
await loadModels();
|
| 410 |
+
postMessage({ type: 'loaded' });
|
| 411 |
+
} catch (err) {
|
| 412 |
+
startupError('load failed', err);
|
| 413 |
+
postMessage({ type: 'error', error: err.toString() });
|
| 414 |
+
}
|
| 415 |
+
} else if (type === 'generate') {
|
| 416 |
+
if (!isReady) {
|
| 417 |
+
postMessage({ type: 'error', error: 'Models are not loaded yet.' });
|
| 418 |
+
return;
|
| 419 |
+
}
|
| 420 |
+
if (isGenerating) return;
|
| 421 |
+
try {
|
| 422 |
+
await startGeneration(data.text, data.voice);
|
| 423 |
+
} catch (err) {
|
| 424 |
+
console.error('Generation Error:', err);
|
| 425 |
+
postMessage({ type: 'error', error: err.toString() });
|
| 426 |
+
}
|
| 427 |
+
} else if (type === 'encode_voice') {
|
| 428 |
+
if (!isReady) {
|
| 429 |
+
postMessage({ type: 'error', error: 'Models are not loaded yet.' });
|
| 430 |
+
return;
|
| 431 |
+
}
|
| 432 |
+
if (isGenerating) {
|
| 433 |
+
postMessage({ type: 'error', error: 'Cannot encode a voice while generation is running.' });
|
| 434 |
+
return;
|
| 435 |
+
}
|
| 436 |
+
try {
|
| 437 |
+
const embedding = await encodeVoiceAudio(data.audio);
|
| 438 |
+
currentVoiceEmbedding = embedding;
|
| 439 |
+
currentVoiceName = 'custom';
|
| 440 |
+
await ensureVoiceConditioningCached('custom', embedding, {
|
| 441 |
+
force: true,
|
| 442 |
+
statusText: 'Conditioning custom voice...'
|
| 443 |
+
});
|
| 444 |
+
postMessage({ type: 'voice_encoded', voiceName: 'custom' });
|
| 445 |
+
postMessage({ type: 'status', status: 'Ready', state: 'idle' });
|
| 446 |
+
} catch (err) {
|
| 447 |
+
console.error('Voice encoding error:', err);
|
| 448 |
+
postMessage({ type: 'error', error: 'Failed to encode voice: ' + err.toString() });
|
| 449 |
+
}
|
| 450 |
+
} else if (type === 'set_voice') {
|
| 451 |
+
if (!isReady) {
|
| 452 |
+
postMessage({ type: 'error', error: 'Models are not loaded yet.' });
|
| 453 |
+
return;
|
| 454 |
+
}
|
| 455 |
+
if (isGenerating) {
|
| 456 |
+
postMessage({ type: 'error', error: 'Cannot switch voice while generation is running.' });
|
| 457 |
+
return;
|
| 458 |
+
}
|
| 459 |
+
try {
|
| 460 |
+
if (data.voiceName === 'custom') {
|
| 461 |
+
if (!currentVoiceEmbedding || currentVoiceName !== 'custom') {
|
| 462 |
+
postMessage({ type: 'error', error: 'No custom voice loaded. Upload audio first.' });
|
| 463 |
+
return;
|
| 464 |
+
}
|
| 465 |
+
await ensureVoiceConditioningCached('custom', currentVoiceEmbedding, {
|
| 466 |
+
statusText: 'Conditioning custom voice...'
|
| 467 |
+
});
|
| 468 |
+
postMessage({ type: 'voice_set', voiceName: 'custom' });
|
| 469 |
+
} else if (predefinedVoices[data.voiceName]) {
|
| 470 |
+
currentVoiceEmbedding = predefinedVoices[data.voiceName];
|
| 471 |
+
currentVoiceName = data.voiceName;
|
| 472 |
+
await ensureVoiceConditioningCached(data.voiceName, currentVoiceEmbedding, {
|
| 473 |
+
statusText: `Conditioning voice (${data.voiceName})...`
|
| 474 |
+
});
|
| 475 |
+
postMessage({ type: 'voice_set', voiceName: data.voiceName });
|
| 476 |
+
} else {
|
| 477 |
+
postMessage({ type: 'error', error: `Unknown voice: ${data.voiceName}` });
|
| 478 |
+
return;
|
| 479 |
+
}
|
| 480 |
+
postMessage({ type: 'status', status: 'Ready', state: 'idle' });
|
| 481 |
+
} catch (err) {
|
| 482 |
+
console.error('Voice switch error:', err);
|
| 483 |
+
postMessage({ type: 'error', error: 'Failed to set voice: ' + err.toString() });
|
| 484 |
+
}
|
| 485 |
+
} else if (type === 'set_lsd') {
|
| 486 |
+
// Dynamic LSD adjustment for edge devices
|
| 487 |
+
const newLSD = Math.max(1, Math.min(MAX_LSD, data.lsd));
|
| 488 |
+
if (newLSD !== currentLSD) {
|
| 489 |
+
if (DEBUG_LOGS) {
|
| 490 |
+
console.log(`LSD adjusted: ${currentLSD} → ${newLSD}`);
|
| 491 |
+
}
|
| 492 |
+
currentLSD = newLSD;
|
| 493 |
+
}
|
| 494 |
+
} else if (type === 'stop') {
|
| 495 |
+
isGenerating = false;
|
| 496 |
+
postMessage({ type: 'status', status: 'Stopped', state: 'idle' });
|
| 497 |
+
}
|
| 498 |
+
};
|
| 499 |
+
|
| 500 |
+
async function loadModels() {
|
| 501 |
+
if (mimiEncoderSession) return;
|
| 502 |
+
|
| 503 |
+
postMessage({ type: 'status', status: 'Loading ONNX Runtime...', state: 'loading' });
|
| 504 |
+
startupLog('loading onnx runtime');
|
| 505 |
+
|
| 506 |
+
try {
|
| 507 |
+
const ortModule = await import(LOCAL_ORT_MODULE_URL);
|
| 508 |
+
ort = ortModule.default || ortModule;
|
| 509 |
+
startupLog('onnx runtime loaded', { moduleUrl: LOCAL_ORT_MODULE_URL, wasmBase: LOCAL_ORT_BASE_URL });
|
| 510 |
+
} catch (e) {
|
| 511 |
+
startupError('failed to load onnx runtime', e);
|
| 512 |
+
throw new Error('Failed to load ONNX Runtime: ' + e.message);
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
if (!ort) {
|
| 516 |
+
throw new Error('ONNX Runtime failed to load');
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
postMessage({ type: 'status', status: 'Loading models...', state: 'loading' });
|
| 520 |
+
|
| 521 |
+
const requestedThreadCount = Math.max(1, workerLoadConfig.threadCount || 1);
|
| 522 |
+
startupLog('configuring wasm', {
|
| 523 |
+
requestedThreadCount,
|
| 524 |
+
crossOriginIsolated: self.crossOriginIsolated === true
|
| 525 |
+
});
|
| 526 |
+
|
| 527 |
+
// Configure WASM Paths
|
| 528 |
+
ort.env.wasm.wasmPaths = LOCAL_ORT_BASE_URL;
|
| 529 |
+
ort.env.wasm.proxy = false;
|
| 530 |
+
|
| 531 |
+
// Enable SIMD for significant performance boost (2-4x faster)
|
| 532 |
+
ort.env.wasm.simd = true;
|
| 533 |
+
|
| 534 |
+
// Configure multi-threading
|
| 535 |
+
if (!self.crossOriginIsolated) {
|
| 536 |
+
console.warn('Environment is not cross-origin isolated. Disabling WASM multi-threading.');
|
| 537 |
+
console.warn('To enable multi-threading, serve with headers:');
|
| 538 |
+
console.warn(' Cross-Origin-Opener-Policy: same-origin');
|
| 539 |
+
console.warn(' Cross-Origin-Embedder-Policy: require-corp');
|
| 540 |
+
ort.env.wasm.numThreads = 1;
|
| 541 |
+
} else {
|
| 542 |
+
ort.env.wasm.numThreads = requestedThreadCount;
|
| 543 |
+
if (DEBUG_LOGS) {
|
| 544 |
+
console.log(`Multi-threading enabled with ${requestedThreadCount} threads`);
|
| 545 |
+
}
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
if (DEBUG_LOGS) {
|
| 549 |
+
console.log(`ORT: crossOriginIsolated=${self.crossOriginIsolated}, simd=${ort.env.wasm.simd}, threads=${ort.env.wasm.numThreads}`);
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
try {
|
| 553 |
+
const sessionOptions = {
|
| 554 |
+
executionProviders: ['wasm'],
|
| 555 |
+
graphOptimizationLevel: 'all'
|
| 556 |
+
};
|
| 557 |
+
const createSession = async (name, modelUrl) => {
|
| 558 |
+
postMessage({ type: 'status', status: `Loading ${name}...`, state: 'loading' });
|
| 559 |
+
startupLog('creating session', name, modelUrl);
|
| 560 |
+
const session = await ort.InferenceSession.create(modelUrl, sessionOptions);
|
| 561 |
+
startupLog('session ready', name);
|
| 562 |
+
return session;
|
| 563 |
+
};
|
| 564 |
+
|
| 565 |
+
// Load all models in parallel
|
| 566 |
+
postMessage({ type: 'status', status: 'Loading MIMI encoder...', state: 'loading' });
|
| 567 |
+
startupLog('starting model loads');
|
| 568 |
+
|
| 569 |
+
const [encoderRes, textCondRes, flowMainRes, flowFlowRes, decoderRes] = await Promise.all([
|
| 570 |
+
createSession('mimi_encoder', MODELS.mimi_encoder),
|
| 571 |
+
createSession('text_conditioner', MODELS.text_conditioner),
|
| 572 |
+
createSession('flow_lm_main', MODELS.flow_lm_main),
|
| 573 |
+
createSession('flow_lm_flow', MODELS.flow_lm_flow),
|
| 574 |
+
createSession('mimi_decoder', MODELS.mimi_decoder)
|
| 575 |
+
]);
|
| 576 |
+
|
| 577 |
+
mimiEncoderSession = encoderRes;
|
| 578 |
+
textConditionerSession = textCondRes;
|
| 579 |
+
flowLmMainSession = flowMainRes;
|
| 580 |
+
flowLmFlowSession = flowFlowRes;
|
| 581 |
+
mimiDecoderSession = decoderRes;
|
| 582 |
+
|
| 583 |
+
if (DEBUG_LOGS) {
|
| 584 |
+
console.log('All models loaded successfully');
|
| 585 |
+
console.log('Flow LM Main inputs:', flowLmMainSession.inputNames);
|
| 586 |
+
console.log('Flow LM Main outputs:', flowLmMainSession.outputNames);
|
| 587 |
+
console.log('MIMI decoder inputs:', mimiDecoderSession.inputNames);
|
| 588 |
+
console.log('MIMI decoder outputs:', mimiDecoderSession.outputNames);
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
// Load tokenizer
|
| 592 |
+
postMessage({ type: 'status', status: 'Loading tokenizer...', state: 'loading' });
|
| 593 |
+
startupLog('loading tokenizer', MODELS.tokenizer);
|
| 594 |
+
|
| 595 |
+
const tokenizerResponse = await fetch(MODELS.tokenizer);
|
| 596 |
+
if (!tokenizerResponse.ok) {
|
| 597 |
+
throw new Error(`Failed to load tokenizer: ${tokenizerResponse.statusText}`);
|
| 598 |
+
}
|
| 599 |
+
const tokenizerBuffer = await tokenizerResponse.arrayBuffer();
|
| 600 |
+
tokenizerModelB64 = btoa(String.fromCharCode(...new Uint8Array(tokenizerBuffer)));
|
| 601 |
+
startupLog('tokenizer fetched', tokenizerBuffer.byteLength);
|
| 602 |
+
|
| 603 |
+
// Import and initialize sentencepiece processor
|
| 604 |
+
startupLog('importing sentencepiece module', SENTENCEPIECE_MODULE_URL);
|
| 605 |
+
const spModule = await import(SENTENCEPIECE_MODULE_URL);
|
| 606 |
+
const SentencePieceProcessor = spModule.SentencePieceProcessor;
|
| 607 |
+
if (!SentencePieceProcessor) {
|
| 608 |
+
throw new Error('SentencePieceProcessor not found in sentencepiece.js');
|
| 609 |
+
}
|
| 610 |
+
tokenizerProcessor = new SentencePieceProcessor();
|
| 611 |
+
await tokenizerProcessor.loadFromB64StringModel(tokenizerModelB64);
|
| 612 |
+
startupLog('sentencepiece ready');
|
| 613 |
+
if (DEBUG_LOGS) {
|
| 614 |
+
console.log('Tokenizer loaded');
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
// Load predefined voices
|
| 618 |
+
postMessage({ type: 'status', status: 'Loading voices...', state: 'loading' });
|
| 619 |
+
startupLog('loading voices', MODELS.voices);
|
| 620 |
+
|
| 621 |
+
try {
|
| 622 |
+
const voicesResponse = await fetch(MODELS.voices);
|
| 623 |
+
if (voicesResponse.ok) {
|
| 624 |
+
const voicesData = await voicesResponse.arrayBuffer();
|
| 625 |
+
startupLog('voices fetched', voicesData.byteLength);
|
| 626 |
+
predefinedVoices = parseVoicesBin(voicesData);
|
| 627 |
+
if (DEBUG_LOGS) {
|
| 628 |
+
console.log('Loaded voices:', Object.keys(predefinedVoices));
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
// Set default voice
|
| 632 |
+
if (predefinedVoices['cosette']) {
|
| 633 |
+
currentVoiceEmbedding = predefinedVoices['cosette'];
|
| 634 |
+
currentVoiceName = 'cosette';
|
| 635 |
+
} else {
|
| 636 |
+
// Use first available voice
|
| 637 |
+
const firstVoice = Object.keys(predefinedVoices)[0];
|
| 638 |
+
if (firstVoice) {
|
| 639 |
+
currentVoiceEmbedding = predefinedVoices[firstVoice];
|
| 640 |
+
currentVoiceName = firstVoice;
|
| 641 |
+
}
|
| 642 |
+
}
|
| 643 |
+
}
|
| 644 |
+
} catch (e) {
|
| 645 |
+
console.warn('Could not load predefined voices:', e);
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
if (currentVoiceEmbedding && currentVoiceName) {
|
| 649 |
+
startupLog('conditioning default voice', currentVoiceName);
|
| 650 |
+
await ensureVoiceConditioningCached(currentVoiceName, currentVoiceEmbedding, {
|
| 651 |
+
force: true,
|
| 652 |
+
statusText: `Loading voice conditioning (${currentVoiceName})...`
|
| 653 |
+
});
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
// Send list of available voices
|
| 657 |
+
postMessage({
|
| 658 |
+
type: 'voices_loaded',
|
| 659 |
+
voices: Object.keys(predefinedVoices),
|
| 660 |
+
defaultVoice: currentVoiceName
|
| 661 |
+
});
|
| 662 |
+
|
| 663 |
+
// Pre-allocate s/t tensors for Flow Matching Loop (Optimization)
|
| 664 |
+
// Pre-allocate for MAX_LSD to support dynamic switching
|
| 665 |
+
if (DEBUG_LOGS) {
|
| 666 |
+
console.log(`Pre-allocating Flow Matching tensors for LSD 1-${MAX_LSD}...`);
|
| 667 |
+
}
|
| 668 |
+
stTensors = {};
|
| 669 |
+
|
| 670 |
+
for (let lsd = 1; lsd <= MAX_LSD; lsd++) {
|
| 671 |
+
stTensors[lsd] = [];
|
| 672 |
+
const dt = 1.0 / lsd;
|
| 673 |
+
for (let j = 0; j < lsd; j++) {
|
| 674 |
+
const s = j / lsd;
|
| 675 |
+
const t = s + dt;
|
| 676 |
+
stTensors[lsd].push({
|
| 677 |
+
s: new ort.Tensor('float32', new Float32Array([s]), [1, 1]),
|
| 678 |
+
t: new ort.Tensor('float32', new Float32Array([t]), [1, 1])
|
| 679 |
+
});
|
| 680 |
+
}
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
isReady = true;
|
| 684 |
+
postMessage({ type: 'status', status: 'Ready', state: 'idle' });
|
| 685 |
+
postMessage({ type: 'model_status', status: 'ready', text: 'Ready' });
|
| 686 |
+
postMessage({ type: 'loaded' });
|
| 687 |
+
|
| 688 |
+
} catch (err) {
|
| 689 |
+
startupError('model load failed', err);
|
| 690 |
+
throw err;
|
| 691 |
+
}
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
function parseVoicesBin(buffer) {
|
| 695 |
+
// Simple binary format:
|
| 696 |
+
// Header: 4 bytes (uint32) = number of voices
|
| 697 |
+
// For each voice:
|
| 698 |
+
// - 32 bytes: voice name (null-terminated string)
|
| 699 |
+
// - 4 bytes (uint32): number of frames
|
| 700 |
+
// - 4 bytes (uint32): embedding dim (1024)
|
| 701 |
+
// - frames * dim * 4 bytes: float32 embeddings
|
| 702 |
+
|
| 703 |
+
const voices = {};
|
| 704 |
+
const view = new DataView(buffer);
|
| 705 |
+
let offset = 0;
|
| 706 |
+
|
| 707 |
+
const numVoices = view.getUint32(offset, true);
|
| 708 |
+
offset += 4;
|
| 709 |
+
|
| 710 |
+
for (let i = 0; i < numVoices; i++) {
|
| 711 |
+
// Read voice name
|
| 712 |
+
const nameBytes = new Uint8Array(buffer, offset, 32);
|
| 713 |
+
const nameEnd = nameBytes.indexOf(0);
|
| 714 |
+
const name = new TextDecoder().decode(nameBytes.subarray(0, nameEnd > 0 ? nameEnd : 32)).trim();
|
| 715 |
+
offset += 32;
|
| 716 |
+
|
| 717 |
+
// Read dimensions
|
| 718 |
+
const numFrames = view.getUint32(offset, true);
|
| 719 |
+
offset += 4;
|
| 720 |
+
const embDim = view.getUint32(offset, true);
|
| 721 |
+
offset += 4;
|
| 722 |
+
|
| 723 |
+
// Read embeddings
|
| 724 |
+
const embSize = numFrames * embDim;
|
| 725 |
+
const embeddings = new Float32Array(buffer, offset, embSize);
|
| 726 |
+
offset += embSize * 4;
|
| 727 |
+
|
| 728 |
+
// Store as [1, numFrames, embDim] shaped array info
|
| 729 |
+
voices[name] = {
|
| 730 |
+
data: new Float32Array(embeddings),
|
| 731 |
+
shape: [1, numFrames, embDim]
|
| 732 |
+
};
|
| 733 |
+
|
| 734 |
+
if (DEBUG_LOGS) {
|
| 735 |
+
console.log(`Loaded voice '${name}': ${numFrames} frames, ${embDim} dim`);
|
| 736 |
+
}
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
return voices;
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
async function encodeVoiceAudio(audioData) {
|
| 743 |
+
// audioData should be Float32Array at 24kHz, mono
|
| 744 |
+
// Reshape to [1, 1, samples]
|
| 745 |
+
const input = new ort.Tensor('float32', audioData, [1, 1, audioData.length]);
|
| 746 |
+
|
| 747 |
+
const outputs = await mimiEncoderSession.run({ audio: input });
|
| 748 |
+
const embeddings = outputs[mimiEncoderSession.outputNames[0]];
|
| 749 |
+
|
| 750 |
+
return {
|
| 751 |
+
data: new Float32Array(embeddings.data),
|
| 752 |
+
shape: embeddings.dims
|
| 753 |
+
};
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
async function buildVoiceConditionedState(voiceEmb) {
|
| 757 |
+
const flowLmState = initState(flowLmMainSession, FLOW_LM_STATE_SHAPES);
|
| 758 |
+
const emptySeq = new ort.Tensor('float32', new Float32Array(0), [1, 0, 32]);
|
| 759 |
+
const voiceTensor = new ort.Tensor('float32', voiceEmb.data, voiceEmb.shape);
|
| 760 |
+
|
| 761 |
+
const voiceCondInputs = {
|
| 762 |
+
sequence: emptySeq,
|
| 763 |
+
text_embeddings: voiceTensor,
|
| 764 |
+
...flowLmState
|
| 765 |
+
};
|
| 766 |
+
|
| 767 |
+
const condResult = await flowLmMainSession.run(voiceCondInputs);
|
| 768 |
+
for (let i = 2; i < flowLmMainSession.outputNames.length; i++) {
|
| 769 |
+
const outputName = flowLmMainSession.outputNames[i];
|
| 770 |
+
if (outputName.startsWith('out_state_')) {
|
| 771 |
+
const stateIdx = parseInt(outputName.replace('out_state_', ''));
|
| 772 |
+
flowLmState[`state_${stateIdx}`] = condResult[outputName];
|
| 773 |
+
}
|
| 774 |
+
}
|
| 775 |
+
return flowLmState;
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
function cloneFlowState(baseState) {
|
| 779 |
+
// Shallow clone is enough: we only replace tensor refs in the local state map.
|
| 780 |
+
return { ...baseState };
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
async function ensureVoiceConditioningCached(voiceName, voiceEmb, options = {}) {
|
| 784 |
+
const { force = false, statusText = 'Conditioning voice...' } = options;
|
| 785 |
+
if (!voiceName) {
|
| 786 |
+
throw new Error('Cannot cache voice conditioning without a voice name.');
|
| 787 |
+
}
|
| 788 |
+
if (!voiceEmb) {
|
| 789 |
+
throw new Error(`Cannot cache voice conditioning for '${voiceName}' without embeddings.`);
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
if (!force && voiceConditioningCache.has(voiceName)) {
|
| 793 |
+
if (DEBUG_LOGS) {
|
| 794 |
+
console.log(`[voice-conditioning] ready for '${voiceName}' (cache hit)`);
|
| 795 |
+
}
|
| 796 |
+
return voiceConditioningCache.get(voiceName);
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
postMessage({ type: 'status', status: statusText, state: 'loading' });
|
| 800 |
+
const startMs = performance.now();
|
| 801 |
+
const conditionedState = await buildVoiceConditionedState(voiceEmb);
|
| 802 |
+
voiceConditioningCache.set(voiceName, conditionedState);
|
| 803 |
+
const elapsedMs = performance.now() - startMs;
|
| 804 |
+
if (DEBUG_LOGS) {
|
| 805 |
+
console.log(`[voice-conditioning] completed for '${voiceName}' in ${elapsedMs.toFixed(0)}ms`);
|
| 806 |
+
}
|
| 807 |
+
return conditionedState;
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
// Hardcoded state shapes extracted from ONNX model metadata
|
| 811 |
+
// These are the initial shapes - dynamic dimensions start at 0
|
| 812 |
+
const FLOW_LM_STATE_SHAPES = {
|
| 813 |
+
// KV cache layers: [kv=2, batch=1, max_seq=1000, heads=16, head_dim=64]
|
| 814 |
+
state_0: { shape: [2, 1, 1000, 16, 64], dtype: 'float32' },
|
| 815 |
+
state_1: { shape: [0], dtype: 'float32' }, // dynamic
|
| 816 |
+
state_2: { shape: [1], dtype: 'int64' }, // step counter
|
| 817 |
+
state_3: { shape: [2, 1, 1000, 16, 64], dtype: 'float32' },
|
| 818 |
+
state_4: { shape: [0], dtype: 'float32' },
|
| 819 |
+
state_5: { shape: [1], dtype: 'int64' },
|
| 820 |
+
state_6: { shape: [2, 1, 1000, 16, 64], dtype: 'float32' },
|
| 821 |
+
state_7: { shape: [0], dtype: 'float32' },
|
| 822 |
+
state_8: { shape: [1], dtype: 'int64' },
|
| 823 |
+
state_9: { shape: [2, 1, 1000, 16, 64], dtype: 'float32' },
|
| 824 |
+
state_10: { shape: [0], dtype: 'float32' },
|
| 825 |
+
state_11: { shape: [1], dtype: 'int64' },
|
| 826 |
+
state_12: { shape: [2, 1, 1000, 16, 64], dtype: 'float32' },
|
| 827 |
+
state_13: { shape: [0], dtype: 'float32' },
|
| 828 |
+
state_14: { shape: [1], dtype: 'int64' },
|
| 829 |
+
state_15: { shape: [2, 1, 1000, 16, 64], dtype: 'float32' },
|
| 830 |
+
state_16: { shape: [0], dtype: 'float32' },
|
| 831 |
+
state_17: { shape: [1], dtype: 'int64' },
|
| 832 |
+
};
|
| 833 |
+
|
| 834 |
+
const MIMI_DECODER_STATE_SHAPES = {
|
| 835 |
+
state_0: { shape: [1], dtype: 'bool' },
|
| 836 |
+
state_1: { shape: [1, 512, 6], dtype: 'float32' },
|
| 837 |
+
state_2: { shape: [1], dtype: 'bool' },
|
| 838 |
+
state_3: { shape: [1, 64, 2], dtype: 'float32' },
|
| 839 |
+
state_4: { shape: [1, 256, 6], dtype: 'float32' },
|
| 840 |
+
state_5: { shape: [1], dtype: 'bool' },
|
| 841 |
+
state_6: { shape: [1, 256, 2], dtype: 'float32' },
|
| 842 |
+
state_7: { shape: [1], dtype: 'bool' },
|
| 843 |
+
state_8: { shape: [1, 128, 0], dtype: 'float32' }, // dynamic
|
| 844 |
+
state_9: { shape: [1, 128, 5], dtype: 'float32' },
|
| 845 |
+
state_10: { shape: [1], dtype: 'bool' },
|
| 846 |
+
state_11: { shape: [1, 128, 2], dtype: 'float32' },
|
| 847 |
+
state_12: { shape: [1], dtype: 'bool' },
|
| 848 |
+
state_13: { shape: [1, 64, 0], dtype: 'float32' }, // dynamic
|
| 849 |
+
state_14: { shape: [1, 64, 4], dtype: 'float32' },
|
| 850 |
+
state_15: { shape: [1], dtype: 'bool' },
|
| 851 |
+
state_16: { shape: [1, 64, 2], dtype: 'float32' },
|
| 852 |
+
state_17: { shape: [1], dtype: 'bool' },
|
| 853 |
+
state_18: { shape: [1, 32, 0], dtype: 'float32' }, // dynamic
|
| 854 |
+
state_19: { shape: [2, 1, 8, 1000, 64], dtype: 'float32' },
|
| 855 |
+
state_20: { shape: [1], dtype: 'int64' },
|
| 856 |
+
state_21: { shape: [1], dtype: 'int64' },
|
| 857 |
+
state_22: { shape: [2, 1, 8, 1000, 64], dtype: 'float32' },
|
| 858 |
+
state_23: { shape: [1], dtype: 'int64' },
|
| 859 |
+
state_24: { shape: [1], dtype: 'int64' },
|
| 860 |
+
state_25: { shape: [1], dtype: 'bool' },
|
| 861 |
+
state_26: { shape: [1, 512, 16], dtype: 'float32' },
|
| 862 |
+
state_27: { shape: [1], dtype: 'bool' },
|
| 863 |
+
state_28: { shape: [1, 1, 6], dtype: 'float32' },
|
| 864 |
+
state_29: { shape: [1], dtype: 'bool' },
|
| 865 |
+
state_30: { shape: [1, 64, 2], dtype: 'float32' },
|
| 866 |
+
state_31: { shape: [1], dtype: 'bool' },
|
| 867 |
+
state_32: { shape: [1, 32, 0], dtype: 'float32' }, // dynamic
|
| 868 |
+
state_33: { shape: [1], dtype: 'bool' },
|
| 869 |
+
state_34: { shape: [1, 512, 2], dtype: 'float32' },
|
| 870 |
+
state_35: { shape: [1], dtype: 'bool' },
|
| 871 |
+
state_36: { shape: [1, 64, 4], dtype: 'float32' },
|
| 872 |
+
state_37: { shape: [1], dtype: 'bool' },
|
| 873 |
+
state_38: { shape: [1, 128, 2], dtype: 'float32' },
|
| 874 |
+
state_39: { shape: [1], dtype: 'bool' },
|
| 875 |
+
state_40: { shape: [1, 64, 0], dtype: 'float32' }, // dynamic
|
| 876 |
+
state_41: { shape: [1], dtype: 'bool' },
|
| 877 |
+
state_42: { shape: [1, 128, 5], dtype: 'float32' },
|
| 878 |
+
state_43: { shape: [1], dtype: 'bool' },
|
| 879 |
+
state_44: { shape: [1, 256, 2], dtype: 'float32' },
|
| 880 |
+
state_45: { shape: [1], dtype: 'bool' },
|
| 881 |
+
state_46: { shape: [1, 128, 0], dtype: 'float32' }, // dynamic
|
| 882 |
+
state_47: { shape: [1], dtype: 'bool' },
|
| 883 |
+
state_48: { shape: [1, 256, 6], dtype: 'float32' },
|
| 884 |
+
state_49: { shape: [2, 1, 8, 1000, 64], dtype: 'float32' },
|
| 885 |
+
state_50: { shape: [1], dtype: 'int64' },
|
| 886 |
+
state_51: { shape: [1], dtype: 'int64' },
|
| 887 |
+
state_52: { shape: [2, 1, 8, 1000, 64], dtype: 'float32' },
|
| 888 |
+
state_53: { shape: [1], dtype: 'int64' },
|
| 889 |
+
state_54: { shape: [1], dtype: 'int64' },
|
| 890 |
+
state_55: { shape: [1, 512, 16], dtype: 'float32' },
|
| 891 |
+
};
|
| 892 |
+
|
| 893 |
+
function initState(session, stateShapes) {
|
| 894 |
+
/**
|
| 895 |
+
* Initialize state tensors for a stateful ONNX model using hardcoded shapes.
|
| 896 |
+
*/
|
| 897 |
+
const state = {};
|
| 898 |
+
|
| 899 |
+
for (const inputName of session.inputNames) {
|
| 900 |
+
if (inputName.startsWith('state_')) {
|
| 901 |
+
const stateInfo = stateShapes[inputName];
|
| 902 |
+
if (!stateInfo) {
|
| 903 |
+
console.warn(`Unknown state input: ${inputName}, skipping`);
|
| 904 |
+
continue;
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
const { shape, dtype } = stateInfo;
|
| 908 |
+
const size = shape.reduce((a, b) => a * b, 1);
|
| 909 |
+
|
| 910 |
+
let data;
|
| 911 |
+
if (dtype === 'int64') {
|
| 912 |
+
data = new BigInt64Array(size);
|
| 913 |
+
} else if (dtype === 'bool') {
|
| 914 |
+
data = new Uint8Array(size);
|
| 915 |
+
} else {
|
| 916 |
+
data = new Float32Array(size);
|
| 917 |
+
}
|
| 918 |
+
|
| 919 |
+
state[inputName] = new ort.Tensor(dtype, data, shape);
|
| 920 |
+
if (DEBUG_LOGS) {
|
| 921 |
+
console.log(`Init state ${inputName}: shape=${JSON.stringify(shape)}, dtype=${dtype}`);
|
| 922 |
+
}
|
| 923 |
+
}
|
| 924 |
+
}
|
| 925 |
+
|
| 926 |
+
return state;
|
| 927 |
+
}
|
| 928 |
+
|
| 929 |
+
async function startGeneration(text, voiceName) {
|
| 930 |
+
isGenerating = true;
|
| 931 |
+
currentLSD = MAX_LSD; // Reset to max quality for each new generation
|
| 932 |
+
postMessage({ type: 'status', status: 'Generating...', state: 'running' });
|
| 933 |
+
postMessage({ type: 'generation_started', data: { time: performance.now() } });
|
| 934 |
+
|
| 935 |
+
try {
|
| 936 |
+
// Split text into sentence chunks (target <= CHUNK_TARGET_TOKENS tokens)
|
| 937 |
+
const chunks = splitIntoBestSentences(text);
|
| 938 |
+
if (DEBUG_LOGS) {
|
| 939 |
+
console.log(`Split into ${chunks.length} chunks:`, chunks);
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
if (chunks.length === 0) {
|
| 943 |
+
throw new Error('No text to generate');
|
| 944 |
+
}
|
| 945 |
+
|
| 946 |
+
// Resolve voice
|
| 947 |
+
let resolvedVoiceName = currentVoiceName;
|
| 948 |
+
if (voiceName && voiceName !== currentVoiceName) {
|
| 949 |
+
if (predefinedVoices[voiceName]) {
|
| 950 |
+
currentVoiceEmbedding = predefinedVoices[voiceName];
|
| 951 |
+
currentVoiceName = voiceName;
|
| 952 |
+
resolvedVoiceName = voiceName;
|
| 953 |
+
await ensureVoiceConditioningCached(resolvedVoiceName, currentVoiceEmbedding, {
|
| 954 |
+
statusText: `Conditioning voice (${resolvedVoiceName})...`
|
| 955 |
+
});
|
| 956 |
+
} else if (voiceName === 'custom' && currentVoiceName === 'custom') {
|
| 957 |
+
resolvedVoiceName = 'custom';
|
| 958 |
+
}
|
| 959 |
+
}
|
| 960 |
+
|
| 961 |
+
if (!currentVoiceEmbedding || !resolvedVoiceName) {
|
| 962 |
+
throw new Error('No voice embedding available. Please select a voice or upload custom audio.');
|
| 963 |
+
}
|
| 964 |
+
if (!voiceConditioningCache.has(resolvedVoiceName)) {
|
| 965 |
+
throw new Error(`Voice conditioning cache missing for '${resolvedVoiceName}'. Switch voices to prepare cache.`);
|
| 966 |
+
}
|
| 967 |
+
|
| 968 |
+
// Run generation pipeline with chunks
|
| 969 |
+
await runGenerationPipeline(resolvedVoiceName, chunks);
|
| 970 |
+
|
| 971 |
+
} catch (err) {
|
| 972 |
+
console.error('Generation error:', err);
|
| 973 |
+
postMessage({ type: 'error', error: err.toString() });
|
| 974 |
+
} finally {
|
| 975 |
+
if (isGenerating) {
|
| 976 |
+
postMessage({ type: 'stream_ended' });
|
| 977 |
+
postMessage({ type: 'status', status: 'Finished', state: 'idle' });
|
| 978 |
+
}
|
| 979 |
+
isGenerating = false;
|
| 980 |
+
}
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
async function runGenerationPipeline(voiceName, chunks) {
|
| 984 |
+
// Initialize state - may be reset per chunk
|
| 985 |
+
let mimiState = initState(mimiDecoderSession, MIMI_DECODER_STATE_SHAPES);
|
| 986 |
+
const emptySeq = new ort.Tensor('float32', new Float32Array(0), [1, 0, 32]);
|
| 987 |
+
const emptyTextEmb = new ort.Tensor('float32', new Float32Array(0), [1, 0, 1024]);
|
| 988 |
+
const baseFlowState = voiceConditioningCache.get(voiceName);
|
| 989 |
+
if (!baseFlowState) {
|
| 990 |
+
throw new Error(`Voice conditioning cache missing for '${voiceName}'.`);
|
| 991 |
+
}
|
| 992 |
+
let flowLmState = cloneFlowState(baseFlowState);
|
| 993 |
+
|
| 994 |
+
// Streaming parameters
|
| 995 |
+
const FIRST_CHUNK_FRAMES = 3;
|
| 996 |
+
const NORMAL_CHUNK_FRAMES = 12;
|
| 997 |
+
|
| 998 |
+
// Tracking across all chunks
|
| 999 |
+
const allGeneratedLatents = [];
|
| 1000 |
+
let isFirstAudioChunk = true;
|
| 1001 |
+
let totalDecodedFrames = 0;
|
| 1002 |
+
let totalFlowLmTime = 0;
|
| 1003 |
+
let totalDecodeTime = 0;
|
| 1004 |
+
const arStartTime = performance.now();
|
| 1005 |
+
|
| 1006 |
+
// Process each text chunk
|
| 1007 |
+
for (let chunkIdx = 0; chunkIdx < chunks.length; chunkIdx++) {
|
| 1008 |
+
if (!isGenerating) break;
|
| 1009 |
+
|
| 1010 |
+
if (RESET_FLOW_STATE_EACH_CHUNK && chunkIdx > 0) {
|
| 1011 |
+
flowLmState = cloneFlowState(baseFlowState);
|
| 1012 |
+
}
|
| 1013 |
+
if (RESET_MIMI_STATE_EACH_CHUNK && chunkIdx > 0) {
|
| 1014 |
+
mimiState = initState(mimiDecoderSession, MIMI_DECODER_STATE_SHAPES);
|
| 1015 |
+
}
|
| 1016 |
+
|
| 1017 |
+
const chunkText = chunks[chunkIdx];
|
| 1018 |
+
if (DEBUG_LOGS) {
|
| 1019 |
+
console.log(`Processing chunk ${chunkIdx + 1}/${chunks.length}: "${chunkText}"`);
|
| 1020 |
+
}
|
| 1021 |
+
|
| 1022 |
+
let isFirstAudioChunkOfTextChunk = true;
|
| 1023 |
+
|
| 1024 |
+
// Tokenize this chunk
|
| 1025 |
+
const tokenIds = tokenizerProcessor.encodeIds(chunkText);
|
| 1026 |
+
if (DEBUG_LOGS) {
|
| 1027 |
+
console.log(`Chunk ${chunkIdx + 1} tokens:`, tokenIds.length);
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
// Text conditioning for this chunk
|
| 1031 |
+
const textInput = new ort.Tensor('int64', BigInt64Array.from(tokenIds.map(x => BigInt(x))), [1, tokenIds.length]);
|
| 1032 |
+
const textCondResult = await textConditionerSession.run({ token_ids: textInput });
|
| 1033 |
+
let textEmb = textCondResult[textConditionerSession.outputNames[0]];
|
| 1034 |
+
|
| 1035 |
+
if (textEmb.dims.length === 2) {
|
| 1036 |
+
textEmb = new ort.Tensor('float32', textEmb.data, [1, textEmb.dims[0], textEmb.dims[1]]);
|
| 1037 |
+
}
|
| 1038 |
+
|
| 1039 |
+
const textCondInputs = {
|
| 1040 |
+
sequence: emptySeq,
|
| 1041 |
+
text_embeddings: textEmb,
|
| 1042 |
+
...flowLmState
|
| 1043 |
+
};
|
| 1044 |
+
|
| 1045 |
+
let condResult = await flowLmMainSession.run(textCondInputs);
|
| 1046 |
+
|
| 1047 |
+
// Update state from text conditioning
|
| 1048 |
+
for (let i = 2; i < flowLmMainSession.outputNames.length; i++) {
|
| 1049 |
+
const outputName = flowLmMainSession.outputNames[i];
|
| 1050 |
+
if (outputName.startsWith('out_state_')) {
|
| 1051 |
+
const stateIdx = parseInt(outputName.replace('out_state_', ''));
|
| 1052 |
+
flowLmState[`state_${stateIdx}`] = condResult[outputName];
|
| 1053 |
+
}
|
| 1054 |
+
}
|
| 1055 |
+
|
| 1056 |
+
// AR generation for this chunk
|
| 1057 |
+
const chunkLatents = [];
|
| 1058 |
+
let currentLatent = new ort.Tensor('float32', new Float32Array(32).fill(NaN), [1, 1, 32]);
|
| 1059 |
+
let chunkDecodedFrames = 0;
|
| 1060 |
+
const FRAMES_AFTER_EOS = 3; // Match PyTorch behavior - generate extra frames after EOS
|
| 1061 |
+
let eosStep = null;
|
| 1062 |
+
|
| 1063 |
+
let chunkEnded = false;
|
| 1064 |
+
let chunkGenTimeMs = 0;
|
| 1065 |
+
for (let step = 0; step < MAX_FRAMES; step++) {
|
| 1066 |
+
if (!isGenerating) break;
|
| 1067 |
+
|
| 1068 |
+
// Yield every 4 steps to allow message processing (e.g., set_lsd)
|
| 1069 |
+
if (step > 0 && step % 4 === 0) {
|
| 1070 |
+
await new Promise(r => setTimeout(r, 0));
|
| 1071 |
+
}
|
| 1072 |
+
|
| 1073 |
+
const arInputs = {
|
| 1074 |
+
sequence: currentLatent,
|
| 1075 |
+
text_embeddings: emptyTextEmb,
|
| 1076 |
+
...flowLmState
|
| 1077 |
+
};
|
| 1078 |
+
|
| 1079 |
+
const stepStart = performance.now();
|
| 1080 |
+
const arResult = await flowLmMainSession.run(arInputs);
|
| 1081 |
+
const stepElapsed = performance.now() - stepStart;
|
| 1082 |
+
chunkGenTimeMs += stepElapsed;
|
| 1083 |
+
|
| 1084 |
+
const conditioning = arResult['conditioning'];
|
| 1085 |
+
const eosLogit = arResult['eos_logit'].data[0];
|
| 1086 |
+
const isEos = eosLogit > -4.0;
|
| 1087 |
+
|
| 1088 |
+
// Track when EOS is first detected
|
| 1089 |
+
if (isEos && eosStep === null) {
|
| 1090 |
+
eosStep = step;
|
| 1091 |
+
}
|
| 1092 |
+
|
| 1093 |
+
// Only stop after FRAMES_AFTER_EOS additional frames
|
| 1094 |
+
const shouldStop = eosStep !== null && step >= eosStep + FRAMES_AFTER_EOS;
|
| 1095 |
+
|
| 1096 |
+
// Flow matching (LSD loop) - uses currentLSD which can be adjusted dynamically
|
| 1097 |
+
const TEMP = 0.7;
|
| 1098 |
+
const STD = Math.sqrt(TEMP);
|
| 1099 |
+
let xData = new Float32Array(32);
|
| 1100 |
+
for (let i = 0; i < 32; i++) {
|
| 1101 |
+
let u = 0, v = 0;
|
| 1102 |
+
while (u === 0) u = Math.random();
|
| 1103 |
+
while (v === 0) v = Math.random();
|
| 1104 |
+
xData[i] = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v) * STD;
|
| 1105 |
+
}
|
| 1106 |
+
|
| 1107 |
+
const lsdSteps = currentLSD;
|
| 1108 |
+
const dt = 1.0 / lsdSteps;
|
| 1109 |
+
|
| 1110 |
+
for (let j = 0; j < lsdSteps; j++) {
|
| 1111 |
+
const flowInputs = {
|
| 1112 |
+
c: conditioning,
|
| 1113 |
+
s: stTensors[lsdSteps][j].s,
|
| 1114 |
+
t: stTensors[lsdSteps][j].t,
|
| 1115 |
+
x: new ort.Tensor('float32', xData, [1, 32])
|
| 1116 |
+
};
|
| 1117 |
+
|
| 1118 |
+
const flowResult = await flowLmFlowSession.run(flowInputs);
|
| 1119 |
+
const v = flowResult['flow_dir'].data;
|
| 1120 |
+
|
| 1121 |
+
for (let k = 0; k < 32; k++) {
|
| 1122 |
+
xData[k] += v[k] * dt;
|
| 1123 |
+
}
|
| 1124 |
+
}
|
| 1125 |
+
|
| 1126 |
+
totalFlowLmTime += stepElapsed;
|
| 1127 |
+
|
| 1128 |
+
const latentData = xData;
|
| 1129 |
+
chunkLatents.push(new Float32Array(latentData));
|
| 1130 |
+
allGeneratedLatents.push(new Float32Array(latentData));
|
| 1131 |
+
|
| 1132 |
+
// Update state
|
| 1133 |
+
currentLatent = new ort.Tensor('float32', latentData, [1, 1, 32]);
|
| 1134 |
+
for (let i = 2; i < flowLmMainSession.outputNames.length; i++) {
|
| 1135 |
+
const outputName = flowLmMainSession.outputNames[i];
|
| 1136 |
+
if (outputName.startsWith('out_state_')) {
|
| 1137 |
+
const stateIdx = parseInt(outputName.replace('out_state_', ''));
|
| 1138 |
+
flowLmState[`state_${stateIdx}`] = arResult[outputName];
|
| 1139 |
+
}
|
| 1140 |
+
}
|
| 1141 |
+
|
| 1142 |
+
// Decode audio chunks
|
| 1143 |
+
const pending = chunkLatents.length - chunkDecodedFrames;
|
| 1144 |
+
let decodeSize = 0;
|
| 1145 |
+
|
| 1146 |
+
if (shouldStop) {
|
| 1147 |
+
decodeSize = pending;
|
| 1148 |
+
} else if (isFirstAudioChunk && pending >= FIRST_CHUNK_FRAMES) {
|
| 1149 |
+
decodeSize = FIRST_CHUNK_FRAMES;
|
| 1150 |
+
} else if (pending >= NORMAL_CHUNK_FRAMES) {
|
| 1151 |
+
decodeSize = NORMAL_CHUNK_FRAMES;
|
| 1152 |
+
}
|
| 1153 |
+
|
| 1154 |
+
if (decodeSize > 0) {
|
| 1155 |
+
const decodeLatents = new Float32Array(decodeSize * 32);
|
| 1156 |
+
for (let i = 0; i < decodeSize; i++) {
|
| 1157 |
+
decodeLatents.set(chunkLatents[chunkDecodedFrames + i], i * 32);
|
| 1158 |
+
}
|
| 1159 |
+
|
| 1160 |
+
const latentTensor = new ort.Tensor('float32', decodeLatents, [1, decodeSize, 32]);
|
| 1161 |
+
const decodeInputs = { latent: latentTensor, ...mimiState };
|
| 1162 |
+
|
| 1163 |
+
const decStart = performance.now();
|
| 1164 |
+
const decodeResult = await mimiDecoderSession.run(decodeInputs);
|
| 1165 |
+
const decElapsed = performance.now() - decStart;
|
| 1166 |
+
totalDecodeTime += decElapsed;
|
| 1167 |
+
chunkGenTimeMs += decElapsed;
|
| 1168 |
+
const audioChunk = decodeResult[mimiDecoderSession.outputNames[0]].data;
|
| 1169 |
+
|
| 1170 |
+
// Update MIMI state
|
| 1171 |
+
for (let i = 1; i < mimiDecoderSession.outputNames.length; i++) {
|
| 1172 |
+
const outputName = mimiDecoderSession.outputNames[i];
|
| 1173 |
+
const stateIdx = i - 1;
|
| 1174 |
+
mimiState[`state_${stateIdx}`] = decodeResult[outputName];
|
| 1175 |
+
}
|
| 1176 |
+
|
| 1177 |
+
chunkDecodedFrames += decodeSize;
|
| 1178 |
+
totalDecodedFrames += decodeSize;
|
| 1179 |
+
|
| 1180 |
+
const audioFloat32 = new Float32Array(audioChunk);
|
| 1181 |
+
const isLastChunk = shouldStop && chunkIdx === chunks.length - 1;
|
| 1182 |
+
postMessage({
|
| 1183 |
+
type: 'audio_chunk',
|
| 1184 |
+
data: audioFloat32,
|
| 1185 |
+
metrics: {
|
| 1186 |
+
bbTime: 0,
|
| 1187 |
+
decTime: 0,
|
| 1188 |
+
chunkDuration: audioFloat32.length / SAMPLE_RATE,
|
| 1189 |
+
genTimeSec: chunkGenTimeMs / 1000,
|
| 1190 |
+
isFirst: isFirstAudioChunk,
|
| 1191 |
+
isLast: isLastChunk,
|
| 1192 |
+
chunkStart: isFirstAudioChunkOfTextChunk
|
| 1193 |
+
}
|
| 1194 |
+
}, [audioFloat32.buffer]);
|
| 1195 |
+
|
| 1196 |
+
isFirstAudioChunk = false;
|
| 1197 |
+
isFirstAudioChunkOfTextChunk = false;
|
| 1198 |
+
chunkGenTimeMs = 0;
|
| 1199 |
+
}
|
| 1200 |
+
|
| 1201 |
+
if (shouldStop) {
|
| 1202 |
+
if (DEBUG_LOGS) {
|
| 1203 |
+
console.log(`Chunk ${chunkIdx + 1} EOS at step ${eosStep}, stopped at step ${step}, ${chunkLatents.length} frames`);
|
| 1204 |
+
}
|
| 1205 |
+
chunkEnded = true;
|
| 1206 |
+
break;
|
| 1207 |
+
}
|
| 1208 |
+
}
|
| 1209 |
+
|
| 1210 |
+
if (chunkEnded && isGenerating && chunkIdx < chunks.length - 1) {
|
| 1211 |
+
const gapSamples = Math.max(1, Math.floor(CHUNK_GAP_SEC * SAMPLE_RATE));
|
| 1212 |
+
const silence = new Float32Array(gapSamples);
|
| 1213 |
+
postMessage({
|
| 1214 |
+
type: 'audio_chunk',
|
| 1215 |
+
data: silence,
|
| 1216 |
+
metrics: {
|
| 1217 |
+
bbTime: 0,
|
| 1218 |
+
decTime: 0,
|
| 1219 |
+
chunkDuration: gapSamples / SAMPLE_RATE,
|
| 1220 |
+
isFirst: false,
|
| 1221 |
+
isLast: false,
|
| 1222 |
+
isSilence: true
|
| 1223 |
+
}
|
| 1224 |
+
}, [silence.buffer]);
|
| 1225 |
+
}
|
| 1226 |
+
}
|
| 1227 |
+
|
| 1228 |
+
const totalTime = (performance.now() - arStartTime) / 1000;
|
| 1229 |
+
const audioSeconds = allGeneratedLatents.length * SAMPLES_PER_FRAME / SAMPLE_RATE;
|
| 1230 |
+
|
| 1231 |
+
// RTFx based on actual generation time (flow LM + decoder), not including conditioning
|
| 1232 |
+
const genTime = (totalFlowLmTime + totalDecodeTime) / 1000;
|
| 1233 |
+
const rtfx = audioSeconds / genTime;
|
| 1234 |
+
|
| 1235 |
+
if (DEBUG_LOGS) {
|
| 1236 |
+
console.log(`Generation complete: ${allGeneratedLatents.length} frames (${audioSeconds.toFixed(2)}s audio)`);
|
| 1237 |
+
console.log(` Total time: ${totalTime.toFixed(2)}s`);
|
| 1238 |
+
console.log(` Gen time: ${genTime.toFixed(2)}s, RTFx: ${rtfx.toFixed(2)}x`);
|
| 1239 |
+
console.log(` Flow LM: ${(totalFlowLmTime / 1000).toFixed(2)}s (${(totalFlowLmTime / allGeneratedLatents.length).toFixed(1)}ms/step)`);
|
| 1240 |
+
console.log(` Decoder: ${(totalDecodeTime / 1000).toFixed(2)}s`);
|
| 1241 |
+
}
|
| 1242 |
+
|
| 1243 |
+
postMessage({
|
| 1244 |
+
type: 'status',
|
| 1245 |
+
status: `Finished (RTFx: ${rtfx.toFixed(2)}x)`,
|
| 1246 |
+
state: 'idle',
|
| 1247 |
+
metrics: { rtfx, genTime, totalTime, audioDuration: audioSeconds }
|
| 1248 |
+
});
|
| 1249 |
+
}
|
| 1250 |
+
|
| 1251 |
+
// Pre-allocated buffers for step counter updates (avoid GC pressure in hot loop)
|
| 1252 |
+
const stepBuffers = {};
|
| 1253 |
+
|
| 1254 |
+
function updateStateSteps(state, increment) {
|
| 1255 |
+
// Update step counters in state dict - reuse buffers to avoid allocation
|
| 1256 |
+
const incBigInt = BigInt(increment);
|
| 1257 |
+
for (const key in state) {
|
| 1258 |
+
if (key.includes('step') && state[key]) {
|
| 1259 |
+
const tensor = state[key];
|
| 1260 |
+
if (tensor.data instanceof BigInt64Array) {
|
| 1261 |
+
// Reuse buffer if same size, otherwise create new one
|
| 1262 |
+
if (!stepBuffers[key] || stepBuffers[key].length !== tensor.data.length) {
|
| 1263 |
+
stepBuffers[key] = new BigInt64Array(tensor.data.length);
|
| 1264 |
+
}
|
| 1265 |
+
const buf = stepBuffers[key];
|
| 1266 |
+
for (let i = 0; i < tensor.data.length; i++) {
|
| 1267 |
+
buf[i] = tensor.data[i] + incBigInt;
|
| 1268 |
+
}
|
| 1269 |
+
state[key] = new ort.Tensor('int64', buf, tensor.dims);
|
| 1270 |
+
}
|
| 1271 |
+
}
|
| 1272 |
+
}
|
| 1273 |
+
}
|
src/vendor/pocket-tts/onnx/flow_lm_flow_int8.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8d627d235c44a597da908e1085ebe241cbbe358964c502c5a5063d18851a5529
|
| 3 |
+
size 9962530
|
src/vendor/pocket-tts/onnx/flow_lm_main_int8.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fd5cdd7f7ab05f638af0011b9561fe95f3d86bae7be7504e921ae3d2874b5da5
|
| 3 |
+
size 76341627
|
src/vendor/pocket-tts/onnx/mimi_decoder_int8.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:501e16f51cf3fb91bd2928ee2a10c96d3461544eff329aafca10489e990b450c
|
| 3 |
+
size 22684077
|
src/vendor/pocket-tts/onnx/mimi_encoder.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:360f050cd0b1e1c9e92e25584391a2214cb7574ba16c67ac5f471f9dce8588e4
|
| 3 |
+
size 73165554
|
src/vendor/pocket-tts/onnx/text_conditioner.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:80ea69f46d8153a9bd42373723cedae4c88ccbde0e052c2a96e9e8f19445adf1
|
| 3 |
+
size 16388363
|
src/vendor/pocket-tts/sentencepiece.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/vendor/pocket-tts/tokenizer.model
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d461765ae179566678c93091c5fa6f2984c31bbe990bf1aa62d92c64d91bc3f6
|
| 3 |
+
size 59339
|
src/vendor/pocket-tts/voices.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a499039e88043ff86cb39705f487cc761406d55f1e73ad19a3c055b16e86b062
|
| 3 |
+
size 1564796
|
src/workers/pocket-bootstrap.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
console.info("[PocketBootstrap] worker module starting");
|
| 2 |
+
|
| 3 |
+
self.addEventListener("error", (event) => {
|
| 4 |
+
console.error("[PocketBootstrap] error event", {
|
| 5 |
+
message: event.message,
|
| 6 |
+
filename: event.filename,
|
| 7 |
+
lineno: event.lineno,
|
| 8 |
+
colno: event.colno,
|
| 9 |
+
});
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
self.addEventListener("unhandledrejection", (event) => {
|
| 13 |
+
console.error("[PocketBootstrap] unhandled rejection", event.reason);
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
const postBootstrapError = (error: unknown) => {
|
| 17 |
+
const message =
|
| 18 |
+
error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
| 19 |
+
|
| 20 |
+
self.postMessage({
|
| 21 |
+
type: "error",
|
| 22 |
+
error: `Pocket bootstrap failed: ${message}`,
|
| 23 |
+
});
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
const queuedMessages: unknown[] = [];
|
| 27 |
+
let vendorMessageHandler:
|
| 28 |
+
| ((event: MessageEvent) => void | Promise<void>)
|
| 29 |
+
| null = null;
|
| 30 |
+
|
| 31 |
+
const forwardOrQueueMessage = (event: MessageEvent) => {
|
| 32 |
+
if (vendorMessageHandler) {
|
| 33 |
+
void vendorMessageHandler(event);
|
| 34 |
+
return;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
queuedMessages.push(event.data);
|
| 38 |
+
console.info("[PocketBootstrap] queued message", {
|
| 39 |
+
type:
|
| 40 |
+
typeof event.data === "object" &&
|
| 41 |
+
event.data !== null &&
|
| 42 |
+
"type" in (event.data as Record<string, unknown>)
|
| 43 |
+
? (event.data as { type?: unknown }).type
|
| 44 |
+
: "unknown",
|
| 45 |
+
});
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
self.addEventListener("message", forwardOrQueueMessage);
|
| 49 |
+
|
| 50 |
+
void (async () => {
|
| 51 |
+
try {
|
| 52 |
+
const vendorWorkerUrl = new URL(
|
| 53 |
+
"/pocket-tts/inference-worker.js",
|
| 54 |
+
self.location.origin,
|
| 55 |
+
).toString();
|
| 56 |
+
console.info("[PocketBootstrap] importing vendor worker");
|
| 57 |
+
await import(/* @vite-ignore */ vendorWorkerUrl);
|
| 58 |
+
console.info("[PocketBootstrap] vendor worker imported");
|
| 59 |
+
|
| 60 |
+
if (typeof self.onmessage !== "function") {
|
| 61 |
+
throw new Error("Vendor worker did not install a message handler.");
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
vendorMessageHandler = self.onmessage.bind(self);
|
| 65 |
+
console.info("[PocketBootstrap] vendor handler ready", {
|
| 66 |
+
queuedMessages: queuedMessages.length,
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
while (queuedMessages.length > 0) {
|
| 70 |
+
const data = queuedMessages.shift();
|
| 71 |
+
await vendorMessageHandler(
|
| 72 |
+
new MessageEvent("message", {
|
| 73 |
+
data,
|
| 74 |
+
}),
|
| 75 |
+
);
|
| 76 |
+
}
|
| 77 |
+
} catch (error) {
|
| 78 |
+
console.error("[PocketBootstrap] vendor worker import failed", error);
|
| 79 |
+
postBootstrapError(error);
|
| 80 |
+
throw error;
|
| 81 |
+
}
|
| 82 |
+
})();
|
| 83 |
+
|
| 84 |
+
export {};
|
tests/capabilities.test.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, test } from "bun:test";
|
| 2 |
+
|
| 3 |
+
import { evaluateCapabilitySnapshot } from "../src/services/capabilities";
|
| 4 |
+
|
| 5 |
+
describe("evaluateCapabilitySnapshot", () => {
|
| 6 |
+
test("fails closed without media devices", () => {
|
| 7 |
+
expect(
|
| 8 |
+
evaluateCapabilitySnapshot({
|
| 9 |
+
hasWebGPU: true,
|
| 10 |
+
hasMediaDevices: false,
|
| 11 |
+
hasAudioWorklet: true,
|
| 12 |
+
hasCrossOriginIsolation: false,
|
| 13 |
+
}),
|
| 14 |
+
).toEqual({
|
| 15 |
+
hasWebGPU: true,
|
| 16 |
+
hasMediaDevices: false,
|
| 17 |
+
hasAudioWorklet: true,
|
| 18 |
+
hasCrossOriginIsolation: false,
|
| 19 |
+
canRunDemo: false,
|
| 20 |
+
failureReason: "This browser does not expose microphone capture APIs.",
|
| 21 |
+
});
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
test("fails closed without webgpu", () => {
|
| 25 |
+
expect(
|
| 26 |
+
evaluateCapabilitySnapshot({
|
| 27 |
+
hasWebGPU: false,
|
| 28 |
+
hasMediaDevices: true,
|
| 29 |
+
hasAudioWorklet: true,
|
| 30 |
+
hasCrossOriginIsolation: false,
|
| 31 |
+
}).canRunDemo,
|
| 32 |
+
).toBe(false);
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
test("passes when all hard requirements exist", () => {
|
| 36 |
+
expect(
|
| 37 |
+
evaluateCapabilitySnapshot({
|
| 38 |
+
hasWebGPU: true,
|
| 39 |
+
hasMediaDevices: true,
|
| 40 |
+
hasAudioWorklet: true,
|
| 41 |
+
hasCrossOriginIsolation: true,
|
| 42 |
+
}),
|
| 43 |
+
).toEqual({
|
| 44 |
+
hasWebGPU: true,
|
| 45 |
+
hasMediaDevices: true,
|
| 46 |
+
hasAudioWorklet: true,
|
| 47 |
+
hasCrossOriginIsolation: true,
|
| 48 |
+
canRunDemo: true,
|
| 49 |
+
failureReason: null,
|
| 50 |
+
});
|
| 51 |
+
});
|
| 52 |
+
});
|
tests/controller.test.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, mock, test } from "bun:test";
|
| 2 |
+
|
| 3 |
+
import { ConversationController } from "../src/app/controller";
|
| 4 |
+
import { AppStore } from "../src/app/store";
|
| 5 |
+
import { createInitialState } from "../src/app/types";
|
| 6 |
+
|
| 7 |
+
const createDependencies = () => {
|
| 8 |
+
const state = {
|
| 9 |
+
capturedOnce: false,
|
| 10 |
+
};
|
| 11 |
+
const playbackStream = {
|
| 12 |
+
enqueue: mock(async () => {}),
|
| 13 |
+
finish: mock(async () => {}),
|
| 14 |
+
stop: mock(() => {}),
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
return {
|
| 18 |
+
capabilities: {
|
| 19 |
+
detect: mock(async () => ({
|
| 20 |
+
hasWebGPU: true,
|
| 21 |
+
hasMediaDevices: true,
|
| 22 |
+
hasAudioWorklet: true,
|
| 23 |
+
hasCrossOriginIsolation: true,
|
| 24 |
+
canRunDemo: true,
|
| 25 |
+
failureReason: null,
|
| 26 |
+
})),
|
| 27 |
+
},
|
| 28 |
+
audioCapture: {
|
| 29 |
+
ensureMicrophonePermission: mock(async () => "granted" as const),
|
| 30 |
+
listenForUtterance: mock(() => {
|
| 31 |
+
if (!state.capturedOnce) {
|
| 32 |
+
state.capturedOnce = true;
|
| 33 |
+
return Promise.resolve(new Blob(["input"], { type: "audio/webm" }));
|
| 34 |
+
}
|
| 35 |
+
return new Promise<Blob>(() => {});
|
| 36 |
+
}),
|
| 37 |
+
recordForDuration: mock(async () => new Blob(["calibration"], { type: "audio/webm" })),
|
| 38 |
+
stop: mock(() => {}),
|
| 39 |
+
dispose: mock(async () => {}),
|
| 40 |
+
},
|
| 41 |
+
asr: {
|
| 42 |
+
initialize: mock(async () => {}),
|
| 43 |
+
warmup: mock(async () => {}),
|
| 44 |
+
transcribe: mock(async () => ({ text: "What's the weather?" })),
|
| 45 |
+
},
|
| 46 |
+
llm: {
|
| 47 |
+
initialize: mock(async () => {}),
|
| 48 |
+
warmup: mock(async () => {}),
|
| 49 |
+
generate: mock(async ({ onChunk }: { onChunk: (chunk: string) => void }) => {
|
| 50 |
+
onChunk("It's 84");
|
| 51 |
+
onChunk(" degrees.");
|
| 52 |
+
return "It's 84 degrees.";
|
| 53 |
+
}),
|
| 54 |
+
},
|
| 55 |
+
tts: {
|
| 56 |
+
initialize: mock(async () => {}),
|
| 57 |
+
warmup: mock(async () => {}),
|
| 58 |
+
bootstrapFromUtterance: mock(async () => ({
|
| 59 |
+
voiceProfile: {
|
| 60 |
+
source: "first-utterance" as const,
|
| 61 |
+
ready: true,
|
| 62 |
+
referenceAudioKey: "first",
|
| 63 |
+
embeddingCacheKey: "embed",
|
| 64 |
+
lastUpdatedAt: 1,
|
| 65 |
+
},
|
| 66 |
+
})),
|
| 67 |
+
synthesizeStream: mock(
|
| 68 |
+
async ({
|
| 69 |
+
onAudioChunk,
|
| 70 |
+
}: {
|
| 71 |
+
onAudioChunk: (chunk: Float32Array) => void | Promise<void>;
|
| 72 |
+
}) => {
|
| 73 |
+
await onAudioChunk(new Float32Array([0.1, 0.2, 0.1]));
|
| 74 |
+
},
|
| 75 |
+
),
|
| 76 |
+
synthesize: mock(async () => new Blob(["audio"], { type: "audio/wav" })),
|
| 77 |
+
},
|
| 78 |
+
playback: {
|
| 79 |
+
createStream: mock(() => playbackStream),
|
| 80 |
+
play: mock(async (_blob: Blob, options?: { onEnded?: () => void }) => {
|
| 81 |
+
options?.onEnded?.();
|
| 82 |
+
}),
|
| 83 |
+
stop: mock(() => {}),
|
| 84 |
+
},
|
| 85 |
+
persistence: {
|
| 86 |
+
load: mock(() => null),
|
| 87 |
+
save: mock(() => {}),
|
| 88 |
+
clear: mock(() => {}),
|
| 89 |
+
},
|
| 90 |
+
};
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
describe("ConversationController", () => {
|
| 94 |
+
test("requires consent before starting", async () => {
|
| 95 |
+
const store = new AppStore(createInitialState());
|
| 96 |
+
const controller = new ConversationController(store, createDependencies());
|
| 97 |
+
|
| 98 |
+
await controller.bootstrap();
|
| 99 |
+
await controller.start();
|
| 100 |
+
|
| 101 |
+
expect(store.getState().errorMessage).toBe(
|
| 102 |
+
"Allow microphone access and local audio processing to begin.",
|
| 103 |
+
);
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
test("requires benchmark before starting a session", async () => {
|
| 107 |
+
const store = new AppStore(createInitialState());
|
| 108 |
+
const controller = new ConversationController(store, createDependencies());
|
| 109 |
+
|
| 110 |
+
await controller.bootstrap();
|
| 111 |
+
controller.setConsentAccepted(true);
|
| 112 |
+
await controller.start();
|
| 113 |
+
|
| 114 |
+
expect(store.getState().errorMessage).toBe(
|
| 115 |
+
"Run Microphone Calibration before starting a session.",
|
| 116 |
+
);
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
test("keeps benchmark required after hydrating a saved session", async () => {
|
| 120 |
+
const store = new AppStore(createInitialState());
|
| 121 |
+
const deps = createDependencies();
|
| 122 |
+
deps.persistence.load = mock(() => ({
|
| 123 |
+
version: 1,
|
| 124 |
+
turns: [
|
| 125 |
+
{
|
| 126 |
+
id: "saved-user-turn",
|
| 127 |
+
role: "user" as const,
|
| 128 |
+
transcript: "Saved prompt",
|
| 129 |
+
audioStatus: "none" as const,
|
| 130 |
+
createdAt: 1,
|
| 131 |
+
isFirstContact: true,
|
| 132 |
+
},
|
| 133 |
+
],
|
| 134 |
+
voiceProfile: {
|
| 135 |
+
source: "cached" as const,
|
| 136 |
+
ready: true,
|
| 137 |
+
referenceAudioKey: "saved-reference",
|
| 138 |
+
embeddingCacheKey: "saved-embedding",
|
| 139 |
+
lastUpdatedAt: 1,
|
| 140 |
+
},
|
| 141 |
+
lastOpenedAt: 1,
|
| 142 |
+
}));
|
| 143 |
+
const controller = new ConversationController(store, deps);
|
| 144 |
+
|
| 145 |
+
await controller.bootstrap();
|
| 146 |
+
|
| 147 |
+
expect(store.getState().turns).toHaveLength(1);
|
| 148 |
+
expect(store.getState().supportsResume).toBe(true);
|
| 149 |
+
expect(store.getState().runtimeReady).toBe(false);
|
| 150 |
+
expect(store.getState().statusText).toBe("Run Microphone Calibration");
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
+
test("uses microphone calibration to prime the voice profile", async () => {
|
| 154 |
+
const store = new AppStore(createInitialState());
|
| 155 |
+
const deps = createDependencies();
|
| 156 |
+
const controller = new ConversationController(store, deps);
|
| 157 |
+
|
| 158 |
+
await controller.bootstrap();
|
| 159 |
+
controller.setConsentAccepted(true);
|
| 160 |
+
await controller.runBenchmark();
|
| 161 |
+
|
| 162 |
+
expect(store.getState().voiceProfile.ready).toBe(true);
|
| 163 |
+
expect(store.getState().benchmarkSummary).toContain("Calibration ready.");
|
| 164 |
+
expect(deps.audioCapture.recordForDuration).toHaveBeenCalled();
|
| 165 |
+
expect(deps.tts.bootstrapFromUtterance).toHaveBeenCalledTimes(1);
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
test("runs a first-contact cycle and persists it", async () => {
|
| 169 |
+
const store = new AppStore(createInitialState());
|
| 170 |
+
const deps = createDependencies();
|
| 171 |
+
const controller = new ConversationController(store, deps);
|
| 172 |
+
|
| 173 |
+
await controller.bootstrap();
|
| 174 |
+
controller.setConsentAccepted(true);
|
| 175 |
+
await controller.runBenchmark();
|
| 176 |
+
await controller.start();
|
| 177 |
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
| 178 |
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
| 179 |
+
|
| 180 |
+
expect(store.getState().turns).toHaveLength(2);
|
| 181 |
+
expect(store.getState().turns[0]?.transcript).toBe("What's the weather?");
|
| 182 |
+
expect(store.getState().turns[1]?.transcript).toBe("It's 84 degrees.");
|
| 183 |
+
expect(store.getState().firstContactComplete).toBe(true);
|
| 184 |
+
expect(store.getState().runtimeReady).toBe(true);
|
| 185 |
+
expect(store.getState().phase).toBe("idle");
|
| 186 |
+
expect(store.getState().statusText).toBe("Ready");
|
| 187 |
+
expect(deps.persistence.save).toHaveBeenCalled();
|
| 188 |
+
expect(deps.tts.bootstrapFromUtterance).toHaveBeenCalledTimes(1);
|
| 189 |
+
expect(deps.tts.synthesizeStream).toHaveBeenCalled();
|
| 190 |
+
expect(deps.playback.createStream).toHaveBeenCalled();
|
| 191 |
+
});
|
| 192 |
+
|
| 193 |
+
test("sends the LLM a prompt that ends on the user turn", async () => {
|
| 194 |
+
const store = new AppStore(createInitialState());
|
| 195 |
+
const deps = createDependencies();
|
| 196 |
+
const controller = new ConversationController(store, deps);
|
| 197 |
+
|
| 198 |
+
await controller.bootstrap();
|
| 199 |
+
controller.setConsentAccepted(true);
|
| 200 |
+
await controller.runBenchmark();
|
| 201 |
+
await controller.start();
|
| 202 |
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
| 203 |
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
| 204 |
+
|
| 205 |
+
const request = deps.llm.generate.mock.calls[0]?.[0] as
|
| 206 |
+
| { turns?: Array<{ role: string; transcript: string }> }
|
| 207 |
+
| undefined;
|
| 208 |
+
|
| 209 |
+
expect(request?.turns?.at(-1)?.role).toBe("user");
|
| 210 |
+
expect(request?.turns?.at(-1)?.transcript).toBe("What's the weather?");
|
| 211 |
+
});
|
| 212 |
+
});
|
tests/persistence.test.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, test } from "bun:test";
|
| 2 |
+
|
| 3 |
+
import { LocalPersistenceService } from "../src/services/persistence";
|
| 4 |
+
|
| 5 |
+
const createMemoryStorage = () => {
|
| 6 |
+
const storage = new Map<string, string>();
|
| 7 |
+
return {
|
| 8 |
+
getItem: (key: string) => storage.get(key) ?? null,
|
| 9 |
+
setItem: (key: string, value: string) => {
|
| 10 |
+
storage.set(key, value);
|
| 11 |
+
},
|
| 12 |
+
removeItem: (key: string) => {
|
| 13 |
+
storage.delete(key);
|
| 14 |
+
},
|
| 15 |
+
};
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
describe("LocalPersistenceService", () => {
|
| 19 |
+
test("round-trips persisted session state", () => {
|
| 20 |
+
const service = new LocalPersistenceService(createMemoryStorage());
|
| 21 |
+
|
| 22 |
+
service.save({
|
| 23 |
+
version: 1,
|
| 24 |
+
turns: [],
|
| 25 |
+
voiceProfile: {
|
| 26 |
+
source: "cached",
|
| 27 |
+
ready: true,
|
| 28 |
+
referenceAudioKey: "abc",
|
| 29 |
+
embeddingCacheKey: "embed",
|
| 30 |
+
lastUpdatedAt: 1,
|
| 31 |
+
},
|
| 32 |
+
lastOpenedAt: 42,
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
expect(service.load()).toEqual({
|
| 36 |
+
version: 1,
|
| 37 |
+
turns: [],
|
| 38 |
+
voiceProfile: {
|
| 39 |
+
source: "cached",
|
| 40 |
+
ready: true,
|
| 41 |
+
referenceAudioKey: "abc",
|
| 42 |
+
embeddingCacheKey: "embed",
|
| 43 |
+
lastUpdatedAt: 1,
|
| 44 |
+
},
|
| 45 |
+
lastOpenedAt: 42,
|
| 46 |
+
});
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
test("clears invalid persisted versions", () => {
|
| 50 |
+
const storage = createMemoryStorage();
|
| 51 |
+
const service = new LocalPersistenceService(storage);
|
| 52 |
+
storage.setItem(
|
| 53 |
+
"private-voice-agent/session",
|
| 54 |
+
JSON.stringify({ version: 999 }),
|
| 55 |
+
);
|
| 56 |
+
|
| 57 |
+
expect(service.load()).toBeNull();
|
| 58 |
+
expect(storage.getItem("private-voice-agent/session")).toBeNull();
|
| 59 |
+
});
|
| 60 |
+
});
|
tsconfig.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
// Environment setup & latest features
|
| 4 |
+
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
| 5 |
+
"target": "ESNext",
|
| 6 |
+
"module": "Preserve",
|
| 7 |
+
"moduleDetection": "force",
|
| 8 |
+
"jsx": "react-jsx",
|
| 9 |
+
"allowJs": true,
|
| 10 |
+
|
| 11 |
+
// Bundler mode
|
| 12 |
+
"moduleResolution": "bundler",
|
| 13 |
+
"allowImportingTsExtensions": true,
|
| 14 |
+
"verbatimModuleSyntax": true,
|
| 15 |
+
"noEmit": true,
|
| 16 |
+
|
| 17 |
+
// Best practices
|
| 18 |
+
"strict": true,
|
| 19 |
+
"skipLibCheck": true,
|
| 20 |
+
"noFallthroughCasesInSwitch": true,
|
| 21 |
+
"noUncheckedIndexedAccess": true,
|
| 22 |
+
"noImplicitOverride": true,
|
| 23 |
+
|
| 24 |
+
// Some stricter flags (disabled by default)
|
| 25 |
+
"noUnusedLocals": false,
|
| 26 |
+
"noUnusedParameters": false,
|
| 27 |
+
"noPropertyAccessFromIndexSignature": false
|
| 28 |
+
}
|
| 29 |
+
}
|
vite.config.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ServerResponse } from "node:http";
|
| 2 |
+
import { defineConfig, type Plugin } from "vite";
|
| 3 |
+
|
| 4 |
+
const ISOLATION_HEADERS = {
|
| 5 |
+
"Cross-Origin-Opener-Policy": "same-origin",
|
| 6 |
+
"Cross-Origin-Embedder-Policy": "require-corp",
|
| 7 |
+
"Cross-Origin-Resource-Policy": "cross-origin",
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
const applyHeaders = (response: ServerResponse) => {
|
| 11 |
+
for (const [key, value] of Object.entries(ISOLATION_HEADERS)) {
|
| 12 |
+
response.setHeader(key, value);
|
| 13 |
+
}
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
const healthcheckPlugin = (): Plugin => ({
|
| 17 |
+
name: "healthcheck",
|
| 18 |
+
configureServer(server) {
|
| 19 |
+
server.middlewares.use("/health", (_request, response) => {
|
| 20 |
+
applyHeaders(response);
|
| 21 |
+
response.statusCode = 200;
|
| 22 |
+
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
| 23 |
+
response.end("ok");
|
| 24 |
+
});
|
| 25 |
+
},
|
| 26 |
+
configurePreviewServer(server) {
|
| 27 |
+
server.middlewares.use("/health", (_request, response) => {
|
| 28 |
+
applyHeaders(response);
|
| 29 |
+
response.statusCode = 200;
|
| 30 |
+
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
| 31 |
+
response.end("ok");
|
| 32 |
+
});
|
| 33 |
+
},
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
export default defineConfig({
|
| 37 |
+
publicDir: "src/vendor",
|
| 38 |
+
plugins: [healthcheckPlugin()],
|
| 39 |
+
server: {
|
| 40 |
+
headers: ISOLATION_HEADERS,
|
| 41 |
+
host: "0.0.0.0",
|
| 42 |
+
port: 3000,
|
| 43 |
+
strictPort: true,
|
| 44 |
+
},
|
| 45 |
+
preview: {
|
| 46 |
+
headers: ISOLATION_HEADERS,
|
| 47 |
+
host: "0.0.0.0",
|
| 48 |
+
port: 3000,
|
| 49 |
+
strictPort: true,
|
| 50 |
+
},
|
| 51 |
+
build: {
|
| 52 |
+
outDir: "dist",
|
| 53 |
+
},
|
| 54 |
+
optimizeDeps: {
|
| 55 |
+
exclude: ["@mlc-ai/web-llm"],
|
| 56 |
+
},
|
| 57 |
+
});
|