@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/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@missionsquad/browserai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Run small open AI models entirely in the browser with WebGPU: model catalog, capability slots, WebLLM + Transformers.js + Kokoro inference, streaming, schema-constrained JSON, metrics, cache management, and a press-to-talk voice pipeline — as an embeddable, UI-free SDK.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./worker": {
|
|
14
|
+
"types": "./dist/webllm.worker.d.ts",
|
|
15
|
+
"default": "./dist/webllm.worker.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"worker-template",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"sideEffects": [
|
|
25
|
+
"./dist/webllm.worker.js"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
29
|
+
"build": "npm run clean && tsc --noEmit && tsc -p tsconfig.build.json",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"prepublishOnly": "npm run build && npm test"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"webgpu",
|
|
39
|
+
"webllm",
|
|
40
|
+
"transformers.js",
|
|
41
|
+
"onnx",
|
|
42
|
+
"browser",
|
|
43
|
+
"llm",
|
|
44
|
+
"stt",
|
|
45
|
+
"tts",
|
|
46
|
+
"kokoro",
|
|
47
|
+
"whisper",
|
|
48
|
+
"voice-assistant",
|
|
49
|
+
"local-ai"
|
|
50
|
+
],
|
|
51
|
+
"author": "Mission Squad",
|
|
52
|
+
"license": "ISC",
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@huggingface/transformers": "4.2.0",
|
|
55
|
+
"@mlc-ai/web-llm": "0.2.84"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/node": "24.10.4",
|
|
59
|
+
"typescript": "6.0.3",
|
|
60
|
+
"vitest": "4.1.9"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { MultimodalHandle, RawAudio } from "../multimodal.js";
|
|
2
|
+
import { synthesize } from "../multimodal.js";
|
|
3
|
+
import { encodeWavBlob, pitchShiftBuffer } from "./pcm.js";
|
|
4
|
+
|
|
5
|
+
/** A synthesized, playable speech clip. `samples` is the ORIGINAL (un-pitched) PCM. */
|
|
6
|
+
export type SynthClip = {
|
|
7
|
+
/** Object URL for the (pitch-baked) blob; revoke it when replacing the clip. Absent when `createUrl: false`. */
|
|
8
|
+
url: string | null;
|
|
9
|
+
blob: Blob;
|
|
10
|
+
/** Original un-pitched PCM, kept so a pitch change can re-render without re-running the model. */
|
|
11
|
+
samples: Float32Array;
|
|
12
|
+
sampleRate: number;
|
|
13
|
+
durationSec: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SynthesizeClipOptions = {
|
|
17
|
+
voice: string;
|
|
18
|
+
speed?: number;
|
|
19
|
+
/** Pitch ratio baked into the blob (duration-preserving). Default 1 (blob is the model's own output). */
|
|
20
|
+
pitch?: number;
|
|
21
|
+
/** Create an object URL for the blob (default true). Pass false when only PCM/Blob is needed. */
|
|
22
|
+
createUrl?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Synthesize `text` with a loaded TTS handle and package it as a playable clip. The pitch shift is
|
|
27
|
+
* baked into the blob (duration-preserving) while `samples` keeps the original PCM so a pitch slider
|
|
28
|
+
* can re-shift without re-synthesis (see {@link repitchClip}).
|
|
29
|
+
*/
|
|
30
|
+
export async function synthesizeClip(handle: MultimodalHandle, text: string, options: SynthesizeClipOptions): Promise<SynthClip> {
|
|
31
|
+
const audio = await synthesize(handle, text, { voice: options.voice, speed: options.speed ?? 1 });
|
|
32
|
+
return clipFromRawAudio(audio, options.pitch ?? 1, options.createUrl ?? true);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Package an already-synthesized RawAudio as a clip (same pitch-baking rules as synthesizeClip). */
|
|
36
|
+
export function clipFromRawAudio(audio: RawAudio, pitch: number, createUrl = true): SynthClip {
|
|
37
|
+
const samples = audio.audio;
|
|
38
|
+
const sampleRate = audio.sampling_rate;
|
|
39
|
+
const shifted = pitchShiftBuffer(samples, pitch);
|
|
40
|
+
const blob = pitch === 1 ? audio.toBlob() : encodeWavBlob(shifted, sampleRate);
|
|
41
|
+
return {
|
|
42
|
+
url: createUrl ? URL.createObjectURL(blob) : null,
|
|
43
|
+
blob,
|
|
44
|
+
samples,
|
|
45
|
+
sampleRate,
|
|
46
|
+
durationSec: shifted.length / sampleRate,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Re-render a clip from its stored original PCM at a new pitch, revoking the previous object URL.
|
|
52
|
+
* Lets a pitch control take effect immediately on an existing clip without re-running the model.
|
|
53
|
+
*/
|
|
54
|
+
export function repitchClip(clip: SynthClip, pitch: number): SynthClip {
|
|
55
|
+
const shifted = pitchShiftBuffer(clip.samples, pitch);
|
|
56
|
+
const blob = encodeWavBlob(shifted, clip.sampleRate);
|
|
57
|
+
if (clip.url) URL.revokeObjectURL(clip.url);
|
|
58
|
+
return {
|
|
59
|
+
...clip,
|
|
60
|
+
url: clip.url === null ? null : URL.createObjectURL(blob),
|
|
61
|
+
blob,
|
|
62
|
+
durationSec: shifted.length / clip.sampleRate,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Revoke a clip's object URL (call when discarding or replacing a clip). Safe on URL-less clips. */
|
|
67
|
+
export function releaseClip(clip: SynthClip | null | undefined): void {
|
|
68
|
+
if (clip?.url) URL.revokeObjectURL(clip.url);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Playback rate that applies the speed setting exactly once per engine: Kokoro bakes speed into
|
|
73
|
+
* synthesis (the audio is already time-scaled), so it plays at 1×; the Supertonic pipeline ignores
|
|
74
|
+
* generation-time speed, so the speed setting is applied at playback instead.
|
|
75
|
+
*/
|
|
76
|
+
export function clipPlaybackRate(handle: MultimodalHandle | null | undefined, speed: number): number {
|
|
77
|
+
return handle?.kind === "tts-kokoro" ? 1 : speed;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Play a clip through an HTMLAudioElement. Resolves when playback STARTS (autoplay rejection is
|
|
82
|
+
* swallowed), not when it ends — await the element's "ended" event for drain semantics.
|
|
83
|
+
*
|
|
84
|
+
* Visualization caveat for hosts: a media element can be captured by exactly ONE
|
|
85
|
+
* MediaElementAudioSourceNode for its entire life, and closing the AudioContext that source routes
|
|
86
|
+
* through mutes the element permanently. If you attach an analyzer to `audioEl`, create it once per
|
|
87
|
+
* element and never dispose it (or its context) while the element is still in use.
|
|
88
|
+
*/
|
|
89
|
+
export async function playClipThroughElement(audioEl: HTMLAudioElement, clip: SynthClip, playbackRate = 1): Promise<void> {
|
|
90
|
+
if (!clip.url) throw new Error("Clip has no object URL (synthesized with createUrl: false).");
|
|
91
|
+
audioEl.src = clip.url;
|
|
92
|
+
audioEl.playbackRate = playbackRate;
|
|
93
|
+
await audioEl.play().catch(() => undefined);
|
|
94
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { AUDIO_SR } from "./pcm.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Decode an audio file (ArrayBuffer) to a 16 kHz mono Float32Array, matching Transformers.js
|
|
5
|
+
* `read_audio` output. Browser main-thread only: uses `AudioContext`/`OfflineAudioContext`, which do
|
|
6
|
+
* not exist in workers or Node — decode on the page and pass the PCM into worker-side consumers.
|
|
7
|
+
*/
|
|
8
|
+
export async function decodeAudioTo16kMono(buffer: ArrayBuffer): Promise<Float32Array> {
|
|
9
|
+
const ctx = new AudioContext();
|
|
10
|
+
// Close the context even when decode rejects (e.g. a header-only blob from an instant start/stop).
|
|
11
|
+
const decoded = await ctx.decodeAudioData(buffer).finally(() => ctx.close().catch(() => undefined));
|
|
12
|
+
const offline = new OfflineAudioContext(1, Math.ceil(decoded.duration * AUDIO_SR), AUDIO_SR);
|
|
13
|
+
const source = offline.createBufferSource();
|
|
14
|
+
source.buffer = decoded;
|
|
15
|
+
source.connect(offline.destination);
|
|
16
|
+
source.start();
|
|
17
|
+
const rendered = await offline.startRendering();
|
|
18
|
+
return rendered.getChannelData(0);
|
|
19
|
+
}
|
package/src/audio/pcm.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
/**
|
|
5
|
+
* Pitch-shift a mono PCM buffer by `ratio` (2 = up one octave, 0.5 = down one octave) while preserving
|
|
6
|
+
* duration. Time-domain granular overlap-add: Hann-windowed grains hop through the output at a fixed
|
|
7
|
+
* rate (so timing is unchanged) while each grain is read from the input at `ratio×` speed via linear
|
|
8
|
+
* interpolation (so pitch scales). A pure Float32Array→Float32Array function — no Web Audio — so pitch
|
|
9
|
+
* can be applied offline (baked into a clip) and unit-tested independently of a browser.
|
|
10
|
+
*/
|
|
11
|
+
export function pitchShiftBuffer(input: Float32Array, ratio: number): Float32Array {
|
|
12
|
+
if (ratio === 1 || input.length === 0) 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) hann[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (grain - 1));
|
|
19
|
+
|
|
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) break;
|
|
25
|
+
const frac = src - i0;
|
|
26
|
+
const sample = input[i0] * (1 - frac) + input[i0 + 1] * frac;
|
|
27
|
+
output[out + i] += sample * hann[i];
|
|
28
|
+
weight[out + i] += hann[i];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
for (let i = 0; i < output.length; i += 1) if (weight[i] > 1e-6) output[i] /= weight[i]; // OLA gain normalization
|
|
32
|
+
return output;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Encode a mono Float32 PCM buffer as a 16-bit PCM WAV Blob (used when pitch is baked into a clip). */
|
|
36
|
+
export function encodeWavBlob(samples: Float32Array, sampleRate: number): Blob {
|
|
37
|
+
const buffer = new ArrayBuffer(44 + samples.length * 2);
|
|
38
|
+
const view = new DataView(buffer);
|
|
39
|
+
const writeStr = (offset: number, text: string): void => {
|
|
40
|
+
for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
|
|
41
|
+
};
|
|
42
|
+
writeStr(0, "RIFF");
|
|
43
|
+
view.setUint32(4, 36 + samples.length * 2, true);
|
|
44
|
+
writeStr(8, "WAVE");
|
|
45
|
+
writeStr(12, "fmt ");
|
|
46
|
+
view.setUint32(16, 16, true); // PCM fmt chunk size
|
|
47
|
+
view.setUint16(20, 1, true); // PCM format
|
|
48
|
+
view.setUint16(22, 1, true); // mono
|
|
49
|
+
view.setUint32(24, sampleRate, true);
|
|
50
|
+
view.setUint32(28, sampleRate * 2, true); // byte rate
|
|
51
|
+
view.setUint16(32, 2, true); // block align
|
|
52
|
+
view.setUint16(34, 16, true); // bits per sample
|
|
53
|
+
writeStr(36, "data");
|
|
54
|
+
view.setUint32(40, samples.length * 2, true);
|
|
55
|
+
let offset = 44;
|
|
56
|
+
for (let i = 0; i < samples.length; i += 1) {
|
|
57
|
+
const clamped = Math.max(-1, Math.min(1, samples[i]));
|
|
58
|
+
view.setInt16(offset, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
|
|
59
|
+
offset += 2;
|
|
60
|
+
}
|
|
61
|
+
return new Blob([buffer], { type: "audio/wav" });
|
|
62
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { MicrophoneError } from "../errors.js";
|
|
2
|
+
import { decodeAudioTo16kMono } from "./decode.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Press-to-talk microphone recorder: getUserMedia → MediaRecorder with no timeslice (one blob at
|
|
6
|
+
* stop) → 16 kHz mono PCM. Hardened lifecycle ported from small-ai's pipeline recorder:
|
|
7
|
+
* - a synchronous pending claim so a double-press cannot start two recorders;
|
|
8
|
+
* - an inactive-recorder guard (the recorder auto-stops when its track ends — device unplugged,
|
|
9
|
+
* permission revoked — and `stop()` would then be a silent no-op whose `onstop` never fires);
|
|
10
|
+
* - try/finally teardown that always releases the mic tracks and closes the AudioContext.
|
|
11
|
+
*
|
|
12
|
+
* The recorder creates an AudioContext + MediaStreamSource so hosts can visualize input levels:
|
|
13
|
+
* `onStreamReady` receives them (attach an AnalyserNode there). The SDK never renders anything.
|
|
14
|
+
*/
|
|
15
|
+
export type MicRecorderOptions = {
|
|
16
|
+
/** Called once per take after the mic is live — attach analyzers/visualizers here. */
|
|
17
|
+
onStreamReady?: (graph: { context: AudioContext; source: MediaStreamAudioSourceNode; stream: MediaStream }) => void;
|
|
18
|
+
/** Called during teardown, before the context closes — dispose whatever onStreamReady created. */
|
|
19
|
+
onStreamEnded?: () => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ActiveTake = {
|
|
23
|
+
ctx: AudioContext;
|
|
24
|
+
stream: MediaStream;
|
|
25
|
+
recorder: MediaRecorder;
|
|
26
|
+
chunks: Blob[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class MicRecorder {
|
|
30
|
+
readonly #options: MicRecorderOptions;
|
|
31
|
+
#pending = false;
|
|
32
|
+
#abortPending = false;
|
|
33
|
+
#take: ActiveTake | null = null;
|
|
34
|
+
|
|
35
|
+
constructor(options: MicRecorderOptions = {}) {
|
|
36
|
+
this.#options = options;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether a take is currently being captured (true between a successful start() and stop()). */
|
|
40
|
+
get recording(): boolean {
|
|
41
|
+
return this.#take !== null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#streamEnded(): void {
|
|
45
|
+
try {
|
|
46
|
+
this.#options.onStreamEnded?.();
|
|
47
|
+
} catch {
|
|
48
|
+
// A throwing host disposal hook must not interrupt mic/context teardown.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Start capturing. No-op (returns false) when already recording or a start is mid-flight.
|
|
54
|
+
* @throws {MicrophoneError} when the mic cannot be acquired (`permissionDenied` set for denials).
|
|
55
|
+
*/
|
|
56
|
+
async start(): Promise<boolean> {
|
|
57
|
+
if (this.#pending || this.#take) return false;
|
|
58
|
+
this.#pending = true; // claimed synchronously before the getUserMedia await (double-press guard)
|
|
59
|
+
let stream: MediaStream | null = null;
|
|
60
|
+
let ctx: AudioContext | null = null;
|
|
61
|
+
try {
|
|
62
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
63
|
+
if (this.#abortPending) {
|
|
64
|
+
// cancel() ran while the permission prompt / acquisition was pending — release and bail.
|
|
65
|
+
this.#abortPending = false;
|
|
66
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
ctx = new AudioContext();
|
|
70
|
+
const source = ctx.createMediaStreamSource(stream);
|
|
71
|
+
try {
|
|
72
|
+
this.#options.onStreamReady?.({ context: ctx, source, stream });
|
|
73
|
+
} catch {
|
|
74
|
+
// A throwing host visualization hook must not abort the take.
|
|
75
|
+
}
|
|
76
|
+
const recorder = new MediaRecorder(stream);
|
|
77
|
+
const chunks: Blob[] = [];
|
|
78
|
+
recorder.ondataavailable = (event) => chunks.push(event.data);
|
|
79
|
+
recorder.start();
|
|
80
|
+
this.#take = { ctx, stream, recorder, chunks };
|
|
81
|
+
return true;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// Release anything acquired before the failure — otherwise the mic indicator stays lit.
|
|
84
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
85
|
+
this.#streamEnded();
|
|
86
|
+
if (ctx) void ctx.close().catch(() => undefined);
|
|
87
|
+
throw new MicrophoneError(error);
|
|
88
|
+
} finally {
|
|
89
|
+
this.#pending = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Stop the take and decode it to 16 kHz mono PCM. Returns null when nothing was recorded (no
|
|
95
|
+
* take in flight, or an empty capture). Always releases the mic, even if stopping throws.
|
|
96
|
+
*/
|
|
97
|
+
async stop(): Promise<Float32Array | null> {
|
|
98
|
+
const take = this.#take;
|
|
99
|
+
this.#take = null; // claim synchronously so a re-entrant stop can't double-clean
|
|
100
|
+
if (!take) return null;
|
|
101
|
+
const { recorder, stream, ctx, chunks } = take;
|
|
102
|
+
try {
|
|
103
|
+
await new Promise<void>((resolve) => {
|
|
104
|
+
// The recorder auto-stops when its track ends (device unplugged, permission revoked). stop()
|
|
105
|
+
// would then be a silent no-op and onstop would never fire — resolve immediately instead.
|
|
106
|
+
if (recorder.state === "inactive") {
|
|
107
|
+
resolve();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
recorder.onstop = () => resolve();
|
|
111
|
+
recorder.stop();
|
|
112
|
+
});
|
|
113
|
+
} finally {
|
|
114
|
+
// Always release the mic and context — even if stop() threw (legacy InvalidStateError).
|
|
115
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
116
|
+
this.#streamEnded();
|
|
117
|
+
await ctx.close().catch(() => undefined);
|
|
118
|
+
}
|
|
119
|
+
const blob = new Blob(chunks, { type: recorder.mimeType || "audio/webm" });
|
|
120
|
+
if (blob.size === 0) return null;
|
|
121
|
+
return decodeAudioTo16kMono(await blob.arrayBuffer());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Abandon a take without decoding it (releases the mic; the audio is discarded). Also aborts a start() still awaiting the permission prompt. */
|
|
125
|
+
async cancel(): Promise<void> {
|
|
126
|
+
if (this.#pending && !this.#take) this.#abortPending = true; // start() releases the mic when getUserMedia resolves
|
|
127
|
+
const take = this.#take;
|
|
128
|
+
this.#take = null;
|
|
129
|
+
if (!take) return;
|
|
130
|
+
const { recorder, stream, ctx } = take;
|
|
131
|
+
try {
|
|
132
|
+
await new Promise<void>((resolve) => {
|
|
133
|
+
if (recorder.state === "inactive") {
|
|
134
|
+
resolve();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
recorder.onstop = () => resolve();
|
|
138
|
+
recorder.stop();
|
|
139
|
+
});
|
|
140
|
+
} finally {
|
|
141
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
142
|
+
this.#streamEnded();
|
|
143
|
+
await ctx.close().catch(() => undefined);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export type StreamingAudioPlayer = {
|
|
2
|
+
/** Schedule one PCM chunk to play back-to-back after everything already scheduled. */
|
|
3
|
+
push(samples: Float32Array, sampleRate: number): void;
|
|
4
|
+
/** No more chunks; resolves (and tears down) once everything scheduled has finished playing. */
|
|
5
|
+
end(): Promise<void>;
|
|
6
|
+
/** Barge-in/error: stop playback now and tear down. Idempotent. */
|
|
7
|
+
stop(): void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type StreamingAudioPlayerOptions = {
|
|
11
|
+
/**
|
|
12
|
+
* Called once with the player's audio graph so hosts can tap it for visualization (e.g. connect an
|
|
13
|
+
* AnalyserNode to `gain`). The audible path is gain → destination only — do not connect the tap to
|
|
14
|
+
* the destination or audio doubles.
|
|
15
|
+
*/
|
|
16
|
+
onGraphReady?: (graph: { context: AudioContext; gain: GainNode }) => void;
|
|
17
|
+
/** Called during teardown, before the context closes — dispose whatever onGraphReady created. */
|
|
18
|
+
onTeardown?: () => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Gapless player for streamed TTS: schedules each chunk's PCM into one AudioContext end-to-end.
|
|
23
|
+
* Chunks are butt-joined at a running cursor; if synthesis falls behind playback this yields a small
|
|
24
|
+
* silent gap rather than overlap. Teardown (close ctx) is idempotent, and `stop()` resolves any
|
|
25
|
+
* in-flight `end()` promise so a barge-in can never hang a waiting turn.
|
|
26
|
+
*/
|
|
27
|
+
export function createStreamingAudioPlayer(options: StreamingAudioPlayerOptions = {}): StreamingAudioPlayer {
|
|
28
|
+
const ctx = new AudioContext();
|
|
29
|
+
const gain = ctx.createGain();
|
|
30
|
+
gain.connect(ctx.destination);
|
|
31
|
+
try {
|
|
32
|
+
options.onGraphReady?.({ context: ctx, gain });
|
|
33
|
+
} catch {
|
|
34
|
+
// A throwing host visualization tap must not break (or leak) the player.
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let nextTime = ctx.currentTime;
|
|
38
|
+
let pending = 0;
|
|
39
|
+
let ended = false;
|
|
40
|
+
let torn = false;
|
|
41
|
+
let onDrained: (() => void) | null = null;
|
|
42
|
+
|
|
43
|
+
const teardown = (): void => {
|
|
44
|
+
if (torn) return;
|
|
45
|
+
torn = true;
|
|
46
|
+
try {
|
|
47
|
+
options.onTeardown?.();
|
|
48
|
+
} catch {
|
|
49
|
+
// Host tap disposal errors must not prevent the context from closing.
|
|
50
|
+
}
|
|
51
|
+
void ctx.close().catch(() => undefined);
|
|
52
|
+
};
|
|
53
|
+
const maybeDrain = (): void => {
|
|
54
|
+
if (ended && pending === 0) {
|
|
55
|
+
teardown();
|
|
56
|
+
onDrained?.();
|
|
57
|
+
onDrained = null;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
push(samples, sampleRate) {
|
|
63
|
+
if (torn || samples.length === 0) return;
|
|
64
|
+
// The context is often created outside a click call stack — resume defensively (autoplay policy).
|
|
65
|
+
if (ctx.state === "suspended") void ctx.resume().catch(() => undefined);
|
|
66
|
+
const buffer = ctx.createBuffer(1, samples.length, sampleRate);
|
|
67
|
+
buffer.getChannelData(0).set(samples);
|
|
68
|
+
const source = ctx.createBufferSource();
|
|
69
|
+
source.buffer = buffer;
|
|
70
|
+
source.connect(gain);
|
|
71
|
+
// If synthesis is slower than playback this yields a small silent gap rather than overlap.
|
|
72
|
+
const startAt = Math.max(nextTime, ctx.currentTime);
|
|
73
|
+
pending += 1;
|
|
74
|
+
source.onended = () => {
|
|
75
|
+
pending -= 1;
|
|
76
|
+
maybeDrain();
|
|
77
|
+
};
|
|
78
|
+
source.start(startAt);
|
|
79
|
+
nextTime = startAt + buffer.duration;
|
|
80
|
+
},
|
|
81
|
+
end() {
|
|
82
|
+
ended = true;
|
|
83
|
+
return new Promise<void>((resolve) => {
|
|
84
|
+
if (pending === 0 || torn) {
|
|
85
|
+
teardown();
|
|
86
|
+
resolve();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
onDrained = resolve;
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
stop() {
|
|
93
|
+
ended = true;
|
|
94
|
+
teardown();
|
|
95
|
+
onDrained?.();
|
|
96
|
+
onDrained = null;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|