@missionsquad/browserai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +229 -0
- package/dist/audio/clip.d.ts +51 -0
- package/dist/audio/clip.d.ts.map +1 -0
- package/dist/audio/clip.js +71 -0
- package/dist/audio/clip.js.map +1 -0
- package/dist/audio/decode.d.ts +7 -0
- package/dist/audio/decode.d.ts.map +1 -0
- package/dist/audio/decode.js +19 -0
- package/dist/audio/decode.js.map +1 -0
- package/dist/audio/pcm.d.ts +13 -0
- package/dist/audio/pcm.d.ts.map +1 -0
- package/dist/audio/pcm.js +66 -0
- package/dist/audio/pcm.js.map +1 -0
- package/dist/audio/recorder.d.ts +40 -0
- package/dist/audio/recorder.d.ts.map +1 -0
- package/dist/audio/recorder.js +127 -0
- package/dist/audio/recorder.js.map +1 -0
- package/dist/audio/streaming-player.d.ts +29 -0
- package/dist/audio/streaming-player.d.ts.map +1 -0
- package/dist/audio/streaming-player.js +82 -0
- package/dist/audio/streaming-player.js.map +1 -0
- package/dist/client.d.ts +195 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +638 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +81 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +19 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +52 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +74 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +18 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +43 -0
- package/dist/events.js.map +1 -0
- package/dist/generation.d.ts +12 -0
- package/dist/generation.d.ts.map +1 -0
- package/dist/generation.js +30 -0
- package/dist/generation.js.map +1 -0
- package/dist/hardware.d.ts +23 -0
- package/dist/hardware.d.ts.map +1 -0
- package/dist/hardware.js +150 -0
- package/dist/hardware.js.map +1 -0
- package/dist/history.d.ts +81 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +193 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/inference.d.ts +89 -0
- package/dist/inference.d.ts.map +1 -0
- package/dist/inference.js +411 -0
- package/dist/inference.js.map +1 -0
- package/dist/json.d.ts +22 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +145 -0
- package/dist/json.js.map +1 -0
- package/dist/metrics.d.ts +18 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +59 -0
- package/dist/metrics.js.map +1 -0
- package/dist/model-cache.d.ts +49 -0
- package/dist/model-cache.d.ts.map +1 -0
- package/dist/model-cache.js +511 -0
- package/dist/model-cache.js.map +1 -0
- package/dist/model-source.d.ts +27 -0
- package/dist/model-source.d.ts.map +1 -0
- package/dist/model-source.js +110 -0
- package/dist/model-source.js.map +1 -0
- package/dist/models.d.ts +103 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +1052 -0
- package/dist/models.js.map +1 -0
- package/dist/multimodal.d.ts +261 -0
- package/dist/multimodal.d.ts.map +1 -0
- package/dist/multimodal.js +506 -0
- package/dist/multimodal.js.map +1 -0
- package/dist/prompt.d.ts +13 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +32 -0
- package/dist/prompt.js.map +1 -0
- package/dist/proxy-verify.d.ts +10 -0
- package/dist/proxy-verify.d.ts.map +1 -0
- package/dist/proxy-verify.js +98 -0
- package/dist/proxy-verify.js.map +1 -0
- package/dist/schema-validator.d.ts +6 -0
- package/dist/schema-validator.d.ts.map +1 -0
- package/dist/schema-validator.js +104 -0
- package/dist/schema-validator.js.map +1 -0
- package/dist/schema.d.ts +98 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +115 -0
- package/dist/schema.js.map +1 -0
- package/dist/text-utils.d.ts +8 -0
- package/dist/text-utils.d.ts.map +1 -0
- package/dist/text-utils.js +14 -0
- package/dist/text-utils.js.map +1 -0
- package/dist/voice/helpers.d.ts +35 -0
- package/dist/voice/helpers.d.ts.map +1 -0
- package/dist/voice/helpers.js +49 -0
- package/dist/voice/helpers.js.map +1 -0
- package/dist/voice/voice-pipeline.d.ts +155 -0
- package/dist/voice/voice-pipeline.d.ts.map +1 -0
- package/dist/voice/voice-pipeline.js +407 -0
- package/dist/voice/voice-pipeline.js.map +1 -0
- package/dist/webllm.worker.d.ts +2 -0
- package/dist/webllm.worker.d.ts.map +1 -0
- package/dist/webllm.worker.js +8 -0
- package/dist/webllm.worker.js.map +1 -0
- package/package.json +62 -0
- package/src/audio/clip.ts +94 -0
- package/src/audio/decode.ts +19 -0
- package/src/audio/pcm.ts +62 -0
- package/src/audio/recorder.ts +146 -0
- package/src/audio/streaming-player.ts +99 -0
- package/src/client.ts +784 -0
- package/src/config.ts +104 -0
- package/src/errors.ts +81 -0
- package/src/events.ts +53 -0
- package/src/generation.ts +39 -0
- package/src/hardware.ts +193 -0
- package/src/history.ts +257 -0
- package/src/index.ts +203 -0
- package/src/inference.ts +579 -0
- package/src/json.ts +169 -0
- package/src/metrics.ts +63 -0
- package/src/model-cache.ts +637 -0
- package/src/model-source.ts +142 -0
- package/src/models.ts +1171 -0
- package/src/multimodal.ts +697 -0
- package/src/prompt.ts +33 -0
- package/src/proxy-verify.ts +113 -0
- package/src/schema-validator.ts +117 -0
- package/src/schema.ts +118 -0
- package/src/text-utils.ts +12 -0
- package/src/voice/helpers.ts +59 -0
- package/src/voice/voice-pipeline.ts +513 -0
- package/src/webllm.worker.ts +9 -0
- package/worker-template/README.md +39 -0
- package/worker-template/cloudflare-worker.ts +389 -0
- package/worker-template/wrangler.jsonc +19 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mission Squad
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
14
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# @missionsquad/browserai
|
|
2
|
+
|
|
3
|
+
Run small open AI models **entirely in the browser** with WebGPU — no inference server — as an
|
|
4
|
+
embeddable, UI-free TypeScript SDK. BrowserAI packages a curated 47-model catalog, model
|
|
5
|
+
loading/downloading with progress, capability-slot management, three inference runtimes, streaming,
|
|
6
|
+
schema-constrained JSON generation, run metrics, browser-cache management, and a complete
|
|
7
|
+
press-to-talk voice-assistant pipeline. Drop it into any frontend site and build your own UI on top.
|
|
8
|
+
|
|
9
|
+
Three inference runtimes:
|
|
10
|
+
|
|
11
|
+
- **WebLLM (MLC)** for quantized MLC text models, with schema-constrained JSON decoding and detailed
|
|
12
|
+
per-token runtime telemetry.
|
|
13
|
+
- **Transformers.js + ONNX/WebGPU** for browser-ready ONNX models: text LLMs, Whisper/Moonshine/
|
|
14
|
+
Parakeet transcription, Supertonic speech, SmolVLM/Qwen-VL/OCR vision, Gemma 4 multimodal, and
|
|
15
|
+
audio-LLM ASR (Granite Speech, Voxtral realtime).
|
|
16
|
+
- **kokoro-js** (loaded from its CDN bundle by default) for Kokoro 82M text-to-speech, including
|
|
17
|
+
sentence-by-sentence streaming synthesis.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @missionsquad/browserai
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Requirements: a WebGPU-capable browser (lightweight speech models — Whisper/Moonshine/Parakeet
|
|
26
|
+
transcription and the TTS engines — can fall back to wasm; text, vision, and audio-LLM models
|
|
27
|
+
require WebGPU), Node.js 20+ for development. `@mlc-ai/web-llm` and `@huggingface/transformers` are regular
|
|
28
|
+
dependencies, dynamically imported only when a model that needs them loads — your initial bundle
|
|
29
|
+
stays small.
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { BrowserAI } from "@missionsquad/browserai";
|
|
35
|
+
|
|
36
|
+
const ai = new BrowserAI();
|
|
37
|
+
ai.on("loadprogress", ({ progress, status }) => console.log(progress, status));
|
|
38
|
+
|
|
39
|
+
await ai.load("Qwen3.5-0.8B-q4f16_1-MLC"); // downloads once, then serves from browser cache
|
|
40
|
+
|
|
41
|
+
const { text, stats } = await ai.generateText(
|
|
42
|
+
[
|
|
43
|
+
{ role: "system", content: "You are terse." },
|
|
44
|
+
{ role: "user", content: "Why is the sky blue?" },
|
|
45
|
+
],
|
|
46
|
+
{ runtime: { jsonMode: "none" }, onDelta: (full) => render(full) },
|
|
47
|
+
);
|
|
48
|
+
console.log(text, stats.extra?.decode_tokens_per_s);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Capability slots
|
|
52
|
+
|
|
53
|
+
Models load into one of four **capability slots** — `text`, `vision`, `stt` (transcription), `tts`
|
|
54
|
+
(speech) — with at most one model per slot, so up to four models can be resident at once. Loading a
|
|
55
|
+
model whose slots are occupied replaces the conflicting owner. Loads/unloads are single-flight, and
|
|
56
|
+
runs are serialized per loaded model, so concurrent calls queue instead of driving one engine from
|
|
57
|
+
two places.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
await ai.load("onnx-community/whisper-base"); // stt slot
|
|
61
|
+
await ai.load("onnx-community/Kokoro-82M-v1.0-ONNX"); // tts slot
|
|
62
|
+
|
|
63
|
+
const { text } = await ai.transcribe(pcm16k); // Float32Array @ 16 kHz mono
|
|
64
|
+
const clip = await ai.synthesizeToClip("Hello!", { voice: "af_heart" });
|
|
65
|
+
ai.occupiedSlots(); // Set { "text", "stt", "tts" }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Schema-constrained JSON
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { buildExtractionMessages, parseJsonFromModel, validateAgainstJsonSchema } from "@missionsquad/browserai";
|
|
72
|
+
|
|
73
|
+
const messages = buildExtractionMessages(inputText, mySchema);
|
|
74
|
+
const { text } = await ai.generateText(messages, { schema: mySchema }); // WebLLM: real constrained decoding
|
|
75
|
+
const parsed = parseJsonFromModel(text);
|
|
76
|
+
if (parsed.ok) console.log(validateAgainstJsonSchema(parsed.value, mySchema)); // [] when valid
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
WebLLM backends enforce the schema with a grammar-constrained decoder; Transformers.js backends are
|
|
80
|
+
prompt-guided, so validate the output either way.
|
|
81
|
+
|
|
82
|
+
### Voice pipeline (press-to-talk assistant)
|
|
83
|
+
|
|
84
|
+
`VoicePipeline` chains mic → STT → text LLM → TTS → speakers as a headless state machine with typed
|
|
85
|
+
events. With Kokoro in the speech slot, replies are spoken **while the text model is still
|
|
86
|
+
generating** (audio starts after the first sentence); other TTS models speak the finished reply as
|
|
87
|
+
one clip. Pressing talk during playback stops it (barge-in).
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { BrowserAI, VoicePipeline } from "@missionsquad/browserai";
|
|
91
|
+
|
|
92
|
+
const ai = new BrowserAI();
|
|
93
|
+
const voice = new VoicePipeline(ai);
|
|
94
|
+
|
|
95
|
+
await voice.loadRecommendedModels(); // Whisper Base + Qwen3.5 0.8B + Kokoro 82M (~2.5 GB VRAM)
|
|
96
|
+
|
|
97
|
+
voice.on("stagechange", ({ stage }) => setIndicator(stage)); // idle/listening/transcribing/thinking/speaking/error
|
|
98
|
+
voice.on("transcript", ({ text }) => addUserBubble(text));
|
|
99
|
+
voice.on("replydelta", ({ text }) => updateAssistantBubble(text));
|
|
100
|
+
voice.on("turncommitted", ({ messages }) => renderHistory(messages));
|
|
101
|
+
voice.on("error", ({ label, message }) => showError(`${label}: ${message}`));
|
|
102
|
+
|
|
103
|
+
talkButton.onclick = () => voice.toggle(); // press to talk, press again to send
|
|
104
|
+
newChatButton.onclick = () => voice.reset();
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The turn commits to history only on success; discarded takes (`turndiscarded`: too short, no speech,
|
|
108
|
+
empty reply, contention with other work) leave history untouched. For input-level visualization,
|
|
109
|
+
subscribe to `micstream` (attach an AnalyserNode) and `micstreamended`; for output, pass
|
|
110
|
+
`streamingAudio.onGraphReady` to tap the streaming player's gain node.
|
|
111
|
+
|
|
112
|
+
### Vision
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
await ai.load("HuggingFaceTB/SmolVLM-256M-Instruct");
|
|
116
|
+
const answer = await ai.describeImage(imageDataUrl, "What does this chart show?", (partial) => render(partial));
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Metrics + run history
|
|
120
|
+
|
|
121
|
+
Every generation returns `RuntimeStats` (tokens, decode tok/s, TTFT, per-token latency breakdown for
|
|
122
|
+
WebLLM, measured telemetry for Transformers.js). Persist and aggregate runs with `RunHistoryStore`:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { RunHistoryStore, runRecordFromStats } from "@missionsquad/browserai";
|
|
126
|
+
|
|
127
|
+
const history = new RunHistoryStore(); // localStorage-backed; pass { storage: null } for in-memory
|
|
128
|
+
const record = runRecordFromStats("chat", stats);
|
|
129
|
+
if (record) history.append(record);
|
|
130
|
+
history.aggregateByModel("chat"); // decode/ttft averages per model
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Cache management
|
|
134
|
+
|
|
135
|
+
Models download once (hundreds of MB to a few GB) and are cached in browser storage — WebLLM weights
|
|
136
|
+
in IndexedDB by default (configurable via `cacheBackend`), Transformers.js artifacts always in the
|
|
137
|
+
Cache API bucket `transformers-cache`.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
await ai.cacheStatus("onnx-community/whisper-base"); // { downloaded, matchedEntries, … }
|
|
141
|
+
await ai.deleteModelArtifacts("onnx-community/whisper-base");
|
|
142
|
+
await ai.deleteAllModelArtifacts(); // releases loaded models first
|
|
143
|
+
await ai.estimateStorage(); // origin-wide usage/quota
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Cleanup targets model artifact storage only — never cookies, localStorage, or unrelated site data.
|
|
147
|
+
|
|
148
|
+
## Configuration
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
const ai = new BrowserAI({
|
|
152
|
+
modelSource: "direct", // "direct" (default, straight from Hugging Face) | "proxy"
|
|
153
|
+
proxyOrigin: undefined, // proxy Worker origin; defaults to the page origin in proxy mode
|
|
154
|
+
verifyProxy: true, // probe the proxy before each proxied load
|
|
155
|
+
cacheBackend: "indexeddb", // WebLLM artifact cache: "indexeddb" | "cache" | "opfs" | "cross-origin"
|
|
156
|
+
webllm: {
|
|
157
|
+
worker: () => new Worker(new URL("./llm.worker.ts", import.meta.url), { type: "module" }),
|
|
158
|
+
logLevel: "INFO",
|
|
159
|
+
},
|
|
160
|
+
kokoro: { url: "https://cdn.jsdelivr.net/npm/kokoro-js@1.2.1/+esm" }, // or load: () => import("kokoro-js")
|
|
161
|
+
tts: { voice: "af_heart", speed: 1, pitch: 1 },
|
|
162
|
+
history: { key: "browserai:v1:history", maxRecords: 200 },
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Running WebLLM in a worker
|
|
167
|
+
|
|
168
|
+
By default WebLLM engines run on the main thread. To keep loading and token generation off it,
|
|
169
|
+
create a two-line worker entry in your app and pass its factory:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
// llm.worker.ts — bundled by YOUR build (Vite/webpack understand this pattern)
|
|
173
|
+
export * from "@missionsquad/browserai/worker";
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
const ai = new BrowserAI({
|
|
178
|
+
webllm: { worker: () => new Worker(new URL("./llm.worker.ts", import.meta.url), { type: "module" }) },
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Model-artifact proxy (optional)
|
|
183
|
+
|
|
184
|
+
Direct downloads work on any site. If a hosted deployment hits CORS or cache-storage edge cases with
|
|
185
|
+
cross-origin model downloads, deploy the Cloudflare Worker in [`worker-template/`](worker-template/)
|
|
186
|
+
and configure `{ modelSource: "proxy" }`. The Worker proxies `/hf/*` (WebLLM weights),
|
|
187
|
+
`/hf-transformers/*` (Transformers.js artifacts), and `/gh-raw/*` (WebLLM wasm libraries), restricted
|
|
188
|
+
to an allowlist derived from the SDK's model catalog. Kokoro is the one exception: kokoro-js ships
|
|
189
|
+
its own runtime and fetches its weights directly.
|
|
190
|
+
|
|
191
|
+
## Model catalog
|
|
192
|
+
|
|
193
|
+
47 presets across four capability types (see `MODEL_PRESETS`; each entry carries GPU-memory
|
|
194
|
+
estimates, download size, stability rating, and per-model default runtime parameters):
|
|
195
|
+
|
|
196
|
+
- **Text (WebLLM/MLC):** Qwen3.5 0.8B/2B/4B, Gemma 3 1B, Gemma 4 E2B/E4B (experimental custom
|
|
197
|
+
records), Llama 3.2 1B/3B, Hermes 3, OLMo 2, Phi-4 mini, Ministral 3, Mistral 7B.
|
|
198
|
+
- **Text (Transformers.js/ONNX):** Nemotron 3 Nano, LFM2.5 230M/350M/1.2B-Thinking, Falcon H1 Tiny,
|
|
199
|
+
the Granite 4.0 family, MiniCPM5, SmolLM3, Gemma 4 E2B/E4B ONNX (multimodal: text + image + audio).
|
|
200
|
+
- **Transcription:** Whisper Base/Tiny/Medium/Medium-EN, Moonshine Base/Tiny, Cohere Transcribe,
|
|
201
|
+
Parakeet CTC 0.6B, Granite Speech 2B, Voxtral Mini 4B Realtime.
|
|
202
|
+
- **Speech:** Kokoro 82M (streams), Supertonic 1/2.
|
|
203
|
+
- **Vision:** SmolVLM 256M/500M, Qwen3-VL 2B, Qwen2.5-VL 3B, GLM-OCR, LightOnOCR-2.
|
|
204
|
+
|
|
205
|
+
## Browser constraints worth knowing
|
|
206
|
+
|
|
207
|
+
- **WebGPU** is required for text, vision, Gemma-multimodal, and audio-LLM models; ASR-pipeline
|
|
208
|
+
(Whisper/Moonshine/Parakeet) and TTS presets fall back to wasm. `probeHardware()` reports
|
|
209
|
+
adapter/features/limits; other loads throw `WebGPUUnavailableError` without it.
|
|
210
|
+
- `decodeAudioTo16kMono`, `capSrc` (image downscaling), `MicRecorder`, and the audio players use
|
|
211
|
+
main-thread browser APIs (`AudioContext`, `Image`, canvas, `MediaRecorder`) — decode/capture on
|
|
212
|
+
the page even if you move inference elsewhere.
|
|
213
|
+
- If you attach an analyzer to an `<audio>` element playing clips, remember a media element allows
|
|
214
|
+
exactly **one** `MediaElementSourceNode` for its whole life — create the analyzer once and never
|
|
215
|
+
close its context while the element is in use.
|
|
216
|
+
- All SDK errors are `BrowserAIError` subclasses (`ModelLoadError`, `MicrophoneError`,
|
|
217
|
+
`MissingModelError`, `VoiceTurnError`, …) — branch with `instanceof`.
|
|
218
|
+
|
|
219
|
+
## Development
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
npm ci
|
|
223
|
+
npm run build # type-check + emit dist (ESM + .d.ts)
|
|
224
|
+
npm test # vitest unit tests
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
ISC
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { MultimodalHandle, RawAudio } from "../multimodal.js";
|
|
2
|
+
/** A synthesized, playable speech clip. `samples` is the ORIGINAL (un-pitched) PCM. */
|
|
3
|
+
export type SynthClip = {
|
|
4
|
+
/** Object URL for the (pitch-baked) blob; revoke it when replacing the clip. Absent when `createUrl: false`. */
|
|
5
|
+
url: string | null;
|
|
6
|
+
blob: Blob;
|
|
7
|
+
/** Original un-pitched PCM, kept so a pitch change can re-render without re-running the model. */
|
|
8
|
+
samples: Float32Array;
|
|
9
|
+
sampleRate: number;
|
|
10
|
+
durationSec: number;
|
|
11
|
+
};
|
|
12
|
+
export type SynthesizeClipOptions = {
|
|
13
|
+
voice: string;
|
|
14
|
+
speed?: number;
|
|
15
|
+
/** Pitch ratio baked into the blob (duration-preserving). Default 1 (blob is the model's own output). */
|
|
16
|
+
pitch?: number;
|
|
17
|
+
/** Create an object URL for the blob (default true). Pass false when only PCM/Blob is needed. */
|
|
18
|
+
createUrl?: boolean;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Synthesize `text` with a loaded TTS handle and package it as a playable clip. The pitch shift is
|
|
22
|
+
* baked into the blob (duration-preserving) while `samples` keeps the original PCM so a pitch slider
|
|
23
|
+
* can re-shift without re-synthesis (see {@link repitchClip}).
|
|
24
|
+
*/
|
|
25
|
+
export declare function synthesizeClip(handle: MultimodalHandle, text: string, options: SynthesizeClipOptions): Promise<SynthClip>;
|
|
26
|
+
/** Package an already-synthesized RawAudio as a clip (same pitch-baking rules as synthesizeClip). */
|
|
27
|
+
export declare function clipFromRawAudio(audio: RawAudio, pitch: number, createUrl?: boolean): SynthClip;
|
|
28
|
+
/**
|
|
29
|
+
* Re-render a clip from its stored original PCM at a new pitch, revoking the previous object URL.
|
|
30
|
+
* Lets a pitch control take effect immediately on an existing clip without re-running the model.
|
|
31
|
+
*/
|
|
32
|
+
export declare function repitchClip(clip: SynthClip, pitch: number): SynthClip;
|
|
33
|
+
/** Revoke a clip's object URL (call when discarding or replacing a clip). Safe on URL-less clips. */
|
|
34
|
+
export declare function releaseClip(clip: SynthClip | null | undefined): void;
|
|
35
|
+
/**
|
|
36
|
+
* Playback rate that applies the speed setting exactly once per engine: Kokoro bakes speed into
|
|
37
|
+
* synthesis (the audio is already time-scaled), so it plays at 1×; the Supertonic pipeline ignores
|
|
38
|
+
* generation-time speed, so the speed setting is applied at playback instead.
|
|
39
|
+
*/
|
|
40
|
+
export declare function clipPlaybackRate(handle: MultimodalHandle | null | undefined, speed: number): number;
|
|
41
|
+
/**
|
|
42
|
+
* Play a clip through an HTMLAudioElement. Resolves when playback STARTS (autoplay rejection is
|
|
43
|
+
* swallowed), not when it ends — await the element's "ended" event for drain semantics.
|
|
44
|
+
*
|
|
45
|
+
* Visualization caveat for hosts: a media element can be captured by exactly ONE
|
|
46
|
+
* MediaElementAudioSourceNode for its entire life, and closing the AudioContext that source routes
|
|
47
|
+
* through mutes the element permanently. If you attach an analyzer to `audioEl`, create it once per
|
|
48
|
+
* element and never dispose it (or its context) while the element is still in use.
|
|
49
|
+
*/
|
|
50
|
+
export declare function playClipThroughElement(audioEl: HTMLAudioElement, clip: SynthClip, playbackRate?: number): Promise<void>;
|
|
51
|
+
//# sourceMappingURL=clip.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"clip.d.ts","sourceRoot":"","sources":["../../src/audio/clip.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAInE,uFAAuF;AACvF,MAAM,MAAM,SAAS,GAAG;IACtB,gHAAgH;IAChH,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,IAAI,EAAE,IAAI,CAAC;IACX,kGAAkG;IAClG,OAAO,EAAE,YAAY,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yGAAyG;IACzG,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iGAAiG;IACjG,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,MAAM,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,SAAS,CAAC,CAG/H;AAED,qGAAqG;AACrG,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,UAAO,GAAG,SAAS,CAY5F;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,CAUrE;AAED,qGAAqG;AACrG,wBAAgB,WAAW,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,CAEpE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnG;AAED;;;;;;;;GAQG;AACH,wBAAsB,sBAAsB,CAAC,OAAO,EAAE,gBAAgB,EAAE,IAAI,EAAE,SAAS,EAAE,YAAY,SAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAKxH"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { synthesize } from "../multimodal.js";
|
|
2
|
+
import { encodeWavBlob, pitchShiftBuffer } from "./pcm.js";
|
|
3
|
+
/**
|
|
4
|
+
* Synthesize `text` with a loaded TTS handle and package it as a playable clip. The pitch shift is
|
|
5
|
+
* baked into the blob (duration-preserving) while `samples` keeps the original PCM so a pitch slider
|
|
6
|
+
* can re-shift without re-synthesis (see {@link repitchClip}).
|
|
7
|
+
*/
|
|
8
|
+
export async function synthesizeClip(handle, text, options) {
|
|
9
|
+
const audio = await synthesize(handle, text, { voice: options.voice, speed: options.speed ?? 1 });
|
|
10
|
+
return clipFromRawAudio(audio, options.pitch ?? 1, options.createUrl ?? true);
|
|
11
|
+
}
|
|
12
|
+
/** Package an already-synthesized RawAudio as a clip (same pitch-baking rules as synthesizeClip). */
|
|
13
|
+
export function clipFromRawAudio(audio, pitch, createUrl = true) {
|
|
14
|
+
const samples = audio.audio;
|
|
15
|
+
const sampleRate = audio.sampling_rate;
|
|
16
|
+
const shifted = pitchShiftBuffer(samples, pitch);
|
|
17
|
+
const blob = pitch === 1 ? audio.toBlob() : encodeWavBlob(shifted, sampleRate);
|
|
18
|
+
return {
|
|
19
|
+
url: createUrl ? URL.createObjectURL(blob) : null,
|
|
20
|
+
blob,
|
|
21
|
+
samples,
|
|
22
|
+
sampleRate,
|
|
23
|
+
durationSec: shifted.length / sampleRate,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Re-render a clip from its stored original PCM at a new pitch, revoking the previous object URL.
|
|
28
|
+
* Lets a pitch control take effect immediately on an existing clip without re-running the model.
|
|
29
|
+
*/
|
|
30
|
+
export function repitchClip(clip, pitch) {
|
|
31
|
+
const shifted = pitchShiftBuffer(clip.samples, pitch);
|
|
32
|
+
const blob = encodeWavBlob(shifted, clip.sampleRate);
|
|
33
|
+
if (clip.url)
|
|
34
|
+
URL.revokeObjectURL(clip.url);
|
|
35
|
+
return {
|
|
36
|
+
...clip,
|
|
37
|
+
url: clip.url === null ? null : URL.createObjectURL(blob),
|
|
38
|
+
blob,
|
|
39
|
+
durationSec: shifted.length / clip.sampleRate,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Revoke a clip's object URL (call when discarding or replacing a clip). Safe on URL-less clips. */
|
|
43
|
+
export function releaseClip(clip) {
|
|
44
|
+
if (clip?.url)
|
|
45
|
+
URL.revokeObjectURL(clip.url);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Playback rate that applies the speed setting exactly once per engine: Kokoro bakes speed into
|
|
49
|
+
* synthesis (the audio is already time-scaled), so it plays at 1×; the Supertonic pipeline ignores
|
|
50
|
+
* generation-time speed, so the speed setting is applied at playback instead.
|
|
51
|
+
*/
|
|
52
|
+
export function clipPlaybackRate(handle, speed) {
|
|
53
|
+
return handle?.kind === "tts-kokoro" ? 1 : speed;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Play a clip through an HTMLAudioElement. Resolves when playback STARTS (autoplay rejection is
|
|
57
|
+
* swallowed), not when it ends — await the element's "ended" event for drain semantics.
|
|
58
|
+
*
|
|
59
|
+
* Visualization caveat for hosts: a media element can be captured by exactly ONE
|
|
60
|
+
* MediaElementAudioSourceNode for its entire life, and closing the AudioContext that source routes
|
|
61
|
+
* through mutes the element permanently. If you attach an analyzer to `audioEl`, create it once per
|
|
62
|
+
* element and never dispose it (or its context) while the element is still in use.
|
|
63
|
+
*/
|
|
64
|
+
export async function playClipThroughElement(audioEl, clip, playbackRate = 1) {
|
|
65
|
+
if (!clip.url)
|
|
66
|
+
throw new Error("Clip has no object URL (synthesized with createUrl: false).");
|
|
67
|
+
audioEl.src = clip.url;
|
|
68
|
+
audioEl.playbackRate = playbackRate;
|
|
69
|
+
await audioEl.play().catch(() => undefined);
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=clip.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"clip.js","sourceRoot":"","sources":["../../src/audio/clip.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decode an audio file (ArrayBuffer) to a 16 kHz mono Float32Array, matching Transformers.js
|
|
3
|
+
* `read_audio` output. Browser main-thread only: uses `AudioContext`/`OfflineAudioContext`, which do
|
|
4
|
+
* not exist in workers or Node — decode on the page and pass the PCM into worker-side consumers.
|
|
5
|
+
*/
|
|
6
|
+
export declare function decodeAudioTo16kMono(buffer: ArrayBuffer): Promise<Float32Array>;
|
|
7
|
+
//# sourceMappingURL=decode.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"decode.d.ts","sourceRoot":"","sources":["../../src/audio/decode.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,wBAAsB,oBAAoB,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC,CAWrF"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { AUDIO_SR } from "./pcm.js";
|
|
2
|
+
/**
|
|
3
|
+
* Decode an audio file (ArrayBuffer) to a 16 kHz mono Float32Array, matching Transformers.js
|
|
4
|
+
* `read_audio` output. Browser main-thread only: uses `AudioContext`/`OfflineAudioContext`, which do
|
|
5
|
+
* not exist in workers or Node — decode on the page and pass the PCM into worker-side consumers.
|
|
6
|
+
*/
|
|
7
|
+
export async function decodeAudioTo16kMono(buffer) {
|
|
8
|
+
const ctx = new AudioContext();
|
|
9
|
+
// Close the context even when decode rejects (e.g. a header-only blob from an instant start/stop).
|
|
10
|
+
const decoded = await ctx.decodeAudioData(buffer).finally(() => ctx.close().catch(() => undefined));
|
|
11
|
+
const offline = new OfflineAudioContext(1, Math.ceil(decoded.duration * AUDIO_SR), AUDIO_SR);
|
|
12
|
+
const source = offline.createBufferSource();
|
|
13
|
+
source.buffer = decoded;
|
|
14
|
+
source.connect(offline.destination);
|
|
15
|
+
source.start();
|
|
16
|
+
const rendered = await offline.startRendering();
|
|
17
|
+
return rendered.getChannelData(0);
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=decode.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"decode.js","sourceRoot":"","sources":["../../src/audio/decode.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEpC;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,MAAmB;IAC5D,MAAM,GAAG,GAAG,IAAI,YAAY,EAAE,CAAC;IAC/B,mGAAmG;IACnG,MAAM,OAAO,GAAG,MAAM,GAAG,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC,CAAC;IACpG,MAAM,OAAO,GAAG,IAAI,mBAAmB,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,GAAG,QAAQ,CAAC,EAAE,QAAQ,CAAC,CAAC;IAC7F,MAAM,MAAM,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAC5C,MAAM,CAAC,MAAM,GAAG,OAAO,CAAC;IACxB,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACpC,MAAM,CAAC,KAAK,EAAE,CAAC;IACf,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,cAAc,EAAE,CAAC;IAChD,OAAO,QAAQ,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC;AACpC,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Sample rate every STT/audio-LLM recipe in the SDK consumes (Whisper-style 16 kHz mono). */
|
|
2
|
+
export declare const AUDIO_SR = 16000;
|
|
3
|
+
/**
|
|
4
|
+
* Pitch-shift a mono PCM buffer by `ratio` (2 = up one octave, 0.5 = down one octave) while preserving
|
|
5
|
+
* duration. Time-domain granular overlap-add: Hann-windowed grains hop through the output at a fixed
|
|
6
|
+
* rate (so timing is unchanged) while each grain is read from the input at `ratio×` speed via linear
|
|
7
|
+
* interpolation (so pitch scales). A pure Float32Array→Float32Array function — no Web Audio — so pitch
|
|
8
|
+
* can be applied offline (baked into a clip) and unit-tested independently of a browser.
|
|
9
|
+
*/
|
|
10
|
+
export declare function pitchShiftBuffer(input: Float32Array, ratio: number): Float32Array;
|
|
11
|
+
/** Encode a mono Float32 PCM buffer as a 16-bit PCM WAV Blob (used when pitch is baked into a clip). */
|
|
12
|
+
export declare function encodeWavBlob(samples: Float32Array, sampleRate: number): Blob;
|
|
13
|
+
//# sourceMappingURL=pcm.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pcm.d.ts","sourceRoot":"","sources":["../../src/audio/pcm.ts"],"names":[],"mappings":"AAAA,8FAA8F;AAC9F,eAAO,MAAM,QAAQ,QAAQ,CAAC;AAE9B;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,GAAG,YAAY,CAsBjF;AAED,wGAAwG;AACxG,wBAAgB,aAAa,CAAC,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CA0B7E"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Sample rate every STT/audio-LLM recipe in the SDK consumes (Whisper-style 16 kHz mono). */
|
|
2
|
+
export const AUDIO_SR = 16000;
|
|
3
|
+
/**
|
|
4
|
+
* Pitch-shift a mono PCM buffer by `ratio` (2 = up one octave, 0.5 = down one octave) while preserving
|
|
5
|
+
* duration. Time-domain granular overlap-add: Hann-windowed grains hop through the output at a fixed
|
|
6
|
+
* rate (so timing is unchanged) while each grain is read from the input at `ratio×` speed via linear
|
|
7
|
+
* interpolation (so pitch scales). A pure Float32Array→Float32Array function — no Web Audio — so pitch
|
|
8
|
+
* can be applied offline (baked into a clip) and unit-tested independently of a browser.
|
|
9
|
+
*/
|
|
10
|
+
export function pitchShiftBuffer(input, ratio) {
|
|
11
|
+
if (ratio === 1 || input.length === 0)
|
|
12
|
+
return input;
|
|
13
|
+
const grain = 1024;
|
|
14
|
+
const hop = grain / 4; // 75% overlap
|
|
15
|
+
const output = new Float32Array(input.length);
|
|
16
|
+
const weight = new Float32Array(input.length);
|
|
17
|
+
const hann = new Float32Array(grain);
|
|
18
|
+
for (let i = 0; i < grain; i += 1)
|
|
19
|
+
hann[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (grain - 1));
|
|
20
|
+
for (let out = 0; out + grain <= output.length; out += hop) {
|
|
21
|
+
for (let i = 0; i < grain; i += 1) {
|
|
22
|
+
const src = out + i * ratio; // grain anchored at `out` (time preserved), read at ratio× (pitch)
|
|
23
|
+
const i0 = Math.floor(src);
|
|
24
|
+
if (i0 + 1 >= input.length)
|
|
25
|
+
break;
|
|
26
|
+
const frac = src - i0;
|
|
27
|
+
const sample = input[i0] * (1 - frac) + input[i0 + 1] * frac;
|
|
28
|
+
output[out + i] += sample * hann[i];
|
|
29
|
+
weight[out + i] += hann[i];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (let i = 0; i < output.length; i += 1)
|
|
33
|
+
if (weight[i] > 1e-6)
|
|
34
|
+
output[i] /= weight[i]; // OLA gain normalization
|
|
35
|
+
return output;
|
|
36
|
+
}
|
|
37
|
+
/** Encode a mono Float32 PCM buffer as a 16-bit PCM WAV Blob (used when pitch is baked into a clip). */
|
|
38
|
+
export function encodeWavBlob(samples, sampleRate) {
|
|
39
|
+
const buffer = new ArrayBuffer(44 + samples.length * 2);
|
|
40
|
+
const view = new DataView(buffer);
|
|
41
|
+
const writeStr = (offset, text) => {
|
|
42
|
+
for (let i = 0; i < text.length; i += 1)
|
|
43
|
+
view.setUint8(offset + i, text.charCodeAt(i));
|
|
44
|
+
};
|
|
45
|
+
writeStr(0, "RIFF");
|
|
46
|
+
view.setUint32(4, 36 + samples.length * 2, true);
|
|
47
|
+
writeStr(8, "WAVE");
|
|
48
|
+
writeStr(12, "fmt ");
|
|
49
|
+
view.setUint32(16, 16, true); // PCM fmt chunk size
|
|
50
|
+
view.setUint16(20, 1, true); // PCM format
|
|
51
|
+
view.setUint16(22, 1, true); // mono
|
|
52
|
+
view.setUint32(24, sampleRate, true);
|
|
53
|
+
view.setUint32(28, sampleRate * 2, true); // byte rate
|
|
54
|
+
view.setUint16(32, 2, true); // block align
|
|
55
|
+
view.setUint16(34, 16, true); // bits per sample
|
|
56
|
+
writeStr(36, "data");
|
|
57
|
+
view.setUint32(40, samples.length * 2, true);
|
|
58
|
+
let offset = 44;
|
|
59
|
+
for (let i = 0; i < samples.length; i += 1) {
|
|
60
|
+
const clamped = Math.max(-1, Math.min(1, samples[i]));
|
|
61
|
+
view.setInt16(offset, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
|
|
62
|
+
offset += 2;
|
|
63
|
+
}
|
|
64
|
+
return new Blob([buffer], { type: "audio/wav" });
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=pcm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pcm.js","sourceRoot":"","sources":["../../src/audio/pcm.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Press-to-talk microphone recorder: getUserMedia → MediaRecorder with no timeslice (one blob at
|
|
3
|
+
* stop) → 16 kHz mono PCM. Hardened lifecycle ported from small-ai's pipeline recorder:
|
|
4
|
+
* - a synchronous pending claim so a double-press cannot start two recorders;
|
|
5
|
+
* - an inactive-recorder guard (the recorder auto-stops when its track ends — device unplugged,
|
|
6
|
+
* permission revoked — and `stop()` would then be a silent no-op whose `onstop` never fires);
|
|
7
|
+
* - try/finally teardown that always releases the mic tracks and closes the AudioContext.
|
|
8
|
+
*
|
|
9
|
+
* The recorder creates an AudioContext + MediaStreamSource so hosts can visualize input levels:
|
|
10
|
+
* `onStreamReady` receives them (attach an AnalyserNode there). The SDK never renders anything.
|
|
11
|
+
*/
|
|
12
|
+
export type MicRecorderOptions = {
|
|
13
|
+
/** Called once per take after the mic is live — attach analyzers/visualizers here. */
|
|
14
|
+
onStreamReady?: (graph: {
|
|
15
|
+
context: AudioContext;
|
|
16
|
+
source: MediaStreamAudioSourceNode;
|
|
17
|
+
stream: MediaStream;
|
|
18
|
+
}) => void;
|
|
19
|
+
/** Called during teardown, before the context closes — dispose whatever onStreamReady created. */
|
|
20
|
+
onStreamEnded?: () => void;
|
|
21
|
+
};
|
|
22
|
+
export declare class MicRecorder {
|
|
23
|
+
#private;
|
|
24
|
+
constructor(options?: MicRecorderOptions);
|
|
25
|
+
/** Whether a take is currently being captured (true between a successful start() and stop()). */
|
|
26
|
+
get recording(): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Start capturing. No-op (returns false) when already recording or a start is mid-flight.
|
|
29
|
+
* @throws {MicrophoneError} when the mic cannot be acquired (`permissionDenied` set for denials).
|
|
30
|
+
*/
|
|
31
|
+
start(): Promise<boolean>;
|
|
32
|
+
/**
|
|
33
|
+
* Stop the take and decode it to 16 kHz mono PCM. Returns null when nothing was recorded (no
|
|
34
|
+
* take in flight, or an empty capture). Always releases the mic, even if stopping throws.
|
|
35
|
+
*/
|
|
36
|
+
stop(): Promise<Float32Array | null>;
|
|
37
|
+
/** Abandon a take without decoding it (releases the mic; the audio is discarded). Also aborts a start() still awaiting the permission prompt. */
|
|
38
|
+
cancel(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=recorder.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recorder.d.ts","sourceRoot":"","sources":["../../src/audio/recorder.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;GAUG;AACH,MAAM,MAAM,kBAAkB,GAAG;IAC/B,sFAAsF;IACtF,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,OAAO,EAAE,YAAY,CAAC;QAAC,MAAM,EAAE,0BAA0B,CAAC;QAAC,MAAM,EAAE,WAAW,CAAA;KAAE,KAAK,IAAI,CAAC;IACpH,kGAAkG;IAClG,aAAa,CAAC,EAAE,MAAM,IAAI,CAAC;CAC5B,CAAC;AASF,qBAAa,WAAW;;gBAMV,OAAO,GAAE,kBAAuB;IAI5C,iGAAiG;IACjG,IAAI,SAAS,IAAI,OAAO,CAEvB;IAUD;;;OAGG;IACG,KAAK,IAAI,OAAO,CAAC,OAAO,CAAC;IAqC/B;;;OAGG;IACG,IAAI,IAAI,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IA2B1C,iJAAiJ;IAC3I,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;CAqB9B"}
|