@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
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import type { BrowserAI } from "../client.js";
|
|
2
|
+
import type { ChatMessage } from "../inference.js";
|
|
3
|
+
import type { RuntimeParameters } from "../generation.js";
|
|
4
|
+
import { runtimeParamsFromPreset } from "../generation.js";
|
|
5
|
+
import { getModelPreset } from "../models.js";
|
|
6
|
+
import type { TtsDefaults } from "../config.js";
|
|
7
|
+
import { TypedEmitter } from "../events.js";
|
|
8
|
+
import { formatError, MicrophoneError, VoiceTurnError } from "../errors.js";
|
|
9
|
+
import { AUDIO_SR, pitchShiftBuffer } from "../audio/pcm.js";
|
|
10
|
+
import { MicRecorder } from "../audio/recorder.js";
|
|
11
|
+
import type { StreamingAudioPlayer, StreamingAudioPlayerOptions } from "../audio/streaming-player.js";
|
|
12
|
+
import { createStreamingAudioPlayer } from "../audio/streaming-player.js";
|
|
13
|
+
import type { SynthClip } from "../audio/clip.js";
|
|
14
|
+
import { clipPlaybackRate, playClipThroughElement, releaseClip } from "../audio/clip.js";
|
|
15
|
+
import type { LoadProgress } from "../client.js";
|
|
16
|
+
import type { PipelineTask } from "./helpers.js";
|
|
17
|
+
import {
|
|
18
|
+
capPipelineHistory,
|
|
19
|
+
missingPipelineModels,
|
|
20
|
+
PIPELINE_HISTORY_MAX_TURNS,
|
|
21
|
+
PIPELINE_RECOMMENDED,
|
|
22
|
+
PIPELINE_SYSTEM_PROMPT,
|
|
23
|
+
PIPELINE_TASKS,
|
|
24
|
+
shouldSpeakReply,
|
|
25
|
+
streamedDelta,
|
|
26
|
+
} from "./helpers.js";
|
|
27
|
+
import { stripThinkTags } from "../text-utils.js";
|
|
28
|
+
|
|
29
|
+
export type VoicePipelineStage = "idle" | "listening" | "transcribing" | "thinking" | "speaking" | "error";
|
|
30
|
+
|
|
31
|
+
export type TurnDiscardReason = "contention" | "too-short" | "no-speech" | "empty-reply";
|
|
32
|
+
|
|
33
|
+
export type VoicePipelineEvents = {
|
|
34
|
+
/** Every stage transition. Fired mid-stream too (thinking → speaking on the first audio chunk). */
|
|
35
|
+
stagechange: { stage: VoicePipelineStage; previousStage: VoicePipelineStage };
|
|
36
|
+
/** The mic turned on/off. `recording` can outlive "listening" briefly while the take decodes. */
|
|
37
|
+
recordingchange: { recording: boolean };
|
|
38
|
+
/** The mic is live — attach an AnalyserNode here for input visualization. */
|
|
39
|
+
micstream: { context: AudioContext; source: MediaStreamAudioSourceNode; stream: MediaStream };
|
|
40
|
+
/** The mic graph is being torn down — dispose whatever `micstream` created. */
|
|
41
|
+
micstreamended: Record<string, never>;
|
|
42
|
+
/**
|
|
43
|
+
* Partial transcript while transcribing. Only generative STT models (Gemma, Granite Speech,
|
|
44
|
+
* Voxtral) stream partials — plain Whisper/Moonshine pipelines emit nothing until the final text,
|
|
45
|
+
* so keep a placeholder until the `transcript` event.
|
|
46
|
+
*/
|
|
47
|
+
partialtranscript: { text: string };
|
|
48
|
+
/** The final accepted transcript for the turn. */
|
|
49
|
+
transcript: { text: string };
|
|
50
|
+
/** Streaming reply text — the FULL accumulated, think-stripped text so far. */
|
|
51
|
+
replydelta: { text: string };
|
|
52
|
+
/** Audio is about to be heard: streamed sentences (Kokoro) or one whole clip (other TTS). */
|
|
53
|
+
speechstart: { mode: "streaming" | "clip" };
|
|
54
|
+
/** Non-streaming path only: the synthesized clip, before playback starts. */
|
|
55
|
+
replyclip: { clip: SynthClip };
|
|
56
|
+
/** The turn committed: history now includes this user/assistant exchange. */
|
|
57
|
+
turncommitted: { transcript: string; reply: string; messages: readonly ChatMessage[] };
|
|
58
|
+
/** The take/turn was dropped without committing (see reason). Live UI should discard partials. */
|
|
59
|
+
turndiscarded: { reason: TurnDiscardReason };
|
|
60
|
+
/** A component failed; the pipeline is in the "error" stage until the next take or reset. */
|
|
61
|
+
error: { label: string; message: string; permissionDenied: boolean };
|
|
62
|
+
/**
|
|
63
|
+
* Playback fully drained. NOTE: the pipeline goes idle at synthesis-drain — the tail of playback
|
|
64
|
+
* continues during idle (this keeps talk re-enabled and barge-in possible), so this event fires
|
|
65
|
+
* after the turn already committed.
|
|
66
|
+
*/
|
|
67
|
+
playbackfinished: Record<string, never>;
|
|
68
|
+
/** reset() cleared the conversation. */
|
|
69
|
+
reset: Record<string, never>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type VoicePipelineOptions = {
|
|
73
|
+
/** System turn for the assistant. Defaults to {@link PIPELINE_SYSTEM_PROMPT}. */
|
|
74
|
+
systemPrompt?: string;
|
|
75
|
+
/** How many user↔assistant exchanges are sent as model context (display history is unbounded). */
|
|
76
|
+
historyMaxTurns?: number;
|
|
77
|
+
/**
|
|
78
|
+
* Runtime overrides merged over the conversational defaults (free-form decoding, temperature 0.7,
|
|
79
|
+
* 256 max tokens, unpinned seed, thinking disabled where supported).
|
|
80
|
+
*/
|
|
81
|
+
runtime?: Partial<RuntimeParameters>;
|
|
82
|
+
/** Voice/speed/pitch overrides (defaults come from the client's TTS config). */
|
|
83
|
+
tts?: Partial<TtsDefaults>;
|
|
84
|
+
/** Minimum decoded take length in samples; shorter takes are discarded. Default 16000/4 (~¼s). */
|
|
85
|
+
minTakeSamples?: number;
|
|
86
|
+
/**
|
|
87
|
+
* Extra busy predicate: when it returns true at turn time, the take is discarded ("contention")
|
|
88
|
+
* instead of driving engines that other host work is using. The client's own busy state is always
|
|
89
|
+
* checked; use this for host-side work the client cannot see.
|
|
90
|
+
*/
|
|
91
|
+
isExternalWorkBusy?: () => boolean;
|
|
92
|
+
/** Visualization taps for the streaming audio player's graph. */
|
|
93
|
+
streamingAudio?: StreamingAudioPlayerOptions;
|
|
94
|
+
/**
|
|
95
|
+
* Element used for whole-clip playback (non-Kokoro TTS). Defaults to a detached `new Audio()`.
|
|
96
|
+
* If you attach an analyzer to it, note a media element allows exactly ONE MediaElementSource for
|
|
97
|
+
* its whole life — create the analyzer once and never dispose it while the element is in use.
|
|
98
|
+
*/
|
|
99
|
+
clipAudioElement?: HTMLAudioElement;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Headless press-to-talk voice assistant: mic → STT → text LLM → TTS → speakers, as a state machine
|
|
104
|
+
* with typed events. One turn per press pair: `startListening()` (press) then `finishTurn()`
|
|
105
|
+
* (press again) — or `toggle()` for both. With a Kokoro speech model, replies are spoken
|
|
106
|
+
* sentence-by-sentence while the text model is still generating; other TTS models speak the
|
|
107
|
+
* finished reply as one clip. Pressing talk while a reply plays stops it (barge-in). A turn is
|
|
108
|
+
* committed to history only on success; failures leave history untouched.
|
|
109
|
+
*/
|
|
110
|
+
export class VoicePipeline extends TypedEmitter<VoicePipelineEvents> {
|
|
111
|
+
readonly #client: BrowserAI;
|
|
112
|
+
readonly #options: VoicePipelineOptions;
|
|
113
|
+
readonly #recorder: MicRecorder;
|
|
114
|
+
#stage: VoicePipelineStage = "idle";
|
|
115
|
+
#recording = false;
|
|
116
|
+
#error: string | null = null;
|
|
117
|
+
#transcript = "";
|
|
118
|
+
#reply = "";
|
|
119
|
+
#messages: ChatMessage[] = [];
|
|
120
|
+
#player: StreamingAudioPlayer | null = null;
|
|
121
|
+
#clip: SynthClip | null = null;
|
|
122
|
+
#clipAudio: HTMLAudioElement | null = null;
|
|
123
|
+
|
|
124
|
+
constructor(client: BrowserAI, options: VoicePipelineOptions = {}) {
|
|
125
|
+
super();
|
|
126
|
+
this.#client = client;
|
|
127
|
+
this.#options = options;
|
|
128
|
+
this.#recorder = new MicRecorder({
|
|
129
|
+
onStreamReady: (graph) => this.emit("micstream", graph),
|
|
130
|
+
onStreamEnded: () => this.emit("micstreamended", {}),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get stage(): VoicePipelineStage {
|
|
135
|
+
return this.#stage;
|
|
136
|
+
}
|
|
137
|
+
get recording(): boolean {
|
|
138
|
+
return this.#recording;
|
|
139
|
+
}
|
|
140
|
+
/** Full session history (committed turns only; unbounded — the model sees a capped slice). */
|
|
141
|
+
get messages(): readonly ChatMessage[] {
|
|
142
|
+
return this.#messages;
|
|
143
|
+
}
|
|
144
|
+
get transcript(): string {
|
|
145
|
+
return this.#transcript;
|
|
146
|
+
}
|
|
147
|
+
get reply(): string {
|
|
148
|
+
return this.#reply;
|
|
149
|
+
}
|
|
150
|
+
get error(): string | null {
|
|
151
|
+
return this.#error;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Mid-chain (mic stopped, models running). Listening is NOT busy. */
|
|
155
|
+
busy(): boolean {
|
|
156
|
+
return this.#stage === "transcribing" || this.#stage === "thinking" || this.#stage === "speaking";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Busy OR listening — the pipeline owns (or is about to own) the STT/text/TTS models. */
|
|
160
|
+
active(): boolean {
|
|
161
|
+
return this.busy() || this.#recording;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The chain slots (stt/text/tts) not yet filled by a loaded model. */
|
|
165
|
+
missingModels(): PipelineTask[] {
|
|
166
|
+
return missingPipelineModels(this.#client.occupiedSlots());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Load the recommended model for each unfilled chain slot, sequentially. */
|
|
170
|
+
async loadRecommendedModels(onProgress?: (info: LoadProgress) => void): Promise<void> {
|
|
171
|
+
for (const task of PIPELINE_TASKS) {
|
|
172
|
+
if (!this.#client.slotOwner(task)) {
|
|
173
|
+
await this.#client.load(PIPELINE_RECOMMENDED[task], { onProgress });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Start capturing a take (press-to-talk press). Stops any still-playing reply first (barge-in).
|
|
180
|
+
* Returns false without side effects when a take is already live, a turn is running, the chain
|
|
181
|
+
* models are missing, or other work is busy. Mic failures land in the "error" stage.
|
|
182
|
+
*/
|
|
183
|
+
async startListening(): Promise<boolean> {
|
|
184
|
+
if (this.#recording || this.busy() || this.#otherWorkBusy()) return false;
|
|
185
|
+
if (this.missingModels().length > 0) return false;
|
|
186
|
+
// Barge-in: stop a still-playing reply (whole-clip element or the streaming player).
|
|
187
|
+
this.#stopPlayback();
|
|
188
|
+
try {
|
|
189
|
+
const started = await this.#recorder.start();
|
|
190
|
+
if (!started) return false;
|
|
191
|
+
this.#recording = true;
|
|
192
|
+
this.emit("recordingchange", { recording: true });
|
|
193
|
+
this.#setStage("listening");
|
|
194
|
+
return true;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
const denied = error instanceof MicrophoneError && error.permissionDenied;
|
|
197
|
+
this.#setError(
|
|
198
|
+
"Microphone",
|
|
199
|
+
denied ? "Microphone access was denied. Allow microphone use for this site and try again." : formatError(error),
|
|
200
|
+
denied,
|
|
201
|
+
);
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Press-to-talk convenience: start a take, or finish the live one. */
|
|
207
|
+
async toggle(): Promise<void> {
|
|
208
|
+
if (this.#recording) await this.finishTurn();
|
|
209
|
+
else await this.startListening();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Finish the live take and run the chain: decode → transcribe → generate → speak → commit.
|
|
214
|
+
* Stage/`turndiscarded`/`turncommitted`/`error` events narrate the run.
|
|
215
|
+
*/
|
|
216
|
+
async finishTurn(): Promise<void> {
|
|
217
|
+
if (this.busy()) return;
|
|
218
|
+
const stt = this.#client.sttModel();
|
|
219
|
+
const llm = this.#client.textModel();
|
|
220
|
+
const tts = this.#client.ttsModel();
|
|
221
|
+
if (!stt?.multimodal || !llm || !tts?.multimodal) {
|
|
222
|
+
await this.#stopRecorder().catch(() => undefined); // still release the mic
|
|
223
|
+
this.#setError("Voice pipeline", "Load the transcription, text and speech models before talking.", false);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
this.#setStage("transcribing");
|
|
229
|
+
const pcm = await this.#stopRecorder();
|
|
230
|
+
if (this.#otherWorkBusy()) {
|
|
231
|
+
// Other work started while we were listening — running the chain now would drive the same
|
|
232
|
+
// engines concurrently. Discard the take.
|
|
233
|
+
this.#discard("contention");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!pcm || pcm.length < (this.#options.minTakeSamples ?? AUDIO_SR / 4)) {
|
|
237
|
+
this.#discard("too-short");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const sttResult = await this.#client.transcribe(pcm, (partial) => this.emit("partialtranscript", { text: partial }));
|
|
242
|
+
const transcript = sttResult.text.trim();
|
|
243
|
+
this.#transcript = transcript;
|
|
244
|
+
if (!transcript) {
|
|
245
|
+
this.#discard("no-speech");
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
this.emit("transcript", { text: transcript });
|
|
249
|
+
|
|
250
|
+
this.#setStage("thinking");
|
|
251
|
+
const preset = getModelPreset(llm.modelId);
|
|
252
|
+
// Conversational overrides on the model's defaults: free-form (no JSON constraint), a bit of
|
|
253
|
+
// sampling warmth, short spoken-style replies, and no thinking tokens where supported.
|
|
254
|
+
const runtime: RuntimeParameters = {
|
|
255
|
+
...runtimeParamsFromPreset(preset.defaultRuntime),
|
|
256
|
+
jsonMode: "none",
|
|
257
|
+
temperature: 0.7,
|
|
258
|
+
maxTokens: 256,
|
|
259
|
+
seed: null, // extraction presets pin a seed for reproducibility; conversation should vary
|
|
260
|
+
disableThinking: preset.disableThinkingSupported,
|
|
261
|
+
...this.#options.runtime,
|
|
262
|
+
};
|
|
263
|
+
// Hosts may display the whole session; only the most recent turns go to the model.
|
|
264
|
+
const messages: ChatMessage[] = [
|
|
265
|
+
{ role: "system", content: this.#options.systemPrompt ?? PIPELINE_SYSTEM_PROMPT },
|
|
266
|
+
...capPipelineHistory(this.#messages, this.#options.historyMaxTurns ?? PIPELINE_HISTORY_MAX_TURNS),
|
|
267
|
+
{ role: "user", content: transcript },
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
let reply: string;
|
|
271
|
+
if (this.#client.supportsStreamingTts()) {
|
|
272
|
+
// Kokoro: speak sentences as the LLM produces them (audio starts after the first sentence).
|
|
273
|
+
reply = await this.#runStreamingReply(messages, runtime);
|
|
274
|
+
} else {
|
|
275
|
+
// Non-streaming TTS (e.g. Supertonic): whole reply, then one clip.
|
|
276
|
+
const { text } = await this.#client.generateText(messages, {
|
|
277
|
+
runtime,
|
|
278
|
+
onDelta: (full) => this.emit("replydelta", { text: stripThinkTags(full) }),
|
|
279
|
+
});
|
|
280
|
+
reply = stripThinkTags(text).trim();
|
|
281
|
+
if (shouldSpeakReply(reply)) {
|
|
282
|
+
this.#setStage("speaking");
|
|
283
|
+
this.emit("speechstart", { mode: "clip" });
|
|
284
|
+
const clip = await this.#client.synthesizeToClip(reply, this.#tts());
|
|
285
|
+
releaseClip(this.#clip);
|
|
286
|
+
this.#clip = clip;
|
|
287
|
+
this.emit("replyclip", { clip });
|
|
288
|
+
await this.#playClip(clip);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
this.#reply = reply;
|
|
293
|
+
if (!shouldSpeakReply(reply)) {
|
|
294
|
+
this.#discard("empty-reply");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
this.#messages = [...this.#messages, { role: "user", content: transcript }, { role: "assistant", content: reply }];
|
|
298
|
+
this.emit("turncommitted", { transcript, reply, messages: this.#messages });
|
|
299
|
+
this.#setStage("idle");
|
|
300
|
+
} catch (error) {
|
|
301
|
+
// A mid-stream failure must release the streaming player before surfacing.
|
|
302
|
+
this.#player?.stop();
|
|
303
|
+
this.#player = null;
|
|
304
|
+
const label = error instanceof VoiceTurnError ? error.label : stageErrorLabel(this.#stage);
|
|
305
|
+
this.#setError(label, formatError(error), false);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Clear the conversation (stops any playing reply). Returns false while a take/turn is active —
|
|
311
|
+
* resetting mid-turn would pull history out from under the running chain.
|
|
312
|
+
*/
|
|
313
|
+
reset(): boolean {
|
|
314
|
+
if (this.active()) return false;
|
|
315
|
+
this.#stopPlayback();
|
|
316
|
+
releaseClip(this.#clip);
|
|
317
|
+
this.#clip = null;
|
|
318
|
+
this.#messages = [];
|
|
319
|
+
this.#transcript = "";
|
|
320
|
+
this.#reply = "";
|
|
321
|
+
this.#setStage("idle");
|
|
322
|
+
this.emit("reset", {});
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Tear the pipeline down: stop playback, abandon any live take, drop all listeners. */
|
|
327
|
+
async dispose(): Promise<void> {
|
|
328
|
+
this.#stopPlayback();
|
|
329
|
+
releaseClip(this.#clip);
|
|
330
|
+
this.#clip = null;
|
|
331
|
+
// Detach from a host-supplied clip element so serial pipeline instances don't stack listeners.
|
|
332
|
+
this.#clipAudio?.removeEventListener("ended", this.#clipEnded);
|
|
333
|
+
this.#clipAudio = null;
|
|
334
|
+
await this.#recorder.cancel().catch(() => undefined);
|
|
335
|
+
if (this.#recording) {
|
|
336
|
+
this.#recording = false;
|
|
337
|
+
this.emit("recordingchange", { recording: false });
|
|
338
|
+
}
|
|
339
|
+
this.removeAllListeners();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/* ------------------------------------------------------------------ */
|
|
343
|
+
/* Streaming reply (Kokoro) */
|
|
344
|
+
/* ------------------------------------------------------------------ */
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Generate and speak simultaneously: LLM deltas are pushed into a Kokoro TextSplitterStream,
|
|
348
|
+
* which emits complete sentences; each sentence is synthesized and scheduled gaplessly, so audio
|
|
349
|
+
* starts after the FIRST sentence instead of after the whole reply. Returns the final
|
|
350
|
+
* think-stripped reply. The turn goes idle at synthesis-drain — the tail of playback continues in
|
|
351
|
+
* idle (like the whole-clip path, whose playback resolves at start), so talk re-enables and
|
|
352
|
+
* barge-in stays possible ({@link startListening} stops the player).
|
|
353
|
+
*/
|
|
354
|
+
async #runStreamingReply(messages: ChatMessage[], runtime: RuntimeParameters): Promise<string> {
|
|
355
|
+
const tts = this.#tts();
|
|
356
|
+
const session = await this.#client.createStreamingTts({ voice: tts.voice, speed: tts.speed });
|
|
357
|
+
let player: StreamingAudioPlayer;
|
|
358
|
+
try {
|
|
359
|
+
player = createStreamingAudioPlayer(this.#options.streamingAudio);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
// The session already holds the TTS serialization lock — release it or the client deadlocks.
|
|
362
|
+
session.splitter.close();
|
|
363
|
+
session.release();
|
|
364
|
+
throw new VoiceTurnError("Speech synthesis failed", error);
|
|
365
|
+
}
|
|
366
|
+
this.#player = player;
|
|
367
|
+
|
|
368
|
+
// Consumer: synthesize + schedule each completed sentence; flip to "speaking" on first audio.
|
|
369
|
+
let heardAudio = false;
|
|
370
|
+
const consumer = (async () => {
|
|
371
|
+
for await (const audio of session.chunks) {
|
|
372
|
+
if (this.#player !== player) break; // barge-in/error tore this player down — stop synthesizing
|
|
373
|
+
if (!heardAudio) {
|
|
374
|
+
heardAudio = true;
|
|
375
|
+
this.#setStage("speaking");
|
|
376
|
+
this.emit("speechstart", { mode: "streaming" });
|
|
377
|
+
}
|
|
378
|
+
// Bake the pitch per chunk (duration-preserving), matching the whole-clip path. Speed is
|
|
379
|
+
// already baked into synthesis by Kokoro, so nothing plays with a playbackRate here.
|
|
380
|
+
const pcm = tts.pitch === 1 ? audio.audio : pitchShiftBuffer(audio.audio, tts.pitch);
|
|
381
|
+
player.push(pcm, audio.sampling_rate);
|
|
382
|
+
}
|
|
383
|
+
})();
|
|
384
|
+
consumer.catch(() => undefined); // silences the transient gap; real handling is at the awaits below
|
|
385
|
+
// The consumer always terminates (the splitter is closed on every path below, and barge-in
|
|
386
|
+
// breaks the loop), and its settlement is exactly synthesis-drain — release the TTS lock there.
|
|
387
|
+
void consumer.finally(() => session.release()).catch(() => undefined);
|
|
388
|
+
|
|
389
|
+
let text: string;
|
|
390
|
+
let pushed = "";
|
|
391
|
+
try {
|
|
392
|
+
// onDelta delivers the FULL accumulated text each call — push only the monotonic new suffix of
|
|
393
|
+
// the think-stripped text. A think-tag strip can shrink the text (delta === ""); the tail push
|
|
394
|
+
// below still speaks whatever the incremental pushes missed.
|
|
395
|
+
({ text } = await this.#client.generateText(messages, {
|
|
396
|
+
runtime,
|
|
397
|
+
onDelta: (full) => {
|
|
398
|
+
const clean = stripThinkTags(full);
|
|
399
|
+
this.emit("replydelta", { text: clean });
|
|
400
|
+
const delta = streamedDelta(pushed, clean);
|
|
401
|
+
if (delta) {
|
|
402
|
+
session.splitter.push(delta);
|
|
403
|
+
pushed = clean;
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
}));
|
|
407
|
+
} catch (error) {
|
|
408
|
+
session.splitter.close(); // unblock the consumer's iterator so it can exit
|
|
409
|
+
player.stop();
|
|
410
|
+
// Tag explicitly: the consumer may already have flipped the stage to "speaking".
|
|
411
|
+
throw new VoiceTurnError("Text generation failed", error);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const reply = stripThinkTags(text).trim();
|
|
415
|
+
if (!shouldSpeakReply(reply)) {
|
|
416
|
+
session.splitter.close();
|
|
417
|
+
player.stop(); // nothing worth speaking — drop whatever was queued
|
|
418
|
+
this.#player = null; // cleared before the await so the consumer's break-check stops synthesis
|
|
419
|
+
await consumer.catch(() => undefined);
|
|
420
|
+
return reply;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Push whatever the incremental deltas didn't cover (e.g. after a non-monotonic think-tag strip).
|
|
424
|
+
const tail = streamedDelta(pushed, stripThinkTags(text));
|
|
425
|
+
if (tail) session.splitter.push(tail);
|
|
426
|
+
session.splitter.close();
|
|
427
|
+
try {
|
|
428
|
+
await consumer; // every sentence synthesized + scheduled
|
|
429
|
+
} catch (error) {
|
|
430
|
+
// Tag explicitly: a synthesis failure can surface here while the stage still says "thinking".
|
|
431
|
+
throw new VoiceTurnError("Speech synthesis failed", error);
|
|
432
|
+
}
|
|
433
|
+
// Don't await playback: the player tears itself down when the last chunk finishes, clearing
|
|
434
|
+
// #player unless a newer player has already replaced it. `playbackfinished` fires only on a
|
|
435
|
+
// true drain — a barge-in/reset/dispose stop() clears #player first, so the identity check
|
|
436
|
+
// filters the interrupted case (stop() also resolves the parked end() promise).
|
|
437
|
+
void player.end().then(() => {
|
|
438
|
+
if (this.#player === player) {
|
|
439
|
+
this.#player = null;
|
|
440
|
+
this.emit("playbackfinished", {});
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
return reply;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/* ------------------------------------------------------------------ */
|
|
447
|
+
/* Internals */
|
|
448
|
+
/* ------------------------------------------------------------------ */
|
|
449
|
+
|
|
450
|
+
#tts(): TtsDefaults {
|
|
451
|
+
return { ...this.#client.config.tts, ...this.#options.tts };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
#otherWorkBusy(): boolean {
|
|
455
|
+
return this.#client.isBusy() || this.#options.isExternalWorkBusy?.() === true;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async #stopRecorder(): Promise<Float32Array | null> {
|
|
459
|
+
try {
|
|
460
|
+
return await this.#recorder.stop();
|
|
461
|
+
} finally {
|
|
462
|
+
if (this.#recording) {
|
|
463
|
+
this.#recording = false;
|
|
464
|
+
this.emit("recordingchange", { recording: false });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
readonly #clipEnded = (): void => this.emit("playbackfinished", {});
|
|
470
|
+
|
|
471
|
+
async #playClip(clip: SynthClip): Promise<void> {
|
|
472
|
+
if (!this.#clipAudio) {
|
|
473
|
+
this.#clipAudio = this.#options.clipAudioElement ?? new Audio();
|
|
474
|
+
this.#clipAudio.addEventListener("ended", this.#clipEnded);
|
|
475
|
+
}
|
|
476
|
+
const rate = clipPlaybackRate(this.#client.ttsModel()?.multimodal, this.#tts().speed);
|
|
477
|
+
// Resolves at playback START (matching the streaming path's idle-at-synthesis-drain semantics).
|
|
478
|
+
await playClipThroughElement(this.#clipAudio, clip, rate);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
#stopPlayback(): void {
|
|
482
|
+
this.#clipAudio?.pause();
|
|
483
|
+
this.#player?.stop();
|
|
484
|
+
this.#player = null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
#setStage(stage: VoicePipelineStage): void {
|
|
488
|
+
const previousStage = this.#stage;
|
|
489
|
+
this.#stage = stage;
|
|
490
|
+
this.#error = null;
|
|
491
|
+
if (stage !== previousStage) this.emit("stagechange", { stage, previousStage });
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
#discard(reason: TurnDiscardReason): void {
|
|
495
|
+
this.emit("turndiscarded", { reason });
|
|
496
|
+
this.#setStage("idle");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
#setError(label: string, message: string, permissionDenied: boolean): void {
|
|
500
|
+
const previousStage = this.#stage;
|
|
501
|
+
this.#stage = "error";
|
|
502
|
+
this.#error = `${label}: ${message}`;
|
|
503
|
+
if (previousStage !== "error") this.emit("stagechange", { stage: "error", previousStage });
|
|
504
|
+
this.emit("error", { label, message, permissionDenied });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function stageErrorLabel(stage: VoicePipelineStage): string {
|
|
509
|
+
if (stage === "transcribing") return "Transcription failed";
|
|
510
|
+
if (stage === "thinking") return "Text generation failed";
|
|
511
|
+
if (stage === "speaking") return "Speech synthesis failed";
|
|
512
|
+
return "Voice pipeline failed";
|
|
513
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { WebWorkerMLCEngineHandler } from "@mlc-ai/web-llm";
|
|
2
|
+
|
|
3
|
+
// Route messages from the UI thread to WebLLM's worker-side engine.
|
|
4
|
+
// This keeps model loading and token generation off the main thread.
|
|
5
|
+
const handler = new WebWorkerMLCEngineHandler();
|
|
6
|
+
|
|
7
|
+
self.onmessage = (message: MessageEvent) => {
|
|
8
|
+
handler.onmessage(message);
|
|
9
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# BrowserAI proxy Worker template
|
|
2
|
+
|
|
3
|
+
By default the SDK downloads model artifacts **directly** from Hugging Face (and raw GitHub for
|
|
4
|
+
WebLLM wasm libraries), which works on any site. Hosted deployments sometimes hit CORS or
|
|
5
|
+
cache-storage edge cases with those cross-origin downloads; this Cloudflare Worker makes every
|
|
6
|
+
artifact URL same-origin and restricts proxying to the SDK's model catalog.
|
|
7
|
+
|
|
8
|
+
## Deploy
|
|
9
|
+
|
|
10
|
+
1. Copy `cloudflare-worker.ts` and `wrangler.jsonc` into your project (the Worker imports
|
|
11
|
+
`MODEL_PRESETS` from `@missionsquad/browserai`, so the package must be installed).
|
|
12
|
+
2. Adjust `wrangler.jsonc` (`name`, optionally `routes` for a custom domain, and
|
|
13
|
+
`assets.directory` if the Worker should also serve your site).
|
|
14
|
+
3. `npx wrangler deploy`
|
|
15
|
+
4. Verify: `https://<your-worker-domain>/__worker-health` should return
|
|
16
|
+
`{ ok: true, worker: "browserai-proxy/…" }` and list every Transformers.js preset under
|
|
17
|
+
`allowedHuggingFaceRepos`.
|
|
18
|
+
|
|
19
|
+
## Point the SDK at it
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { BrowserAI } from "@missionsquad/browserai";
|
|
23
|
+
|
|
24
|
+
// Same origin (the Worker serves your site too):
|
|
25
|
+
const ai = new BrowserAI({ modelSource: "proxy" });
|
|
26
|
+
|
|
27
|
+
// Separate proxy origin:
|
|
28
|
+
const ai2 = new BrowserAI({ modelSource: "proxy", proxyOrigin: "https://browserai-proxy.example.workers.dev" });
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The SDK probes `/__worker-health` plus the selected model's config before each proxied load
|
|
32
|
+
(`verifyProxy: false` disables the probe). Kokoro is the one exception to proxying: kokoro-js
|
|
33
|
+
ships its own runtime and fetches its weights directly from its CDN/Hugging Face.
|
|
34
|
+
|
|
35
|
+
## Allowlist
|
|
36
|
+
|
|
37
|
+
`/hf-transformers/*` entries are derived from the SDK catalog at build time, so they never drift.
|
|
38
|
+
The `/hf/*` (MLC weights) and `/gh-raw/*` (wasm libraries) allowlists are explicit constants at the
|
|
39
|
+
top of `cloudflare-worker.ts` — extend them if you add custom MLC records.
|