Spaces:
Running
Running
Deploy prebuilt static bundle
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +0 -34
- CLAUDE.md +0 -106
- README.md +0 -76
- assets/index-D37VpK1p.css +1 -0
- assets/index-DFLBea7M.js +0 -0
- assets/index-oatRNbfa.js +14 -0
- src/vendor/onnxruntime-web/ort-wasm-simd-threaded.jsep.wasm → assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
- assets/transformers.web-Cny4Tgyw.js +0 -0
- bun.lock +0 -332
- docs/specs/browser-voice-agent-arg/core-flows.md +0 -181
- docs/specs/browser-voice-agent-arg/epic-brief.md +0 -104
- docs/specs/browser-voice-agent-arg/tech-plan.md +0 -229
- docs/specs/browser-voice-agent-arg/tickets.md +0 -284
- index.html +2 -1
- {src/vendor/onnxruntime-web → onnxruntime-web}/ort-wasm-simd-threaded.jsep.mjs +0 -0
- onnxruntime-web/ort-wasm-simd-threaded.jsep.wasm +3 -0
- {src/vendor/onnxruntime-web → onnxruntime-web}/ort-wasm-simd-threaded.mjs +0 -0
- {src/vendor/onnxruntime-web → onnxruntime-web}/ort-wasm-simd-threaded.wasm +0 -0
- {src/vendor/onnxruntime-web → onnxruntime-web}/ort.min.mjs +0 -0
- package.json +0 -27
- {src/vendor/pocket-tts → pocket-tts}/CODE-LICENSE +0 -0
- {src/vendor/pocket-tts → pocket-tts}/inference-worker.js +0 -0
- {src/vendor/pocket-tts → pocket-tts}/onnx/flow_lm_flow_int8.onnx +0 -0
- {src/vendor/pocket-tts → pocket-tts}/onnx/flow_lm_main_int8.onnx +0 -0
- {src/vendor/pocket-tts → pocket-tts}/onnx/mimi_decoder_int8.onnx +0 -0
- {src/vendor/pocket-tts → pocket-tts}/onnx/mimi_encoder.onnx +0 -0
- {src/vendor/pocket-tts → pocket-tts}/onnx/text_conditioner.onnx +0 -0
- {src/vendor/pocket-tts → pocket-tts}/sentencepiece.js +0 -0
- {src/vendor/pocket-tts → pocket-tts}/tokenizer.model +0 -0
- {src/vendor/pocket-tts → pocket-tts}/voices.bin +0 -0
- src/adapters/asr.ts +0 -129
- src/adapters/llm.ts +0 -182
- src/adapters/tts.ts +0 -155
- src/app/controller.ts +0 -584
- src/app/store.ts +0 -128
- src/app/types.ts +0 -240
- src/main.ts +0 -189
- src/prompts/system.ts +0 -7
- src/services/audio-capture.ts +0 -291
- src/services/capabilities.ts +0 -58
- src/services/persistence.ts +0 -52
- src/services/playback.ts +0 -257
- src/services/pocket-runtime.ts +0 -609
- src/styles.css +0 -227
- src/workers/pocket-bootstrap.ts +0 -84
- style.css +0 -28
- tests/capabilities.test.ts +0 -52
- tests/controller.test.ts +0 -212
- tests/persistence.test.ts +0 -60
- tsconfig.json +0 -29
.gitignore
DELETED
|
@@ -1,34 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,106 +0,0 @@
|
|
| 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
|
@@ -4,85 +4,9 @@ emoji: 🎙️
|
|
| 4 |
colorFrom: indigo
|
| 5 |
colorTo: purple
|
| 6 |
sdk: static
|
| 7 |
-
app_build_command: npm run build && cp -r dist/* .
|
| 8 |
app_file: 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.
|
|
|
|
| 4 |
colorFrom: indigo
|
| 5 |
colorTo: purple
|
| 6 |
sdk: static
|
|
|
|
| 7 |
app_file: index.html
|
| 8 |
custom_headers:
|
| 9 |
cross-origin-embedder-policy: require-corp
|
| 10 |
cross-origin-opener-policy: same-origin
|
| 11 |
cross-origin-resource-policy: cross-origin
|
| 12 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/index-D37VpK1p.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
:root{color-scheme:light;--bg: #f5f4fa;--card: rgba(255, 255, 255, .96);--border: rgba(124, 99, 186, .14);--text: #1e1a2e;--muted: #6e687f;--accent: #7457cf;--accent-soft: rgba(116, 87, 207, .12);--danger: #9c3951;--shadow: 0 22px 40px rgba(63, 47, 111, .12);--radius-xl: 28px;--radius-lg: 20px;--radius-md: 14px;font-family:IBM Plex Sans,Segoe UI,sans-serif}*{box-sizing:border-box}html,body{min-height:100%;margin:0;background:radial-gradient(circle at top,rgba(138,115,221,.16),transparent 28%),linear-gradient(180deg,#faf9fd 0%,var(--bg) 100%);color:var(--text)}body{padding:24px 16px}button{font:inherit}.app-shell{min-height:calc(100vh - 48px);display:grid;place-items:center}.view{width:min(720px,100%)}.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-xl);box-shadow:var(--shadow);padding:28px}.calibration-card{display:grid;gap:16px}.assistant-card{display:grid;gap:18px;min-height:min(78vh,760px)}.eyebrow{margin:0;color:var(--accent);font-size:.78rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.card-title{margin:0;font-size:clamp(2rem,4vw,3rem);line-height:.95}.card-copy,.timer-text,.summary-text,.transcript-empty,.turn-header{color:var(--muted)}.card-copy,.prompt-block,.timer-text,.summary-text{margin:0;line-height:1.5}.prompt-block{padding:18px;border-radius:var(--radius-lg);background:var(--accent-soft);color:var(--text);font-weight:600;white-space:pre-wrap}.meter{height:12px;border-radius:999px;background:#7457cf1f;overflow:hidden}.meter-fill{width:0;height:100%;border-radius:inherit;background:linear-gradient(180deg,#8b71dd,#6842c3);transition:width .12s linear}.button-row{display:flex;justify-content:center}.primary-button{appearance:none;border:0;border-radius:999px;padding:14px 24px;min-width:188px;background:linear-gradient(180deg,#8b71dd,#6842c3);color:#fff;font-weight:700;cursor:pointer;box-shadow:0 14px 24px #6842c33d;transition:opacity .12s ease,transform .12s ease}.primary-button:hover{transform:translateY(-1px)}.primary-button:disabled{opacity:.5;cursor:not-allowed;transform:none}.transcript-log{min-height:420px;max-height:58vh;overflow:auto;display:flex;flex-direction:column;gap:12px}.transcript-empty{min-height:100%;display:grid;place-items:center;text-align:center;padding:32px 24px;border-radius:var(--radius-lg);background:#7457cf0f}.turn{padding:14px 16px;border-radius:var(--radius-md);background:#7457cf0d;border:1px solid rgba(124,99,186,.1)}.turn[data-role=assistant]{background:#7457cf17}.turn-header{display:flex;justify-content:space-between;gap:12px;margin-bottom:8px;font-size:.76rem;text-transform:uppercase;letter-spacing:.06em}.turn-body{white-space:pre-wrap;line-height:1.5}.error-box{padding:14px 16px;border-radius:var(--radius-md);background:#9c395114;border:1px solid rgba(156,57,81,.16);color:var(--danger)}@media(max-width:720px){body{padding:18px 12px}.card{padding:22px 18px}.transcript-log{min-height:360px}.primary-button{width:100%}}
|
assets/index-DFLBea7M.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
assets/index-oatRNbfa.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))n(r);new MutationObserver(r=>{for(const s of r)if(s.type==="childList")for(const a of s.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&n(a)}).observe(document,{childList:!0,subtree:!0});function t(r){const s={};return r.integrity&&(s.integrity=r.integrity),r.referrerPolicy&&(s.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?s.credentials="include":r.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function n(r){if(r.ep)return;r.ep=!0;const s=t(r);fetch(r.href,s)}})();const q=1,$=20,oe=$*1e3,ce="Read the following lines clearly at a comfortable speaking pace until calibration is complete. Repeat from the beginning if time remains.",le=`The quick brown fox jumps over the lazy dog.
|
| 2 |
+
I am speaking in my natural voice at a comfortable volume.
|
| 3 |
+
The sound of my own voice has never bothered me.
|
| 4 |
+
A recording is never quite the same as the original.
|
| 5 |
+
I confirm this is a true representation of how I speak.
|
| 6 |
+
My speech remains clear when I maintain a consistent distance from the microphone.
|
| 7 |
+
I will continue speaking until the calibration timer reaches zero.
|
| 8 |
+
This sample should capture my normal pronunciation and pacing.`,ue=()=>({hasWebGPU:!1,hasMediaDevices:!1,hasAudioWorklet:!1,hasCrossOriginIsolation:!1,canRunDemo:!1,failureReason:"Checking browser capabilities."}),L=()=>({source:"first-utterance",ready:!1,referenceAudioKey:null,embeddingCacheKey:null,lastUpdatedAt:null}),de=()=>({llmModelId:"Qwen2.5-0.5B-Instruct-q4f16_1-MLC",asrModelId:"Xenova/whisper-tiny.en",ttsModelId:"/pocket-tts",maxTurnsPersisted:8,targetFirstAudioMs:2500}),j=(i=de())=>({capability:ue(),phase:"idle",micPermission:"unknown",consentAccepted:!1,firstContactComplete:!1,turns:[],voiceProfile:L(),statusText:"Idle",errorMessage:null,runtimeReady:!1,supportsResume:!1,lastOpenedAt:null,calibrationProgress:0,calibrationSecondsRemaining:null,calibrationRecording:!1,benchmarkSummary:null,config:i}),X=i=>i.capability.canRunDemo?i.runtimeReady?i.capability.hasCrossOriginIsolation?"Ready":"Ready (single-threaded)":"Run Microphone Calibration":i.capability.failureReason??"Unsupported";class he{#e;#t=new Set;constructor(e=j()){this.#e=e}getState(){return this.#e}subscribe(e){return this.#t.add(e),e(this.#e),()=>{this.#t.delete(e)}}setState(e){const t=this.#e,n=typeof e=="function"?e(t):e;if(n!==t){this.#e=n;for(const r of this.#t)r(this.#e)}}update(e){this.setState(t=>({...t,...e}))}appendTurn(e){this.setState(t=>({...t,turns:[...t.turns,e]}))}updateTurn(e,t){this.setState(n=>({...n,turns:n.turns.map(r=>r.id===e?{...r,...t}:r)}))}hydrate(e){this.setState(t=>({...t,turns:e.turns,voiceProfile:e.voiceProfile,supportsResume:e.turns.length>0||e.voiceProfile.ready,lastOpenedAt:e.lastOpenedAt,firstContactComplete:e.turns.some(n=>n.isFirstContact),statusText:X(t)}))}setVoiceProfile(e){this.update({voiceProfile:e})}clearConversation(){this.setState(e=>({...e,phase:"idle",firstContactComplete:!1,turns:[],voiceProfile:e.runtimeReady?e.voiceProfile:L(),errorMessage:null,statusText:X(e),supportsResume:!1,lastOpenedAt:null,calibrationProgress:e.runtimeReady?1:0,calibrationSecondsRemaining:e.runtimeReady?0:null,calibrationRecording:!1}))}toPersistedSession(){const e=this.getState();return{version:q,turns:e.turns.slice(-e.config.maxTurnsPersisted),voiceProfile:e.voiceProfile,lastOpenedAt:Date.now()}}}const O=(i,e,t={})=>({id:crypto.randomUUID(),role:i,transcript:e,audioStatus:t.audioStatus??"none",createdAt:Date.now(),isFirstContact:t.isFirstContact??!1}),_=160,me=24,pe=520,ge=i=>i.trim().split(/\s+/).filter(Boolean).length,fe=i=>{const e=/[.!?]+["')\]]*(?=\s|$)/.exec(i);return!e||e.index==null?-1:e.index+e[0].length},be=i=>{if(i.length<_||ge(i)<me)return-1;const e=i.lastIndexOf(" ");return e>=Math.floor(_*.94)?e:-1},G=(i,e=!1)=>{let t=i.replace(/^\s+/,"");const n=[];for(;t.trim();){const r=fe(t);if(r>0){const a=t.slice(0,r).trim();a&&n.push(a),t=t.slice(r).replace(/^\s+/,"");continue}if(!e)break;const s=t.trim();s&&n.push(s),t="";break}return{segments:n,remainder:t}},v=i=>{const e=i.replace(/^\s+/,""),t=be(e);return t<=0?{segment:null,remainder:e}:{segment:e.slice(0,t).trim(),remainder:e.slice(t).replace(/^\s+/,"")}};class we{#e;#t;#i=null;#r=null;constructor(e,t){this.#e=e,this.#t=t}get state(){return this.#e.getState()}async bootstrap(){const e=await this.#t.capabilities.detect();this.#e.update({capability:e,runtimeReady:!1,statusText:e.canRunDemo?"Run Microphone Calibration":"Unsupported"});const t=this.#t.persistence.load();t&&this.#e.hydrate(t)}setConsentAccepted(e){this.#e.update({consentAccepted:e})}async runBenchmark(){const e=this.#e.getState(),t=n=>{this.#e.getState().phase==="benchmarking"&&this.#e.update({calibrationProgress:n.progress,calibrationSecondsRemaining:Math.max(0,Math.ceil(n.remainingMs/1e3)),calibrationRecording:n.isRecording})};if(e.phase!=="benchmarking"){if(!e.consentAccepted){this.#e.update({errorMessage:"Allow microphone access and local audio processing to calibrate the system.",phase:"error",statusText:"Consent required",runtimeReady:!1});return}if(!e.capability.canRunDemo){this.#e.update({errorMessage:e.capability.failureReason,phase:"error",statusText:"Unsupported",runtimeReady:!1});return}this.#e.update({phase:"benchmarking",statusText:"Microphone calibration",errorMessage:null,runtimeReady:!1,benchmarkSummary:null,calibrationProgress:0,calibrationSecondsRemaining:$,calibrationRecording:!1,voiceProfile:L()});try{const n=await this.#t.audioCapture.ensureMicrophonePermission();if(this.#e.update({micPermission:n}),n!=="granted")throw new Error("Microphone access was denied. Retry when the browser permission is available.");const r=new AbortController,s=(async()=>{await this.#t.asr.initialize(),await this.#t.asr.warmup?.(),await this.#t.llm.initialize(),await this.#t.llm.warmup?.(),await this.#t.tts.initialize(),await this.#t.tts.warmup?.()})().catch(c=>{throw r.abort(),c});this.#e.update({statusText:"Microphone calibration",calibrationRecording:!0});const a=await this.#t.audioCapture.recordForDuration({durationMs:oe,signal:r.signal,onProgress:t});this.#e.update({calibrationRecording:!1,calibrationProgress:1,calibrationSecondsRemaining:0,statusText:"Finalizing calibration"}),await s,await this.#t.asr.transcribe(a),this.#e.update({statusText:"Finalizing calibration"});const o=await this.#t.tts.bootstrapFromUtterance(a);this.#e.setVoiceProfile(o.voiceProfile),this.#e.update({phase:"idle",statusText:e.capability.hasCrossOriginIsolation?"Ready":"Ready (single-threaded)",runtimeReady:!0,errorMessage:null,benchmarkSummary:"Calibration ready. Captured 20 seconds of local audio and completed the transcription check."})}catch(n){this.#e.update({phase:"error",statusText:"Calibration failed",runtimeReady:!1,calibrationRecording:!1,errorMessage:n instanceof Error?n.message:"Microphone calibration failed."})}}}async start(){const e=this.#e.getState();if(e.phase==="arming"||e.phase==="listening"||e.phase==="thinking"||e.phase==="speaking"){this.#o();return}if(!e.consentAccepted){this.#e.update({errorMessage:"Allow microphone access and local audio processing to begin.",phase:"error",statusText:"Consent required"});return}if(!e.capability.canRunDemo){this.#e.update({errorMessage:e.capability.failureReason,phase:"error",statusText:"Unsupported"});return}if(!e.runtimeReady){this.#e.update({phase:"error",statusText:"Calibration required",errorMessage:"Run Microphone Calibration before starting a session."});return}this.#i?.abort(),this.#i=new AbortController,this.#e.update({errorMessage:null,phase:"arming",statusText:"Arming microphone"});const t=await this.#t.audioCapture.ensureMicrophonePermission();if(this.#e.update({micPermission:t}),t!=="granted"){this.#e.update({phase:"error",statusText:"Microphone denied",errorMessage:"Microphone access was denied. Retry when the browser permission is available."});return}this.#r=this.#s(this.#i.signal),this.#r}async clear(){this.#o(),this.#t.persistence.clear(),this.#e.clearConversation()}async dispose(){await this.clear(),await this.#t.audioCapture.dispose()}async#s(e){try{this.#e.update({phase:"listening",statusText:"Listening",errorMessage:null});const t=await this.#t.audioCapture.listenForUtterance({signal:e});if(e.aborted)return;await this.#n(t,e)}catch(t){if(e.aborted)return;this.#e.update({phase:"error",statusText:"Runtime failed",errorMessage:t instanceof Error?t.message:"Audio or model runtime failed."})}finally{this.#i?.signal===e&&(this.#i=null),this.#r&&(this.#r=null)}}async#n(e,t){this.#e.update({phase:"thinking",statusText:"Transcribing",errorMessage:null});const n=await this.#t.asr.transcribe(e),r=O("user",n.text);this.#e.appendTurn(r);const s=this.#e.getState().turns;let a=this.#e.getState().voiceProfile;const o=a.ready?Promise.resolve({voiceProfile:a}):this.#t.tts.bootstrapFromUtterance(e);a.ready||this.#e.update({statusText:"Thinking"});const c=O("assistant","",{audioStatus:"queued",isFirstContact:!this.#e.getState().firstContactComplete});this.#e.appendTurn(c),this.#e.update({statusText:"Thinking"});const l=this.#t.playback.createStream({signal:t,onEnded:()=>{this.#e.updateTurn(c.id,{audioStatus:"done"})}});let u="",d=null,h=!1,p=Promise.resolve();const m=async()=>{if(a.ready)return;a=(await o).voiceProfile,this.#e.setVoiceProfile(a)},g=f=>{const y=f.trim();y&&(p=p.then(async()=>{t.aborted||(await m(),await this.#t.tts.synthesizeStream({text:y,signal:t,onAudioChunk:async M=>{h||(h=!0,this.#e.update({phase:"speaking",statusText:"Speaking"}),this.#e.updateTurn(c.id,{audioStatus:"playing"})),await l.enqueue(M)}}))}))},N=()=>{d!=null&&(globalThis.clearTimeout(d),d=null)},U=()=>{d!=null||t.aborted||(d=globalThis.setTimeout(()=>{d=null;const f=v(u);f.segment&&(u=f.remainder,g(f.segment),v(u).segment&&U())},pe))},ae=await this.#t.llm.generate({turns:s,signal:t,onChunk:f=>{this.#e.updateTurn(c.id,{transcript:`${this.#a(c.id)}${f}`}),u+=f;const y=G(u);u=y.remainder,y.segments.length>0&&N();for(const M of y.segments)g(M);v(u).segment&&U()}});this.#e.updateTurn(c.id,{transcript:ae}),N();const Z=v(u);Z.segment&&(u=Z.remainder,g(Z.segment));const F=G(u,!0);u=F.remainder;for(const f of F.segments)g(f);await m(),await p,await l.finish(),h||this.#e.updateTurn(c.id,{audioStatus:"done"}),this.#e.update({firstContactComplete:!0,phase:"idle",statusText:this.#l()}),this.#t.persistence.save(this.#e.toPersistedSession())}#a(e){return this.#e.getState().turns.find(t=>t.id===e)?.transcript??""}#l(){return this.#e.getState().capability.hasCrossOriginIsolation?"Ready":"Ready (single-threaded)"}#o(){this.#i?.abort(),this.#i=null,this.#t.audioCapture.stop(),this.#t.playback.stop(),this.#e.update({phase:"idle",statusText:this.#l()})}}const ye="modulepreload",Ce=function(i){return"/"+i},D={},Q=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let c=function(l){return Promise.all(l.map(u=>Promise.resolve(u).then(d=>({status:"fulfilled",value:d}),d=>({status:"rejected",reason:d}))))};document.getElementsByTagName("link");const a=document.querySelector("meta[property=csp-nonce]"),o=a?.nonce||a?.getAttribute("nonce");r=c(t.map(l=>{if(l=Ce(l),l in D)return;D[l]=!0;const u=l.endsWith(".css"),d=u?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${l}"]${d}`))return;const h=document.createElement("link");if(h.rel=u?"stylesheet":ye,u||(h.as="script"),h.crossOrigin="",h.href=l,o&&h.setAttribute("nonce",o),document.head.appendChild(h),u)return new Promise((p,m)=>{h.addEventListener("load",p),h.addEventListener("error",()=>m(new Error(`Unable to preload CSS for ${l}`)))})}))}function s(a){const o=new Event("vite:preloadError",{cancelable:!0});if(o.payload=a,window.dispatchEvent(o),!o.defaultPrevented)throw a}return r.then(a=>{for(const o of a||[])o.status==="rejected"&&s(o.reason);return e().catch(s)})},K=i=>{switch(i.status){case"initiate":return`Preparing ${i.file??"speech model"}...`;case"download":return`Downloading ${i.file??"speech model"}...`;case"progress":return`Downloading ${i.file??"speech model"} (${Math.round(i.progress??0)}%)`;case"done":return`Loaded ${i.file??"speech model"}.`;case"ready":return`Speech model ready (${i.model??i.task??"loaded"}).`;default:return"Loading speech model..."}};class Ie{constructor(e){this.modelId=e}#e=null;#t="wasm";async initialize(e={}){if(this.#e)return;const{env:t,pipeline:n}=await Q(async()=>{const{env:s,pipeline:a}=await import("./transformers.web-Cny4Tgyw.js");return{env:s,pipeline:a}},[]);t.allowLocalModels=!1,t.useFS=!1,t.useFSCache=!1,t.useBrowserCache=!0;const r=typeof navigator<"u"&&"gpu"in navigator?"webgpu":"wasm";try{e.onProgress?.(`Loading ${this.modelId} on ${r.toUpperCase()}...`),this.#e=await n("automatic-speech-recognition",this.modelId,{device:r,dtype:r==="webgpu"?"fp32":"q8",progress_callback:s=>{e.onProgress?.(K(s))}}),this.#t=r}catch(s){if(r!=="webgpu")throw s;console.warn("ASR WebGPU initialization failed, falling back to WASM.",s),e.onProgress?.("Speech WebGPU setup failed. Falling back to WASM..."),this.#e=await n("automatic-speech-recognition",this.modelId,{device:"wasm",dtype:"q8",progress_callback:a=>{e.onProgress?.(K(a))}}),this.#t="wasm"}e.onProgress?.(`Speech ready on ${this.#t.toUpperCase()}.`)}async warmup(e={}){await this.initialize(e),e.onProgress?.("Speech runtime warmed.")}async transcribe(e){if(await this.initialize(),!this.#e)throw new Error("ASR pipeline failed to initialize.");const t=URL.createObjectURL(e);try{const n=await this.#e(t),r=typeof n=="string"?n:n.text??"";if(!r.trim())throw new Error("Speech recognition returned an empty transcript.");return{text:r.trim()}}finally{URL.revokeObjectURL(t)}}getDevice(){return this.#t}}const H=`You are a deadpan browser voice agent demo.
|
| 9 |
+
|
| 10 |
+
Respond directly, briefly, and clearly. Sound like a capable local assistant, not a character.
|
| 11 |
+
Do not mention that you are part of a game, experiment, fiction, or narrative.
|
| 12 |
+
Do not call attention to the user's cloned voice.
|
| 13 |
+
Do not add theatrical flourishes or emotional commentary unless the user explicitly asks for it.
|
| 14 |
+
`,Se=["Qwen2.5-0.5B-Instruct-q4f16_1-MLC","SmolLM2-360M-Instruct-q4f16_1-MLC","Llama-3.2-1B-Instruct-q4f16_1-MLC","Qwen2.5-1.5B-Instruct-q4f16_1-MLC"],Ae=i=>{const e=typeof i.progress=="number"?`${Math.round(i.progress*100)}%`:null;return i.text&&e?`${i.text} (${e})`:i.text?i.text:e?`Loading language model (${e})`:"Loading language model..."};class T{constructor(e){this.preferredModelId=e}#e=null;#t=null;async initialize(e={}){if(this.#e){e.onProgress?.(`Language ready (${this.#t??this.preferredModelId??"model loaded"}).`);return}const t=await Q(()=>import("./index-DFLBea7M.js"),[]),n=this.preferredModelId??T.#r(t);e.onProgress?.(`Preparing ${n}...`),this.#e=await t.CreateMLCEngine(n,{initProgressCallback:r=>{e.onProgress?.(Ae(r))}}),this.#t=n,e.onProgress?.(`Language ready (${n}).`)}async generate(e){if(await this.initialize(),!this.#e)throw new Error("LLM engine failed to initialize.");const t=T.#i(e.turns),n=await this.#e.chat.completions.create({messages:t,temperature:.7,stream:!0});let r="";for await(const s of n){if(e.signal?.aborted)break;const a=s.choices[0]?.delta?.content??"";a&&(r+=a,e.onChunk(a))}return r.trim()}async warmup(e={}){if(await this.initialize(e),!this.#e)throw new Error("LLM engine failed to initialize.");e.onProgress?.("Compiling first language response..."),await this.#e.chat.completions.create({messages:[{role:"system",content:H},{role:"user",content:"Reply with one short word."}],temperature:0,max_tokens:1}),e.onProgress?.("Language runtime warmed.")}static#i(e){const t=[{role:"system",content:H}];for(const n of e)n.role==="system"||!n.transcript.trim()||t.push({role:n.role,content:n.transcript});return t}static#r(e){const n=(e.prebuiltAppConfig?.model_list??[]).map(s=>{const a="model_id"in s?s.model_id:"";return{candidate:s,modelId:a}}).filter(s=>!!s.modelId);for(const s of Se){const a=n.find(o=>o.modelId.includes(s));if(a)return a.modelId}const r=n.at(0)?.modelId;if(!r)throw new Error("WebLLM did not expose any prebuilt models.");return r}}const A=24e3,ve=A*10,Re="/pocket-tts",ke=new URL("data:video/mp2t;base64,Y29uc29sZS5pbmZvKCJbUG9ja2V0Qm9vdHN0cmFwXSB3b3JrZXIgbW9kdWxlIHN0YXJ0aW5nIik7CgpzZWxmLmFkZEV2ZW50TGlzdGVuZXIoImVycm9yIiwgKGV2ZW50KSA9PiB7CiAgY29uc29sZS5lcnJvcigiW1BvY2tldEJvb3RzdHJhcF0gZXJyb3IgZXZlbnQiLCB7CiAgICBtZXNzYWdlOiBldmVudC5tZXNzYWdlLAogICAgZmlsZW5hbWU6IGV2ZW50LmZpbGVuYW1lLAogICAgbGluZW5vOiBldmVudC5saW5lbm8sCiAgICBjb2xubzogZXZlbnQuY29sbm8sCiAgfSk7Cn0pOwoKc2VsZi5hZGRFdmVudExpc3RlbmVyKCJ1bmhhbmRsZWRyZWplY3Rpb24iLCAoZXZlbnQpID0+IHsKICBjb25zb2xlLmVycm9yKCJbUG9ja2V0Qm9vdHN0cmFwXSB1bmhhbmRsZWQgcmVqZWN0aW9uIiwgZXZlbnQucmVhc29uKTsKfSk7Cgpjb25zdCBwb3N0Qm9vdHN0cmFwRXJyb3IgPSAoZXJyb3I6IHVua25vd24pID0+IHsKICBjb25zdCBtZXNzYWdlID0KICAgIGVycm9yIGluc3RhbmNlb2YgRXJyb3IgPyBgJHtlcnJvci5uYW1lfTogJHtlcnJvci5tZXNzYWdlfWAgOiBTdHJpbmcoZXJyb3IpOwoKICBzZWxmLnBvc3RNZXNzYWdlKHsKICAgIHR5cGU6ICJlcnJvciIsCiAgICBlcnJvcjogYFBvY2tldCBib290c3RyYXAgZmFpbGVkOiAke21lc3NhZ2V9YCwKICB9KTsKfTsKCmNvbnN0IHF1ZXVlZE1lc3NhZ2VzOiB1bmtub3duW10gPSBbXTsKbGV0IHZlbmRvck1lc3NhZ2VIYW5kbGVyOgogIHwgKChldmVudDogTWVzc2FnZUV2ZW50KSA9PiB2b2lkIHwgUHJvbWlzZTx2b2lkPikKICB8IG51bGwgPSBudWxsOwoKY29uc3QgZm9yd2FyZE9yUXVldWVNZXNzYWdlID0gKGV2ZW50OiBNZXNzYWdlRXZlbnQpID0+IHsKICBpZiAodmVuZG9yTWVzc2FnZUhhbmRsZXIpIHsKICAgIHZvaWQgdmVuZG9yTWVzc2FnZUhhbmRsZXIoZXZlbnQpOwogICAgcmV0dXJuOwogIH0KCiAgcXVldWVkTWVzc2FnZXMucHVzaChldmVudC5kYXRhKTsKICBjb25zb2xlLmluZm8oIltQb2NrZXRCb290c3RyYXBdIHF1ZXVlZCBtZXNzYWdlIiwgewogICAgdHlwZToKICAgICAgdHlwZW9mIGV2ZW50LmRhdGEgPT09ICJvYmplY3QiICYmCiAgICAgIGV2ZW50LmRhdGEgIT09IG51bGwgJiYKICAgICAgInR5cGUiIGluIChldmVudC5kYXRhIGFzIFJlY29yZDxzdHJpbmcsIHVua25vd24+KQogICAgICAgID8gKGV2ZW50LmRhdGEgYXMgeyB0eXBlPzogdW5rbm93biB9KS50eXBlCiAgICAgICAgOiAidW5rbm93biIsCiAgfSk7Cn07CgpzZWxmLmFkZEV2ZW50TGlzdGVuZXIoIm1lc3NhZ2UiLCBmb3J3YXJkT3JRdWV1ZU1lc3NhZ2UpOwoKdm9pZCAoYXN5bmMgKCkgPT4gewogIHRyeSB7CiAgICBjb25zdCB2ZW5kb3JXb3JrZXJVcmwgPSBuZXcgVVJMKAogICAgICAiL3BvY2tldC10dHMvaW5mZXJlbmNlLXdvcmtlci5qcyIsCiAgICAgIHNlbGYubG9jYXRpb24ub3JpZ2luLAogICAgKS50b1N0cmluZygpOwogICAgY29uc29sZS5pbmZvKCJbUG9ja2V0Qm9vdHN0cmFwXSBpbXBvcnRpbmcgdmVuZG9yIHdvcmtlciIpOwogICAgYXdhaXQgaW1wb3J0KC8qIEB2aXRlLWlnbm9yZSAqLyB2ZW5kb3JXb3JrZXJVcmwpOwogICAgY29uc29sZS5pbmZvKCJbUG9ja2V0Qm9vdHN0cmFwXSB2ZW5kb3Igd29ya2VyIGltcG9ydGVkIik7CgogICAgaWYgKHR5cGVvZiBzZWxmLm9ubWVzc2FnZSAhPT0gImZ1bmN0aW9uIikgewogICAgICB0aHJvdyBuZXcgRXJyb3IoIlZlbmRvciB3b3JrZXIgZGlkIG5vdCBpbnN0YWxsIGEgbWVzc2FnZSBoYW5kbGVyLiIpOwogICAgfQoKICAgIHZlbmRvck1lc3NhZ2VIYW5kbGVyID0gc2VsZi5vbm1lc3NhZ2UuYmluZChzZWxmKTsKICAgIGNvbnNvbGUuaW5mbygiW1BvY2tldEJvb3RzdHJhcF0gdmVuZG9yIGhhbmRsZXIgcmVhZHkiLCB7CiAgICAgIHF1ZXVlZE1lc3NhZ2VzOiBxdWV1ZWRNZXNzYWdlcy5sZW5ndGgsCiAgICB9KTsKCiAgICB3aGlsZSAocXVldWVkTWVzc2FnZXMubGVuZ3RoID4gMCkgewogICAgICBjb25zdCBkYXRhID0gcXVldWVkTWVzc2FnZXMuc2hpZnQoKTsKICAgICAgYXdhaXQgdmVuZG9yTWVzc2FnZUhhbmRsZXIoCiAgICAgICAgbmV3IE1lc3NhZ2VFdmVudCgibWVzc2FnZSIsIHsKICAgICAgICAgIGRhdGEsCiAgICAgICAgfSksCiAgICAgICk7CiAgICB9CiAgfSBjYXRjaCAoZXJyb3IpIHsKICAgIGNvbnNvbGUuZXJyb3IoIltQb2NrZXRCb290c3RyYXBdIHZlbmRvciB3b3JrZXIgaW1wb3J0IGZhaWxlZCIsIGVycm9yKTsKICAgIHBvc3RCb290c3RyYXBFcnJvcihlcnJvcik7CiAgICB0aHJvdyBlcnJvcjsKICB9Cn0pKCk7CgpleHBvcnQge307Cg==",import.meta.url),J=()=>{let i,e;return{promise:new Promise((n,r)=>{i=n,e=r}),resolve:i,reject:e}},Y=i=>i?i.startsWith("https://")||i.startsWith("http://")?i.replace(/\/+$/,""):i.startsWith("/")||i.startsWith("./")||i.startsWith("../")?i.replace(/\/+$/,""):`https://huggingface.co/spaces/${i.replace(/^spaces\//,"")}/resolve/main`:Re,Te=()=>{if(typeof navigator>"u")return!1;const i=navigator.userAgentData?.platform??navigator.platform??navigator.userAgent??"";return/win/i.test(i)},Pe=i=>{if(i.numberOfChannels===1)return i.getChannelData(0).slice();const e=i.length,t=new Float32Array(e);for(let n=0;n<i.numberOfChannels;n+=1){const r=i.getChannelData(n);for(let s=0;s<e;s+=1)t[s]+=r[s]/i.numberOfChannels}return t},Ee=(i,e,t)=>{if(e===t)return i;const n=e/t,r=Math.max(1,Math.round(i.length/n)),s=new Float32Array(r);for(let a=0;a<r;a+=1){const o=a*n,c=Math.floor(o),l=Math.min(c+1,i.length-1),u=o-c;s[a]=i[c]*(1-u)+i[l]*u}return s},Ze=i=>{const e=i.reduce((o,c)=>o+c.length,0),t=new Float32Array(e);let n=0;for(const o of i)t.set(o,n),n+=o.length;const r=new ArrayBuffer(44+t.length*2),s=new DataView(r),a=(o,c)=>{for(let l=0;l<c.length;l+=1)s.setUint8(o+l,c.charCodeAt(l))};a(0,"RIFF"),s.setUint32(4,36+t.length*2,!0),a(8,"WAVE"),a(12,"fmt "),s.setUint32(16,16,!0),s.setUint16(20,1,!0),s.setUint16(22,1,!0),s.setUint32(24,A,!0),s.setUint32(28,A*2,!0),s.setUint16(32,2,!0),s.setUint16(34,16,!0),a(36,"data"),s.setUint32(40,t.length*2,!0);for(let o=0;o<t.length;o+=1){const c=Math.max(-1,Math.min(1,t[o]??0));s.setInt16(44+o*2,c<0?c*32768:c*32767,!0)}return new Blob([r],{type:"audio/wav"})};class Me{#e=null;#t=null;#i=null;#r=null;#s=null;#n=null;#a=Y();#l=!1;#o=!1;#h=null;#u=1;#m=!1;#d=null;#c="Worker not started.";async initialize(e){return this.#a=Y(e.modelId),this.#u=this.#v(),this.#d=e.onProgress??null,this.#i?this.#i:(this.#i=this.#w().catch(t=>{throw this.#i=null,t}),this.#i)}async warmup(e={}){await this.initialize({modelId:this.#a,onProgress:e.onProgress}),!this.#l&&(e.onProgress?.("Compiling voice runtime..."),await this.#f("Benchmark."),this.#l=!0,e.onProgress?.("Voice runtime warmed."))}async bootstrapFromUtterance(e){if(await this.initialize({modelId:this.#a}),!this.#e)throw new Error("Pocket TTS worker failed to initialize.");const t=await this.#I(e),n=J();return this.#s=n,this.#e.postMessage({type:"encode_voice",data:{audio:t}},[t.buffer]),await n.promise,this.#o=!0,{embeddingId:`custom-${Date.now()}`}}async synthesize(e){if(await this.initialize({modelId:this.#a}),e.signal?.aborted)throw new Error("Speech synthesis was cancelled.");if(!this.#o&&e.referenceAudio&&await this.bootstrapFromUtterance(e.referenceAudio),!this.#o&&!this.#h)throw new Error("No Pocket TTS voice is ready.");return this.#f(e.text,e.signal)}async stream(e){if(await this.initialize({modelId:this.#a}),e.signal?.aborted)throw new Error("Speech synthesis was cancelled.");if(!this.#o&&e.referenceAudio&&await this.bootstrapFromUtterance(e.referenceAudio),!this.#o&&!this.#h)throw new Error("No Pocket TTS voice is ready.");await this.#C(e.text,e.onAudioChunk,e.signal)}async#w(){try{await this.#g()}catch(e){if(this.#u>1&&!this.#m){console.warn("Pocket TTS worker failed with multithreaded WASM. Retrying single-threaded.",e),this.#m=!0,this.#u=1,this.#A(),await this.#g();return}throw e}}async#g(){return this.#r=J(),this.#c=`Starting worker (${this.#u} thread${this.#u===1?"":"s"})`,console.info("[PocketRuntime] starting worker",{modelBaseUrl:this.#a,threadCount:this.#u,crossOriginIsolated:globalThis.crossOriginIsolated===!0}),this.#e=new Worker(ke,{type:"module"}),this.#e.onmessage=e=>{this.#y(e.data)},this.#e.onmessageerror=e=>{console.error("[PocketRuntime] worker message error",e),this.#p(new Error(`Pocket TTS worker message error. Last status: ${this.#c}.`))},this.#e.onerror=e=>{const t=[e.filename,e.lineno,e.colno].filter(Boolean).join(":"),n=new Error([e.message||"Pocket TTS worker crashed.",t?`at ${t}`:null,`last status: ${this.#c}`,`thread count: ${this.#u}`].filter(Boolean).join(" "));console.error("[PocketRuntime] worker crashed",{message:e.message,filename:e.filename,lineno:e.lineno,colno:e.colno,lastStatus:this.#c,threadCount:this.#u}),this.#p(n)},console.info("[PocketRuntime] posting load request",{modelBaseUrl:this.#a,threadCount:this.#u}),this.#e.postMessage({type:"load",data:{modelBaseUrl:this.#a,threadCount:this.#u}}),this.#r.promise}#y(e){switch(e.type==="status"&&e.status&&(this.#c=e.status,console.info("[PocketRuntime] worker status",e.status,e.state)),e.type){case"loaded":this.#c="Worker loaded",console.info("[PocketRuntime] worker loaded"),this.#r?.resolve(),this.#r=null;break;case"voices_loaded":this.#c=`Voices loaded (${e.defaultVoice??"none"})`,this.#h=e.defaultVoice??null,this.#h&&this.#d?.(`Voice ready (${this.#h}).`);break;case"voice_encoded":this.#c=`Voice encoded (${e.voiceName??"custom"})`,this.#s?.resolve(),this.#s=null,this.#o=!0,this.#d?.("Voice profile encoded.");break;case"voice_set":this.#c=`Voice selected (${e.voiceName??"unknown"})`,e.voiceName==="custom"&&(this.#o=!0),e.voiceName&&this.#d?.(`Voice selected (${e.voiceName}).`);break;case"audio_chunk":if(this.#n?.chunks&&e.data){const t=this.#n,n=Float32Array.from(e.data);t.mode==="blob"&&t.chunks.push(n),t.onAudioChunk&&(t.chunkTask=t.chunkTask.then(()=>t.onAudioChunk?.(n)).catch(r=>{this.#p(r instanceof Error?r:new Error("Pocket TTS audio streaming failed."))}))}break;case"stream_ended":if(this.#c="Audio stream ended",this.#n){const t=this.#n;this.#n=null,t.abortCleanup?.(),t.chunkTask.finally(()=>{if(t.mode==="blob"){t.resolve(Ze(t.chunks));return}t.resolve()})}break;case"error":this.#c=e.error||"Pocket TTS failed",console.error("[PocketRuntime] worker error",e.error),this.#p(new Error(e.error||"Pocket TTS failed."));break;case"status":e.status&&this.#d?.(e.status);break}}#p(e){if(console.error("[PocketRuntime] rejecting pending work",e),this.#r?.reject(e),this.#r=null,this.#s?.reject(e),this.#s=null,this.#n){const t=this.#n;this.#n=null,t.abortCleanup?.(),t.reject(e)}this.#e?.terminate(),this.#e=null}async#f(e,t){return new Promise((n,r)=>{this.#b({text:e,signal:t,mode:"blob",resolve:n,reject:r})})}async#C(e,t,n){return new Promise((r,s)=>{this.#b({text:e,signal:n,mode:"stream",resolve:r,reject:s,onAudioChunk:t})})}#b(e){if(!this.#e)throw new Error("Pocket TTS worker failed to initialize.");if(this.#n)throw new Error("Pocket TTS only supports one generation at a time.");const t={mode:e.mode,chunks:[],chunkTask:Promise.resolve(),onAudioChunk:e.onAudioChunk,resolve:e.resolve,reject:e.reject};if(e.signal){const n=()=>{this.#n===t&&(this.#e?.postMessage({type:"stop"}),this.#n=null,t.abortCleanup?.(),e.reject(new Error("Speech synthesis was cancelled.")))};e.signal.addEventListener("abort",n,{once:!0}),t.abortCleanup=()=>{e.signal?.removeEventListener("abort",n)}}this.#n=t,this.#e.postMessage({type:"generate",data:{text:e.text}})}async#I(e){const t=await this.#S(),n=await e.arrayBuffer(),r=await t.decodeAudioData(n.slice(0)),s=Pe(r);return Ee(s,r.sampleRate,A).slice(0,ve)}async#S(){return this.#t?this.#t:(this.#t=new AudioContext({sampleRate:A}),this.#t)}#A(){this.#e?.terminate(),this.#e=null,this.#r=null,this.#s=null,this.#n=null,this.#c="Worker reset"}#v(){return typeof navigator>"u"||globalThis.crossOriginIsolated!==!0?1:Te()?(this.#d?.("Voice runtime is using single-threaded mode on Windows for compatibility."),1):Math.min(navigator.hardwareConcurrency||4,8)}}const We=i=>({source:i?"cached":"first-utterance",ready:!0,referenceAudioKey:i??"first-utterance",embeddingCacheKey:i,lastUpdatedAt:Date.now()});class Ve{constructor(e){this.modelId=e}#e=null;#t=null;#i=null;async initialize(e={}){if(this.#e){e.onProgress?.("Voice runtime ready.");return}const t=typeof window<"u"?window.__PRIVATE_VOICE_POCKET_TTS__??(this.#e??=new Me):null;if(!t)throw new Error("Speech runtime is unavailable in this browser environment.");await t.initialize?.({modelId:this.modelId,onProgress:e.onProgress}),this.#e=t}async warmup(e={}){await this.initialize(e),await this.#e?.warmup?.({onProgress:e.onProgress})}async bootstrapFromUtterance(e){if(await this.initialize(),!this.#e)throw new Error("Speech runtime is unavailable.");this.#t=e;const t=await this.#e.bootstrapFromUtterance(e),n=We(t.embeddingId??null);return this.#i=n,{voiceProfile:n}}async synthesize(e){if(await this.initialize(),e.signal?.aborted)throw new Error("Speech synthesis was cancelled.");if(!this.#e)throw new Error("Speech runtime is unavailable.");const t=e.referenceAudio??this.#t;if(!t)throw new Error("No voice reference is available for synthesis.");return this.#e.synthesize({text:e.text,signal:e.signal,referenceAudio:t})}async synthesizeStream(e){if(await this.initialize(),e.signal?.aborted)throw new Error("Speech synthesis was cancelled.");if(!this.#e?.stream)throw new Error("Streaming speech synthesis is unavailable.");const t=e.referenceAudio??this.#t;if(!t&&!this.#i?.ready)throw new Error("No voice reference is available for synthesis.");await this.#e.stream({text:e.text,signal:e.signal,referenceAudio:t,onAudioChunk:e.onAudioChunk})}}const xe=.024,Be=900,Le=12e3;class S{#e=null;#t=null;#i=null;#r=null;#s=null;async ensureMicrophonePermission(){try{return await this.#n(),"granted"}catch{return"denied"}}async listenForUtterance(e={}){const t=await this.#n();if(typeof MediaRecorder>"u")throw new Error("MediaRecorder is unavailable in this browser.");const n=S.#a(),r=n?new MediaRecorder(t,{mimeType:n}):new MediaRecorder(t);this.#s=r;const s=[];let a=!1,o=0,c=0,l=0;r.ondataavailable=m=>{m.data.size>0&&s.push(m.data)};const u=()=>{cancelAnimationFrame(l),r.state!=="inactive"&&r.stop()};e.signal?.addEventListener("abort",()=>{u()},{once:!0});const d=new Float32Array(this.#i?.fftSize??2048),h=()=>{if(!this.#i){l=requestAnimationFrame(h);return}this.#i.getFloatTimeDomainData(d);const m=S.#l(d),g=performance.now();if(m>xe&&(a=!0,o=g),a&&o>0&&g-o>=Be){u();return}if(!a&&g-c>=Le){u();return}l=requestAnimationFrame(h)},p=await new Promise((m,g)=>{r.onstop=()=>{if(cancelAnimationFrame(l),s.length===0){g(new Error("No audio was captured."));return}m(new Blob(s,{type:r.mimeType||"audio/webm"}))},r.onerror=()=>{cancelAnimationFrame(l),g(new Error("Audio capture failed."))},c=performance.now(),r.start(250),l=requestAnimationFrame(h)});return this.#s=null,p}async recordForDuration(e){const t=await this.#n();if(typeof MediaRecorder>"u")throw new Error("MediaRecorder is unavailable in this browser.");const n=S.#a(),r=n?new MediaRecorder(t,{mimeType:n}):new MediaRecorder(t);this.#s=r;const s=[],a=Math.max(1e3,e.durationMs);let o=0,c=0,l=0,u=!1;r.ondataavailable=p=>{p.data.size>0&&s.push(p.data)};const d=(p,m)=>{e.onProgress?.({progress:Math.max(0,Math.min(1,p/a)),elapsedMs:p,remainingMs:Math.max(0,a-p),isRecording:m})},h=()=>{window.clearTimeout(o),window.clearInterval(c),r.state!=="inactive"&&r.stop()};e.signal?.addEventListener("abort",()=>{u=!0,h()},{once:!0});try{return await new Promise((p,m)=>{r.onstop=()=>{if(window.clearTimeout(o),window.clearInterval(c),u){m(new Error("Microphone calibration was cancelled."));return}if(s.length===0){m(new Error("No microphone calibration audio was captured."));return}d(a,!1),p(new Blob(s,{type:r.mimeType||"audio/webm"}))},r.onerror=()=>{window.clearTimeout(o),window.clearInterval(c),m(new Error("Microphone calibration recording failed."))},l=performance.now(),d(0,!0),r.start(250),o=window.setTimeout(()=>{h()},a),c=window.setInterval(()=>{const g=Math.min(a,performance.now()-l);d(g,!0)},100)})}finally{this.#s=null}}stop(){this.#s&&this.#s.state!=="inactive"&&this.#s.stop()}async dispose(){this.stop(),this.#e?.getTracks().forEach(e=>e.stop()),this.#e=null,this.#r?.disconnect(),this.#r=null,this.#i?.disconnect(),this.#i=null,await this.#t?.close(),this.#t=null}async#n(){if(this.#e)return this.#e;const e=await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0}});return this.#e=e,this.#t=new AudioContext,this.#r=this.#t.createMediaStreamSource(e),this.#i=this.#t.createAnalyser(),this.#i.fftSize=2048,this.#r.connect(this.#i),e}static#a(){const e=["audio/webm;codecs=opus","audio/mp4","audio/webm"];for(const t of e)if(MediaRecorder.isTypeSupported(t))return t}static#l(e){let t=0;for(const n of e)t+=n*n;return Math.sqrt(t/e.length)}}const ze=i=>i.hasMediaDevices?i.hasWebGPU?i.hasAudioWorklet?{...i,canRunDemo:!0,failureReason:null}:{...i,canRunDemo:!1,failureReason:"AudioWorklet support is unavailable, so live capture is unreliable."}:{...i,canRunDemo:!1,failureReason:"WebGPU is unavailable, so local model execution cannot start."}:{...i,canRunDemo:!1,failureReason:"This browser does not expose microphone capture APIs."};class Ne{async detect(){const e={hasWebGPU:typeof navigator<"u"&&"gpu"in navigator,hasMediaDevices:typeof navigator<"u"&&typeof navigator.mediaDevices?.getUserMedia=="function",hasAudioWorklet:typeof window<"u"&&typeof window.AudioWorkletNode<"u",hasCrossOriginIsolation:typeof globalThis<"u"&&globalThis.crossOriginIsolated===!0};return ze(e)}}const W="private-voice-agent/session";class Ue{#e;constructor(e=typeof localStorage<"u"?localStorage:null){this.#e=e}load(){if(!this.#e)return null;const e=this.#e.getItem(W);if(!e)return null;try{const t=JSON.parse(e);return t.version!==q?(this.clear(),null):t}catch{return this.clear(),null}}save(e){this.#e&&this.#e.setItem(W,JSON.stringify(e))}clear(){this.#e?.removeItem(W)}}const R=24e3,Fe=.45,Xe=()=>{let i,e;return{promise:new Promise((n,r)=>{i=n,e=r}),resolve:i,reject:e}};class Oe{#e=null;#t=0;#i=!1;#r=!1;#s=!1;#n=new Set;#a=[];#l=0;#o=Xe();#h;constructor(e={}){this.#h=e.onEnded??null,e.signal?.addEventListener("abort",()=>{this.stop()},{once:!0})}async enqueue(e){this.#i||this.#r||e.length===0||(this.#a.push(e),this.#l+=e.length/R,this.#u())}async finish(){return this.#i||this.#r?this.#o.promise:(this.#r=!0,this.#u(!0),this.#d(),this.#o.promise)}stop(){if(!this.#i){this.#i=!0,this.#a=[],this.#l=0;for(const e of this.#n)try{e.stop()}catch{}this.#n.clear(),this.#d()}}#u(e=!1){if(!(this.#a.length===0||this.#i)&&!(!this.#s&&!e&&this.#l<Fe))for(this.#s=!0;this.#a.length>0;){const t=this.#a.shift();t&&(this.#l=Math.max(0,this.#l-t.length/R),this.#m(t))}}#m(e){const t=this.#c();t.state==="suspended"&&t.resume().catch(()=>{});const n=t.createBuffer(1,e.length,R);n.getChannelData(0).set(e);const r=t.createBufferSource();r.buffer=n,r.connect(t.destination);const s=Math.max(this.#t,t.currentTime+.02);this.#t=s+n.duration,r.onended=()=>{this.#n.delete(r),this.#d()},this.#n.add(r),r.start(s)}#d(){if(!(this.#i||this.#r&&this.#a.length===0&&this.#n.size===0))return;this.#h?.(),this.#h=null;const t=this.#e;this.#e=null,t&&t.state!=="closed"&&t.close().catch(()=>{}),this.#o.resolve()}#c(){return this.#e?this.#e:(this.#e=new AudioContext({sampleRate:R}),this.#e)}}class _e{#e=null;#t=null;#i=null;#r=null;#s=null;async play(e,t={}){this.stop();const n=URL.createObjectURL(e),r=new Audio(n);this.#e=r,this.#t=n;const s=()=>{this.#e===r&&(this.#e=null),this.#t===n&&(URL.revokeObjectURL(n),this.#t=null),this.#r===s&&(this.#r=null),this.#i&&(this.#i=null)};this.#r=s,t.signal&&t.signal.addEventListener("abort",()=>{this.stop()},{once:!0}),await new Promise((a,o)=>{this.#i=()=>{t.onEnded?.(),s(),a()},r.onended=()=>{this.#i?.()},r.onerror=()=>{s(),o(new Error("Audio playback failed."))},r.play().catch(c=>{s(),o(c)})})}createStream(e={}){return this.stop(),this.#s=new Oe(e),this.#s}stop(){this.#s?.stop(),this.#s=null,this.#e&&(this.#e.pause(),this.#e.currentTime=0,this.#e=null);const e=this.#r,t=this.#i;this.#r=null,this.#i=null,e?.(),t?.()}}const w=new he(j()),C=new we(w,{capabilities:new Ne,audioCapture:new S,asr:new Ie(w.getState().config.asrModelId),llm:new T(w.getState().config.llmModelId),tts:new Ve(w.getState().config.ttsModelId),playback:new _e,persistence:new Ue});let b="calibration";const ee=document.querySelector("#calibration-view"),te=document.querySelector("#assistant-view"),P=document.querySelector("#calibration-button"),ie=document.querySelector("#calibration-copy"),re=document.querySelector("#calibration-prompt"),ne=document.querySelector("#calibration-fill"),se=document.querySelector("#calibration-timer"),V=document.querySelector("#benchmark-summary"),x=document.querySelector("#calibration-error"),k=document.querySelector("#transcript-log"),I=document.querySelector("#transcript-empty"),E=document.querySelector("#record-button"),B=document.querySelector("#assistant-error");if(!ee||!te||!P||!ie||!re||!ne||!se||!V||!x||!k||!I||!E||!B)throw new Error("The demo shell is missing required DOM nodes.");const z=()=>{const i=w.getState();if(ee.hidden=b!=="calibration",te.hidden=b!=="assistant",ie.textContent=i.runtimeReady?"Calibration is complete. Continue when you're ready to open the assistant.":i.phase==="benchmarking"?"Calibration is running now. Keep reading the prompt clearly until the timer ends.":ce,re.textContent=le,ne.style.width=`${Math.round(i.calibrationProgress*100)}%`,se.textContent=i.phase==="benchmarking"?i.calibrationRecording?`${i.calibrationSecondsRemaining??0}s remaining`:"Finalizing calibration...":i.runtimeReady?"Calibration complete.":"Calibration required.",V.hidden=!i.benchmarkSummary,V.textContent=i.benchmarkSummary??"",P.disabled=i.phase==="benchmarking",P.textContent=i.phase==="benchmarking"?"Calibrating...":i.runtimeReady?"Continue":"Calibrate",k.replaceChildren(),i.turns.length===0)I.hidden=!1,I.textContent=i.runtimeReady?'Press Record and say "Hello, my name is _____."':"Complete calibration first.",k.append(I);else{I.hidden=!0;for(const e of i.turns){const t=document.createElement("article");t.className="turn",t.dataset.role=e.role;const n=document.createElement("div");n.className="turn-header",n.innerHTML=`<span>${e.role}</span><span>${e.audioStatus}</span>`;const r=document.createElement("div");r.className="turn-body",r.textContent=e.transcript||"…",t.append(n,r),k.append(t)}}E.disabled=!i.runtimeReady||i.phase==="benchmarking",E.textContent=i.phase==="listening"||i.phase==="arming"?"Stop Recording":i.phase==="thinking"||i.phase==="speaking"?"Stop":"Record",x.hidden=b!=="calibration"||!i.errorMessage,x.textContent=b==="calibration"?i.errorMessage??"":"",B.hidden=b!=="assistant"||!i.errorMessage,B.textContent=b==="assistant"?i.errorMessage??"":""};P.addEventListener("click",()=>{if(C.setConsentAccepted(!0),w.getState().runtimeReady){b="assistant",z();return}C.runBenchmark()});E.addEventListener("click",()=>{C.setConsentAccepted(!0),C.start()});window.addEventListener("beforeunload",()=>{C.dispose()});w.subscribe(()=>{z()});C.bootstrap().then(()=>{z()});
|
src/vendor/onnxruntime-web/ort-wasm-simd-threaded.jsep.wasm → assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm
RENAMED
|
File without changes
|
assets/transformers.web-Cny4Tgyw.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
bun.lock
DELETED
|
@@ -1,332 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,181 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,104 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,229 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,284 +0,0 @@
|
|
| 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
|
@@ -11,7 +11,8 @@
|
|
| 11 |
name="description"
|
| 12 |
content="Local browser voice assistant with on-device speech processing."
|
| 13 |
/>
|
| 14 |
-
<script type="module" src="
|
|
|
|
| 15 |
</head>
|
| 16 |
<body>
|
| 17 |
<main class="app-shell">
|
|
|
|
| 11 |
name="description"
|
| 12 |
content="Local browser voice assistant with on-device speech processing."
|
| 13 |
/>
|
| 14 |
+
<script type="module" crossorigin src="/assets/index-oatRNbfa.js"></script>
|
| 15 |
+
<link rel="stylesheet" crossorigin href="/assets/index-D37VpK1p.css">
|
| 16 |
</head>
|
| 17 |
<body>
|
| 18 |
<main class="app-shell">
|
{src/vendor/onnxruntime-web → onnxruntime-web}/ort-wasm-simd-threaded.jsep.mjs
RENAMED
|
File without changes
|
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 → onnxruntime-web}/ort-wasm-simd-threaded.mjs
RENAMED
|
File without changes
|
{src/vendor/onnxruntime-web → onnxruntime-web}/ort-wasm-simd-threaded.wasm
RENAMED
|
File without changes
|
{src/vendor/onnxruntime-web → onnxruntime-web}/ort.min.mjs
RENAMED
|
File without changes
|
package.json
DELETED
|
@@ -1,27 +0,0 @@
|
|
| 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/vendor/pocket-tts → pocket-tts}/CODE-LICENSE
RENAMED
|
File without changes
|
{src/vendor/pocket-tts → pocket-tts}/inference-worker.js
RENAMED
|
File without changes
|
{src/vendor/pocket-tts → pocket-tts}/onnx/flow_lm_flow_int8.onnx
RENAMED
|
File without changes
|
{src/vendor/pocket-tts → pocket-tts}/onnx/flow_lm_main_int8.onnx
RENAMED
|
File without changes
|
{src/vendor/pocket-tts → pocket-tts}/onnx/mimi_decoder_int8.onnx
RENAMED
|
File without changes
|
{src/vendor/pocket-tts → pocket-tts}/onnx/mimi_encoder.onnx
RENAMED
|
File without changes
|
{src/vendor/pocket-tts → pocket-tts}/onnx/text_conditioner.onnx
RENAMED
|
File without changes
|
{src/vendor/pocket-tts → pocket-tts}/sentencepiece.js
RENAMED
|
File without changes
|
{src/vendor/pocket-tts → pocket-tts}/tokenizer.model
RENAMED
|
File without changes
|
{src/vendor/pocket-tts → pocket-tts}/voices.bin
RENAMED
|
File without changes
|
src/adapters/asr.ts
DELETED
|
@@ -1,129 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,182 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,155 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,584 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,128 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,240 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,189 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,7 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,291 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,58 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,52 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,257 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,609 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,227 +0,0 @@
|
|
| 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/workers/pocket-bootstrap.ts
DELETED
|
@@ -1,84 +0,0 @@
|
|
| 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 {};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
style.css
DELETED
|
@@ -1,28 +0,0 @@
|
|
| 1 |
-
body {
|
| 2 |
-
padding: 2rem;
|
| 3 |
-
font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
|
| 4 |
-
}
|
| 5 |
-
|
| 6 |
-
h1 {
|
| 7 |
-
font-size: 16px;
|
| 8 |
-
margin-top: 0;
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
p {
|
| 12 |
-
color: rgb(107, 114, 128);
|
| 13 |
-
font-size: 15px;
|
| 14 |
-
margin-bottom: 10px;
|
| 15 |
-
margin-top: 5px;
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
.card {
|
| 19 |
-
max-width: 620px;
|
| 20 |
-
margin: 0 auto;
|
| 21 |
-
padding: 16px;
|
| 22 |
-
border: 1px solid lightgray;
|
| 23 |
-
border-radius: 16px;
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
.card p:last-child {
|
| 27 |
-
margin-bottom: 0;
|
| 28 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/capabilities.test.ts
DELETED
|
@@ -1,52 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,212 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,60 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,29 +0,0 @@
|
|
| 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|