@runtypelabs/persona 4.24.0 → 4.25.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/dist/approval-ui.d.cts +63 -0
- package/dist/approval-ui.d.ts +63 -0
- package/dist/artifacts-ui.d.cts +63 -0
- package/dist/artifacts-ui.d.ts +63 -0
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +63 -0
- package/dist/context-mentions.d.ts +63 -0
- package/dist/event-stream-view.d.cts +63 -0
- package/dist/event-stream-view.d.ts +63 -0
- package/dist/forms-ui.d.cts +74 -0
- package/dist/forms-ui.d.ts +74 -0
- package/dist/index.cjs +42 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +75 -1
- package/dist/index.d.ts +75 -1
- package/dist/index.global.js +27 -27
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +42 -42
- package/dist/index.js.map +1 -1
- package/dist/session-reconnect.d.cts +63 -0
- package/dist/session-reconnect.d.ts +63 -0
- package/dist/smart-dom-reader.d.cts +63 -0
- package/dist/smart-dom-reader.d.ts +63 -0
- package/dist/theme-editor-preview.cjs +49 -49
- package/dist/theme-editor-preview.d.cts +63 -0
- package/dist/theme-editor-preview.d.ts +63 -0
- package/dist/theme-editor-preview.js +27 -27
- package/dist/theme-editor.d.cts +63 -0
- package/dist/theme-editor.d.ts +63 -0
- package/dist/voice-runtime-7DA7MJH4.js +1 -0
- package/dist/voice-runtime.cjs +1 -1
- package/dist/voice-runtime.d.cts +33 -0
- package/dist/voice-runtime.d.ts +33 -0
- package/dist/voice-runtime.js +1 -1
- package/package.json +1 -1
- package/src/index-core.ts +2 -0
- package/src/session.ts +30 -1
- package/src/session.voice.test.ts +72 -1
- package/src/types.ts +71 -0
- package/src/ui.ts +59 -0
- package/src/ui.voice-prewarm.test.ts +222 -0
- package/src/voice/runtype-voice-provider.ts +238 -27
- package/src/voice/voice.test.ts +272 -2
- package/dist/voice-runtime-EFR3R5ES.js +0 -1
|
@@ -16,6 +16,14 @@
|
|
|
16
16
|
// A continuous always-hot mic is, in UX terms, a permanent barge-in session, so
|
|
17
17
|
// `getInterruptionMode()` reports the constant `'barge-in'` and the existing
|
|
18
18
|
// mic-button wiring (ui.ts) treats a click as "hang up at any state" unchanged.
|
|
19
|
+
//
|
|
20
|
+
// Prewarm (`prewarm()`, called on mic intent): by default a fire-and-forget
|
|
21
|
+
// `POST /v1/client/agents/:agentId/voice/prewarm`. With `prewarmMode: 'attach'`
|
|
22
|
+
// the WebSocket opens early with `clientCapabilities=attach` and the extra
|
|
23
|
+
// `runtype.attach` subprotocol. If the server negotiates `runtype.attach`, the
|
|
24
|
+
// socket idles (no call) until `startListening()` sends `{"type":"start"}`; if
|
|
25
|
+
// it negotiates plain `runtype.bearer`, it ignored attach and the call is
|
|
26
|
+
// already live, so `startListening()` adopts it as is.
|
|
19
27
|
|
|
20
28
|
import type {
|
|
21
29
|
VoiceProvider,
|
|
@@ -37,6 +45,29 @@ const CAPTURE_BUFFER_SIZE = 4096;
|
|
|
37
45
|
*/
|
|
38
46
|
const LEVEL_RMS_SCALE = 4;
|
|
39
47
|
const RIFF_MAGIC = 0x52494646; // "RIFF"
|
|
48
|
+
/** `prewarm()` warms at most once per this window per provider instance. */
|
|
49
|
+
const PREWARM_THROTTLE_MS = 30_000;
|
|
50
|
+
/** How long `startListening()` waits on an attach still handshaking. */
|
|
51
|
+
const ATTACH_HANDSHAKE_TIMEOUT_MS = 5_000;
|
|
52
|
+
/** Attached-socket idle window: server default and clamp range. */
|
|
53
|
+
const ATTACH_IDLE_DEFAULT_MS = 30_000;
|
|
54
|
+
const ATTACH_IDLE_MIN_MS = 30_000;
|
|
55
|
+
const ATTACH_IDLE_MAX_MS = 600_000;
|
|
56
|
+
|
|
57
|
+
/** A socket opened by an `'attach'` prewarm, waiting for the click. */
|
|
58
|
+
type AttachedSocket = {
|
|
59
|
+
ws: WebSocket;
|
|
60
|
+
/**
|
|
61
|
+
* `connecting` until the handshake completes; then `attached` (no call yet,
|
|
62
|
+
* needs `start`) or `live` (the server ignored attach and the call is live).
|
|
63
|
+
*/
|
|
64
|
+
state: "connecting" | "attached" | "live";
|
|
65
|
+
/** Settles when the handshake completes, fails, or the socket is released. */
|
|
66
|
+
ready: Promise<void>;
|
|
67
|
+
settle: () => void;
|
|
68
|
+
openedAt: number;
|
|
69
|
+
idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
70
|
+
};
|
|
40
71
|
|
|
41
72
|
/**
|
|
42
73
|
* Strip the canonical 44-byte WAV header (if present) and return the raw PCM16
|
|
@@ -63,6 +94,16 @@ function toWsBase(host: string): string {
|
|
|
63
94
|
return `${secure ? "wss:" : "ws:"}//${trimmed}`;
|
|
64
95
|
}
|
|
65
96
|
|
|
97
|
+
/** Derive an http(s):// base URL from a configured host (the ws base, reversed). */
|
|
98
|
+
function toHttpBase(host: string): string {
|
|
99
|
+
return toWsBase(host).replace(/^ws/i, "http");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function clampAttachIdleMs(ms: number | undefined): number {
|
|
103
|
+
if (typeof ms !== "number" || !Number.isFinite(ms)) return ATTACH_IDLE_DEFAULT_MS;
|
|
104
|
+
return Math.min(ATTACH_IDLE_MAX_MS, Math.max(ATTACH_IDLE_MIN_MS, Math.round(ms)));
|
|
105
|
+
}
|
|
106
|
+
|
|
66
107
|
export class RuntypeVoiceProvider implements VoiceProvider {
|
|
67
108
|
type: "runtype" = "runtype";
|
|
68
109
|
|
|
@@ -87,6 +128,10 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
87
128
|
// Distinguishes a user-initiated close (code 1000) from a dropped connection.
|
|
88
129
|
private intentionalClose = false;
|
|
89
130
|
|
|
131
|
+
// Prewarm latch and the (at most one) socket an `'attach'` prewarm opened.
|
|
132
|
+
private lastPrewarmAt = Number.NEGATIVE_INFINITY;
|
|
133
|
+
private attached: AttachedSocket | null = null;
|
|
134
|
+
|
|
90
135
|
private resultCallbacks: ((result: VoiceResult) => void)[] = [];
|
|
91
136
|
private errorCallbacks: ((error: Error) => void)[] = [];
|
|
92
137
|
private statusCallbacks: ((status: VoiceStatus) => void)[] = [];
|
|
@@ -104,6 +149,153 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
104
149
|
/** No-op: the WS session opens lazily in `startListening` (the "call"). */
|
|
105
150
|
async connect(): Promise<void> {}
|
|
106
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Warm the voice path ahead of the click. Fire-and-forget and throttled to
|
|
154
|
+
* once per {@link PREWARM_THROTTLE_MS}; never emits an error or a status.
|
|
155
|
+
*/
|
|
156
|
+
prewarm(): void {
|
|
157
|
+
if (this.callLive || this.attached) return;
|
|
158
|
+
const agentId = this.config?.agentId;
|
|
159
|
+
const token = this.config?.clientToken;
|
|
160
|
+
const host = this.config?.host;
|
|
161
|
+
if (!agentId || !token || !host) return;
|
|
162
|
+
const now = Date.now();
|
|
163
|
+
if (now - this.lastPrewarmAt < PREWARM_THROTTLE_MS) return;
|
|
164
|
+
this.lastPrewarmAt = now;
|
|
165
|
+
try {
|
|
166
|
+
if (this.config?.prewarmMode === "attach") {
|
|
167
|
+
this.attach(host, agentId, token);
|
|
168
|
+
} else {
|
|
169
|
+
this.requestPrewarm(host, agentId, token);
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
// A prewarm must never surface an error; the click connects normally.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private requestPrewarm(host: string, agentId: string, token: string): void {
|
|
177
|
+
if (typeof fetch !== "function") return;
|
|
178
|
+
const url = `${toHttpBase(host)}/v1/client/agents/${encodeURIComponent(agentId)}/voice/prewarm`;
|
|
179
|
+
void fetch(url, {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
182
|
+
keepalive: true,
|
|
183
|
+
}).catch(() => {});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private attach(host: string, agentId: string, token: string): void {
|
|
187
|
+
const params = new URLSearchParams({ clientCapabilities: "attach" });
|
|
188
|
+
const configuredIdleMs = this.config?.attachIdleMs;
|
|
189
|
+
const idleMs = clampAttachIdleMs(configuredIdleMs);
|
|
190
|
+
if (configuredIdleMs !== undefined) params.set("attachIdleMs", String(idleMs));
|
|
191
|
+
const ws = new WebSocket(`${this.voiceSocketUrl(host, agentId)}?${params}`, [
|
|
192
|
+
"runtype.bearer",
|
|
193
|
+
"runtype.attach",
|
|
194
|
+
token,
|
|
195
|
+
]);
|
|
196
|
+
ws.binaryType = "arraybuffer";
|
|
197
|
+
let settle: () => void = () => {};
|
|
198
|
+
const ready = new Promise<void>((resolve) => {
|
|
199
|
+
settle = resolve;
|
|
200
|
+
});
|
|
201
|
+
const entry: AttachedSocket = {
|
|
202
|
+
ws,
|
|
203
|
+
state: "connecting",
|
|
204
|
+
ready,
|
|
205
|
+
settle,
|
|
206
|
+
openedAt: 0,
|
|
207
|
+
idleTimer: undefined,
|
|
208
|
+
};
|
|
209
|
+
this.attached = entry;
|
|
210
|
+
|
|
211
|
+
ws.onopen = () => {
|
|
212
|
+
if (this.attached !== entry) return;
|
|
213
|
+
entry.state = ws.protocol === "runtype.attach" ? "attached" : "live";
|
|
214
|
+
entry.openedAt = Date.now();
|
|
215
|
+
this.armAttachIdle(entry, idleMs);
|
|
216
|
+
if (entry.state === "attached") ws.send('{"type":"ping"}');
|
|
217
|
+
entry.settle();
|
|
218
|
+
};
|
|
219
|
+
ws.onmessage = (event) => {
|
|
220
|
+
if (this.attached !== entry || typeof event.data !== "string") return;
|
|
221
|
+
let msg: { type?: unknown; idleMs?: unknown };
|
|
222
|
+
try {
|
|
223
|
+
msg = JSON.parse(event.data);
|
|
224
|
+
} catch {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// The server's idle window is authoritative; close no later than it does.
|
|
228
|
+
if (msg.type === "attached" && typeof msg.idleMs === "number") {
|
|
229
|
+
this.armAttachIdle(entry, msg.idleMs - (Date.now() - entry.openedAt));
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
ws.onerror = () => {};
|
|
233
|
+
ws.onclose = () => {
|
|
234
|
+
if (this.attached === entry) this.releaseAttached();
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private armAttachIdle(entry: AttachedSocket, ms: number): void {
|
|
239
|
+
clearTimeout(entry.idleTimer);
|
|
240
|
+
entry.idleTimer = setTimeout(() => {
|
|
241
|
+
if (this.attached === entry) this.releaseAttached();
|
|
242
|
+
}, Math.max(0, ms));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Close and forget the prewarmed socket, if any. Silent by design. */
|
|
246
|
+
private releaseAttached(): void {
|
|
247
|
+
const entry = this.attached;
|
|
248
|
+
if (!entry) return;
|
|
249
|
+
this.attached = null;
|
|
250
|
+
clearTimeout(entry.idleTimer);
|
|
251
|
+
const { ws } = entry;
|
|
252
|
+
ws.onopen = null;
|
|
253
|
+
ws.onmessage = null;
|
|
254
|
+
ws.onerror = null;
|
|
255
|
+
ws.onclose = null;
|
|
256
|
+
entry.settle();
|
|
257
|
+
if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) {
|
|
258
|
+
try {
|
|
259
|
+
ws.close(1000, "prewarm released");
|
|
260
|
+
} catch {
|
|
261
|
+
// ignore
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Hand the prewarmed socket to the call, waiting up to
|
|
268
|
+
* {@link ATTACH_HANDSHAKE_TIMEOUT_MS} for a handshake still in flight.
|
|
269
|
+
* Resolves `null` when there is none, it stalled, or it dropped, so the
|
|
270
|
+
* caller opens a fresh socket.
|
|
271
|
+
*/
|
|
272
|
+
private async takeAttachedSocket(): Promise<{ ws: WebSocket; live: boolean } | null> {
|
|
273
|
+
const entry = this.attached;
|
|
274
|
+
if (!entry) return null;
|
|
275
|
+
if (entry.state === "connecting") {
|
|
276
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
277
|
+
await Promise.race([
|
|
278
|
+
entry.ready,
|
|
279
|
+
new Promise<void>((resolve) => {
|
|
280
|
+
timer = setTimeout(resolve, ATTACH_HANDSHAKE_TIMEOUT_MS);
|
|
281
|
+
}),
|
|
282
|
+
]);
|
|
283
|
+
clearTimeout(timer);
|
|
284
|
+
}
|
|
285
|
+
if (this.attached !== entry) return null;
|
|
286
|
+
if (entry.state === "connecting" || entry.ws.readyState !== WebSocket.OPEN) {
|
|
287
|
+
this.releaseAttached();
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
this.attached = null;
|
|
291
|
+
clearTimeout(entry.idleTimer);
|
|
292
|
+
return { ws: entry.ws, live: entry.state === "live" };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private voiceSocketUrl(host: string, agentId: string): string {
|
|
296
|
+
return `${toWsBase(host)}/ws/agents/${encodeURIComponent(agentId)}/voice`;
|
|
297
|
+
}
|
|
298
|
+
|
|
107
299
|
/** Start the call: acquire mic, open the WS, stream PCM until hang-up. */
|
|
108
300
|
async startListening(): Promise<void> {
|
|
109
301
|
if (this.callLive) return; // idempotent: a call is already live
|
|
@@ -164,11 +356,25 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
164
356
|
}
|
|
165
357
|
});
|
|
166
358
|
|
|
167
|
-
const
|
|
359
|
+
const adopted = await this.takeAttachedSocket();
|
|
360
|
+
if (generation !== this.callGeneration) {
|
|
361
|
+
adopted?.ws.close(1000, "client ended call");
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (adopted) {
|
|
365
|
+
const { ws } = adopted;
|
|
366
|
+
this.ws = ws;
|
|
367
|
+
this.bindCallSocket(ws, generation);
|
|
368
|
+
if (!adopted.live) ws.send('{"type":"start"}');
|
|
369
|
+
this.emitStatus("listening");
|
|
370
|
+
this.startCapture(captureContext, stream, ws, generation);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
168
374
|
// Token rides the subprotocol; `runtype.bearer` is the marker the server
|
|
169
375
|
// echoes as the negotiated subprotocol (browsers fail the handshake if an
|
|
170
376
|
// offered subprotocol goes unanswered).
|
|
171
|
-
const ws = new WebSocket(
|
|
377
|
+
const ws = new WebSocket(this.voiceSocketUrl(host, agentId), ["runtype.bearer", token]);
|
|
172
378
|
ws.binaryType = "arraybuffer";
|
|
173
379
|
this.ws = ws;
|
|
174
380
|
|
|
@@ -177,31 +383,7 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
177
383
|
this.emitStatus("listening");
|
|
178
384
|
this.startCapture(captureContext, stream, ws, generation);
|
|
179
385
|
};
|
|
180
|
-
|
|
181
|
-
ws.onmessage = (event) => this.handleMessage(event, generation);
|
|
182
|
-
|
|
183
|
-
ws.onerror = () => {
|
|
184
|
-
if (generation !== this.callGeneration) return;
|
|
185
|
-
this.emitError(new Error("Voice connection failed"));
|
|
186
|
-
this.emitStatus("error");
|
|
187
|
-
this.cleanup();
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
ws.onclose = (evt) => {
|
|
191
|
-
if (this.intentionalClose) {
|
|
192
|
-
this.intentionalClose = false;
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
if (generation !== this.callGeneration) return;
|
|
196
|
-
if (evt.code !== 1000) {
|
|
197
|
-
const codeMsg = evt.code ? ` (code ${evt.code})` : "";
|
|
198
|
-
this.emitError(new Error(`Voice connection closed${codeMsg}`));
|
|
199
|
-
this.emitStatus("error");
|
|
200
|
-
} else {
|
|
201
|
-
this.emitStatus("idle");
|
|
202
|
-
}
|
|
203
|
-
this.cleanup();
|
|
204
|
-
};
|
|
386
|
+
this.bindCallSocket(ws, generation);
|
|
205
387
|
} catch (error) {
|
|
206
388
|
this.cleanup();
|
|
207
389
|
this.emitError(error as Error);
|
|
@@ -210,6 +392,34 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
210
392
|
}
|
|
211
393
|
}
|
|
212
394
|
|
|
395
|
+
/** Route a call socket's frames, errors, and close into this call. */
|
|
396
|
+
private bindCallSocket(ws: WebSocket, generation: number): void {
|
|
397
|
+
ws.onmessage = (event) => this.handleMessage(event, generation);
|
|
398
|
+
|
|
399
|
+
ws.onerror = () => {
|
|
400
|
+
if (generation !== this.callGeneration) return;
|
|
401
|
+
this.emitError(new Error("Voice connection failed"));
|
|
402
|
+
this.emitStatus("error");
|
|
403
|
+
this.cleanup();
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
ws.onclose = (evt) => {
|
|
407
|
+
if (this.intentionalClose) {
|
|
408
|
+
this.intentionalClose = false;
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (generation !== this.callGeneration) return;
|
|
412
|
+
if (evt.code !== 1000) {
|
|
413
|
+
const codeMsg = evt.code ? ` (code ${evt.code})` : "";
|
|
414
|
+
this.emitError(new Error(`Voice connection closed${codeMsg}`));
|
|
415
|
+
this.emitStatus("error");
|
|
416
|
+
} else {
|
|
417
|
+
this.emitStatus("idle");
|
|
418
|
+
}
|
|
419
|
+
this.cleanup();
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
213
423
|
/** End the call (hang up). */
|
|
214
424
|
async stopListening(): Promise<void> {
|
|
215
425
|
this.cleanup();
|
|
@@ -374,6 +584,7 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
374
584
|
this.callGeneration += 1;
|
|
375
585
|
this.callLive = false;
|
|
376
586
|
this.isSpeaking = false;
|
|
587
|
+
this.releaseAttached();
|
|
377
588
|
|
|
378
589
|
if (this.processor) {
|
|
379
590
|
this.processor.onaudioprocess = null;
|
package/src/voice/voice.test.ts
CHANGED
|
@@ -217,14 +217,17 @@ describe('Voice Support Check', () => {
|
|
|
217
217
|
const WS_OPEN = 1;
|
|
218
218
|
|
|
219
219
|
class MockWebSocket {
|
|
220
|
+
static CONNECTING = 0;
|
|
220
221
|
static OPEN = WS_OPEN;
|
|
221
222
|
static instances: MockWebSocket[] = [];
|
|
222
223
|
|
|
223
224
|
url: string;
|
|
224
225
|
protocols: string | string[] | undefined;
|
|
226
|
+
/** Negotiated subprotocol, set by the test before `triggerOpen()`. */
|
|
227
|
+
protocol = '';
|
|
225
228
|
binaryType = '';
|
|
226
229
|
readyState = 0;
|
|
227
|
-
sent: ArrayBuffer
|
|
230
|
+
sent: Array<ArrayBuffer | string> = [];
|
|
228
231
|
closeCalls: Array<{ code?: number; reason?: string }> = [];
|
|
229
232
|
onopen: (() => void) | null = null;
|
|
230
233
|
onmessage: ((ev: { data: unknown }) => void) | null = null;
|
|
@@ -237,7 +240,7 @@ class MockWebSocket {
|
|
|
237
240
|
MockWebSocket.instances.push(this);
|
|
238
241
|
}
|
|
239
242
|
|
|
240
|
-
send(data: ArrayBuffer) {
|
|
243
|
+
send(data: ArrayBuffer | string) {
|
|
241
244
|
this.sent.push(data);
|
|
242
245
|
}
|
|
243
246
|
|
|
@@ -535,4 +538,271 @@ describe('RuntypeVoiceProvider (realtime streaming)', () => {
|
|
|
535
538
|
engine.finishedCb?.(); // playback drained
|
|
536
539
|
expect(statuses).toEqual(['listening']);
|
|
537
540
|
});
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
describe('RuntypeVoiceProvider prewarm', () => {
|
|
544
|
+
let getUserMedia: ReturnType<typeof vi.fn>;
|
|
545
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
546
|
+
|
|
547
|
+
beforeEach(() => {
|
|
548
|
+
vi.useFakeTimers();
|
|
549
|
+
MockWebSocket.instances = [];
|
|
550
|
+
getUserMedia = vi.fn(async () => makeStream().stream);
|
|
551
|
+
fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
|
552
|
+
vi.stubGlobal('WebSocket', MockWebSocket);
|
|
553
|
+
vi.stubGlobal('navigator', { mediaDevices: { getUserMedia } });
|
|
554
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
555
|
+
(globalThis as any).window.AudioContext = MockAudioContext;
|
|
556
|
+
(globalThis as any).window.webkitAudioContext = MockAudioContext;
|
|
557
|
+
(globalThis as any).window.location = { protocol: 'https:' };
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
afterEach(() => {
|
|
561
|
+
vi.useRealTimers();
|
|
562
|
+
vi.unstubAllGlobals();
|
|
563
|
+
delete (globalThis as any).window.AudioContext;
|
|
564
|
+
delete (globalThis as any).window.webkitAudioContext;
|
|
565
|
+
delete (globalThis as any).window.location;
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
const config = (extra: Partial<NonNullable<VoiceConfig['runtype']>> = {}) => ({
|
|
569
|
+
agentId: 'agent/1',
|
|
570
|
+
clientToken: 'ct_secret',
|
|
571
|
+
host: 'https://api.example.com',
|
|
572
|
+
...extra,
|
|
573
|
+
});
|
|
574
|
+
const attachConfig = (extra: Partial<NonNullable<VoiceConfig['runtype']>> = {}) =>
|
|
575
|
+
config({ prewarmMode: 'attach', ...extra });
|
|
576
|
+
const lastWs = () => MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
|
577
|
+
const openAs = (ws: MockWebSocket, protocol: string) => {
|
|
578
|
+
ws.protocol = protocol;
|
|
579
|
+
ws.triggerOpen();
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
describe("'request' mode (default)", () => {
|
|
583
|
+
it('POSTs the prewarm endpoint with the bearer token and no body', () => {
|
|
584
|
+
new RuntypeVoiceProvider(config()).prewarm();
|
|
585
|
+
|
|
586
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
587
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
588
|
+
expect(url).toBe('https://api.example.com/v1/client/agents/agent%2F1/voice/prewarm');
|
|
589
|
+
expect(init).toEqual({
|
|
590
|
+
method: 'POST',
|
|
591
|
+
headers: { Authorization: 'Bearer ct_secret' },
|
|
592
|
+
keepalive: true,
|
|
593
|
+
});
|
|
594
|
+
expect(MockWebSocket.instances).toHaveLength(0);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it.each([
|
|
598
|
+
['http://localhost:8787', 'http://localhost:8787'],
|
|
599
|
+
['wss://api.example.com', 'https://api.example.com'],
|
|
600
|
+
['api.example.com', 'https://api.example.com'],
|
|
601
|
+
])('derives the http base from %s', (host, base) => {
|
|
602
|
+
new RuntypeVoiceProvider(config({ host })).prewarm();
|
|
603
|
+
expect(fetchMock.mock.calls[0][0]).toBe(`${base}/v1/client/agents/agent%2F1/voice/prewarm`);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it('fires at most once per 30s per provider', () => {
|
|
607
|
+
const provider = new RuntypeVoiceProvider(config());
|
|
608
|
+
provider.prewarm();
|
|
609
|
+
provider.prewarm();
|
|
610
|
+
vi.advanceTimersByTime(29_999);
|
|
611
|
+
provider.prewarm();
|
|
612
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
613
|
+
vi.advanceTimersByTime(1);
|
|
614
|
+
provider.prewarm();
|
|
615
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('swallows rejected and throwing fetches without emitting errors or status', async () => {
|
|
619
|
+
const errors: Error[] = [];
|
|
620
|
+
const statuses: string[] = [];
|
|
621
|
+
fetchMock.mockRejectedValueOnce(new Error('offline'));
|
|
622
|
+
const provider = new RuntypeVoiceProvider(config());
|
|
623
|
+
provider.onError((e) => errors.push(e));
|
|
624
|
+
provider.onStatusChange((s) => statuses.push(s));
|
|
625
|
+
provider.prewarm();
|
|
626
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
627
|
+
fetchMock.mockImplementationOnce(() => {
|
|
628
|
+
throw new Error('sync');
|
|
629
|
+
});
|
|
630
|
+
expect(() => provider.prewarm()).not.toThrow();
|
|
631
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
632
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
633
|
+
expect(errors).toEqual([]);
|
|
634
|
+
expect(statuses).toEqual([]);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('does nothing without credentials or during a live call', async () => {
|
|
638
|
+
new RuntypeVoiceProvider(config({ clientToken: undefined })).prewarm();
|
|
639
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
640
|
+
|
|
641
|
+
const provider = new RuntypeVoiceProvider(config());
|
|
642
|
+
await provider.startListening();
|
|
643
|
+
provider.prewarm();
|
|
644
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
describe("'attach' mode", () => {
|
|
649
|
+
it('opens the voice socket with the attach capability and subprotocol, then pings', () => {
|
|
650
|
+
new RuntypeVoiceProvider(attachConfig()).prewarm();
|
|
651
|
+
|
|
652
|
+
const ws = lastWs();
|
|
653
|
+
expect(ws.url).toBe('wss://api.example.com/ws/agents/agent%2F1/voice?clientCapabilities=attach');
|
|
654
|
+
expect(ws.protocols).toEqual(['runtype.bearer', 'runtype.attach', 'ct_secret']);
|
|
655
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
656
|
+
openAs(ws, 'runtype.attach');
|
|
657
|
+
expect(ws.sent).toEqual(['{"type":"ping"}']);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('sends a clamped attachIdleMs when configured', () => {
|
|
661
|
+
new RuntypeVoiceProvider(attachConfig({ attachIdleMs: 5_000 })).prewarm();
|
|
662
|
+
expect(lastWs().url).toContain('clientCapabilities=attach&attachIdleMs=30000');
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it('never attaches twice concurrently', () => {
|
|
666
|
+
const provider = new RuntypeVoiceProvider(attachConfig());
|
|
667
|
+
provider.prewarm();
|
|
668
|
+
vi.advanceTimersByTime(60_000);
|
|
669
|
+
provider.prewarm();
|
|
670
|
+
expect(MockWebSocket.instances).toHaveLength(1);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('reuses an attached socket on click: sends start and captures on it', async () => {
|
|
674
|
+
const statuses: string[] = [];
|
|
675
|
+
const provider = new RuntypeVoiceProvider(attachConfig());
|
|
676
|
+
provider.onStatusChange((s) => statuses.push(s));
|
|
677
|
+
provider.prewarm();
|
|
678
|
+
const ws = lastWs();
|
|
679
|
+
openAs(ws, 'runtype.attach');
|
|
680
|
+
ws.triggerMessage('{"type":"attached","idleMs":30000}');
|
|
681
|
+
|
|
682
|
+
await provider.startListening();
|
|
683
|
+
|
|
684
|
+
expect(MockWebSocket.instances).toHaveLength(1);
|
|
685
|
+
expect(ws.sent).toEqual(['{"type":"ping"}', '{"type":"start"}']);
|
|
686
|
+
expect(statuses).toEqual(['listening']);
|
|
687
|
+
pumpCapture(constantBuffer(0.1));
|
|
688
|
+
expect(ws.sent).toHaveLength(3);
|
|
689
|
+
// The call owns the socket now: the attach idle timer no longer closes it.
|
|
690
|
+
vi.advanceTimersByTime(600_000);
|
|
691
|
+
expect(ws.closeCalls).toEqual([]);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('adopts a socket whose server ignored attach without sending start', async () => {
|
|
695
|
+
const transcripts: string[] = [];
|
|
696
|
+
const provider = new RuntypeVoiceProvider(attachConfig());
|
|
697
|
+
provider.onTranscript((_role, text) => transcripts.push(text));
|
|
698
|
+
provider.prewarm();
|
|
699
|
+
const ws = lastWs();
|
|
700
|
+
openAs(ws, 'runtype.bearer');
|
|
701
|
+
expect(ws.sent).toEqual([]);
|
|
702
|
+
|
|
703
|
+
await provider.startListening();
|
|
704
|
+
|
|
705
|
+
expect(MockWebSocket.instances).toHaveLength(1);
|
|
706
|
+
expect(ws.sent).toEqual([]);
|
|
707
|
+
ws.triggerMessage(JSON.stringify({ type: 'transcript_final', role: 'user', text: 'hi' }));
|
|
708
|
+
expect(transcripts).toEqual(['hi']);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it('opens a fresh socket when the attached one dropped before the click', async () => {
|
|
712
|
+
const provider = new RuntypeVoiceProvider(attachConfig());
|
|
713
|
+
provider.prewarm();
|
|
714
|
+
const attached = lastWs();
|
|
715
|
+
openAs(attached, 'runtype.attach');
|
|
716
|
+
attached.triggerClose(4408);
|
|
717
|
+
|
|
718
|
+
await provider.startListening();
|
|
719
|
+
|
|
720
|
+
expect(MockWebSocket.instances).toHaveLength(2);
|
|
721
|
+
expect(lastWs().url).toBe('wss://api.example.com/ws/agents/agent%2F1/voice');
|
|
722
|
+
expect(lastWs().protocols).toEqual(['runtype.bearer', 'ct_secret']);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('abandons a handshake stalled for 5s and opens a fresh socket', async () => {
|
|
726
|
+
const provider = new RuntypeVoiceProvider(attachConfig());
|
|
727
|
+
provider.prewarm();
|
|
728
|
+
const stalled = lastWs();
|
|
729
|
+
|
|
730
|
+
const started = provider.startListening();
|
|
731
|
+
await vi.advanceTimersByTimeAsync(4_999);
|
|
732
|
+
expect(MockWebSocket.instances).toHaveLength(1);
|
|
733
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
734
|
+
await started;
|
|
735
|
+
|
|
736
|
+
expect(stalled.closeCalls).toEqual([{ code: 1000, reason: 'prewarm released' }]);
|
|
737
|
+
expect(MockWebSocket.instances).toHaveLength(2);
|
|
738
|
+
expect(lastWs().protocols).toEqual(['runtype.bearer', 'ct_secret']);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('uses an attach that completes while the click waits on it', async () => {
|
|
742
|
+
const provider = new RuntypeVoiceProvider(attachConfig());
|
|
743
|
+
provider.prewarm();
|
|
744
|
+
const ws = lastWs();
|
|
745
|
+
|
|
746
|
+
const started = provider.startListening();
|
|
747
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
748
|
+
openAs(ws, 'runtype.attach');
|
|
749
|
+
await started;
|
|
750
|
+
|
|
751
|
+
expect(MockWebSocket.instances).toHaveLength(1);
|
|
752
|
+
expect(ws.sent).toContain('{"type":"start"}');
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it('closes an unused attached socket at the idle window', () => {
|
|
756
|
+
const errors: Error[] = [];
|
|
757
|
+
const provider = new RuntypeVoiceProvider(attachConfig());
|
|
758
|
+
provider.onError((e) => errors.push(e));
|
|
759
|
+
provider.prewarm();
|
|
760
|
+
const ws = lastWs();
|
|
761
|
+
openAs(ws, 'runtype.attach');
|
|
762
|
+
|
|
763
|
+
vi.advanceTimersByTime(29_999);
|
|
764
|
+
expect(ws.closeCalls).toEqual([]);
|
|
765
|
+
vi.advanceTimersByTime(1);
|
|
766
|
+
expect(ws.closeCalls).toEqual([{ code: 1000, reason: 'prewarm released' }]);
|
|
767
|
+
expect(errors).toEqual([]);
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it("follows the server's idle window from the attached reply", () => {
|
|
771
|
+
const provider = new RuntypeVoiceProvider(attachConfig({ attachIdleMs: 120_000 }));
|
|
772
|
+
provider.prewarm();
|
|
773
|
+
const ws = lastWs();
|
|
774
|
+
openAs(ws, 'runtype.attach');
|
|
775
|
+
vi.advanceTimersByTime(1_000);
|
|
776
|
+
ws.triggerMessage('{"type":"attached","idleMs":60000}');
|
|
777
|
+
|
|
778
|
+
vi.advanceTimersByTime(58_999);
|
|
779
|
+
expect(ws.closeCalls).toEqual([]);
|
|
780
|
+
vi.advanceTimersByTime(1);
|
|
781
|
+
expect(ws.closeCalls).toHaveLength(1);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it('closes the attaching socket when the call is hung up mid-handshake', async () => {
|
|
785
|
+
const provider = new RuntypeVoiceProvider(attachConfig());
|
|
786
|
+
provider.prewarm();
|
|
787
|
+
const ws = lastWs();
|
|
788
|
+
|
|
789
|
+
const started = provider.startListening();
|
|
790
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
791
|
+
await provider.stopListening();
|
|
792
|
+
await started;
|
|
793
|
+
|
|
794
|
+
expect(ws.closeCalls).toEqual([{ code: 1000, reason: 'prewarm released' }]);
|
|
795
|
+
expect(MockWebSocket.instances).toHaveLength(1);
|
|
796
|
+
expect(provider.isBargeInActive()).toBe(false);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it('disconnect closes an attached socket', async () => {
|
|
800
|
+
const provider = new RuntypeVoiceProvider(attachConfig());
|
|
801
|
+
provider.prewarm();
|
|
802
|
+
const ws = lastWs();
|
|
803
|
+
openAs(ws, 'runtype.attach');
|
|
804
|
+
await provider.disconnect();
|
|
805
|
+
expect(ws.closeCalls).toEqual([{ code: 1000, reason: 'prewarm released' }]);
|
|
806
|
+
});
|
|
807
|
+
});
|
|
538
808
|
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{a as m}from"./chunk-5EIIHQLQ.js";var g=16e3,y=24e3,k=4096,C=4,S=1380533830;function E(r){return r.byteLength>=44&&new DataView(r).getUint32(0,!1)===S?new Uint8Array(r,44):new Uint8Array(r)}function V(r){let t=r.replace(/\/+$/,"");return/^wss?:\/\//i.test(t)?t:/^https?:\/\//i.test(t)?t.replace(/^http/i,"ws"):`${typeof window<"u"&&window.location?.protocol==="https:"?"wss:":"ws:"}//${t}`}var d=class{constructor(t){this.config=t;this.type="runtype";this.ws=null;this.captureContext=null;this.levelCallbacks=[];this.mediaStream=null;this.sourceNode=null;this.processor=null;this.playback=null;this.callLive=!1;this.isSpeaking=!1;this.callGeneration=0;this.intentionalClose=!1;this.resultCallbacks=[];this.errorCallbacks=[];this.statusCallbacks=[];this.transcriptCallbacks=[];this.metricsCallbacks=[]}async connect(){}async startListening(){if(this.callLive)return;let t=this.config?.agentId,e=this.config?.clientToken,i=this.config?.host;if(!t)throw new Error("Runtype voice requires an agentId");if(!e)throw new Error("Runtype voice requires a clientToken");if(!i)throw new Error("Runtype voice requires a host (or widget apiUrl)");let s=++this.callGeneration;this.intentionalClose=!1,this.callLive=!0;try{let o=await navigator.mediaDevices.getUserMedia({audio:{sampleRate:g,channelCount:1,echoCancellation:!0}});if(s!==this.callGeneration){o.getTracks().forEach(a=>a.stop());return}this.mediaStream=o;let p=window.AudioContext||window.webkitAudioContext,u=new p({sampleRate:g});u.state==="suspended"&&await u.resume().catch(()=>{}),this.captureContext=u;let l=this.config?.createPlaybackEngine?await this.config.createPlaybackEngine():new m(y);if(s!==this.callGeneration){l.destroy(),o.getTracks().forEach(a=>a.stop()),u.close().catch(()=>{});return}this.playback=l,l.onFinished(()=>{s===this.callGeneration&&(this.isSpeaking=!1,this.ws&&this.ws.readyState===WebSocket.OPEN&&this.emitStatus("listening"))});let f=`${V(i)}/ws/agents/${encodeURIComponent(t)}/voice`,n=new WebSocket(f,["runtype.bearer",e]);n.binaryType="arraybuffer",this.ws=n,n.onopen=()=>{s===this.callGeneration&&(this.emitStatus("listening"),this.startCapture(u,o,n,s))},n.onmessage=a=>this.handleMessage(a,s),n.onerror=()=>{s===this.callGeneration&&(this.emitError(new Error("Voice connection failed")),this.emitStatus("error"),this.cleanup())},n.onclose=a=>{if(this.intentionalClose){this.intentionalClose=!1;return}if(s===this.callGeneration){if(a.code!==1e3){let c=a.code?` (code ${a.code})`:"";this.emitError(new Error(`Voice connection closed${c}`)),this.emitStatus("error")}else this.emitStatus("idle");this.cleanup()}}}catch(o){throw this.cleanup(),this.emitError(o),this.emitStatus("error"),o}}async stopListening(){this.cleanup(),this.emitStatus("idle")}async disconnect(){this.cleanup(),this.emitStatus("disconnected"),this.resultCallbacks=[],this.errorCallbacks=[],this.statusCallbacks=[],this.transcriptCallbacks=[],this.metricsCallbacks=[]}stopPlayback(){this.playback&&this.playback.flush(),this.isSpeaking=!1,this.ws&&this.ws.readyState===WebSocket.OPEN&&this.emitStatus("listening")}getInterruptionMode(){return"barge-in"}isBargeInActive(){return this.callLive}onLevel(t){this.levelCallbacks.push(t)}async deactivateBargeIn(){this.cleanup(),this.emitStatus("idle")}startCapture(t,e,i,s){let o=t.createMediaStreamSource(e);this.sourceNode=o;let p=t.createScriptProcessor(k,1,1);this.processor=p,p.onaudioprocess=u=>{if(s!==this.callGeneration||i.readyState!==WebSocket.OPEN)return;let l=u.inputBuffer.getChannelData(0),f=new Int16Array(l.length),n=0;for(let a=0;a<l.length;a++){let c=Math.max(-1,Math.min(1,l[a]));n+=c*c,f[a]=c<0?c*32768:c*32767}if(i.send(f.buffer),this.levelCallbacks.length>0){let a=Math.sqrt(n/l.length),c=Math.max(0,Math.min(1,a*C));for(let b of this.levelCallbacks)b(c)}},o.connect(p),p.connect(t.destination)}handleMessage(t,e){if(e!==this.callGeneration)return;if(t.data instanceof ArrayBuffer){this.handleAudioFrame(t.data,e);return}let i;try{i=JSON.parse(t.data)}catch{return}switch(i.type){case"transcript_interim":this.emitStatus("listening"),this.emitTranscript("user",i.text??"",!1);break;case"transcript_final":{let s=i.role==="assistant"?"assistant":"user";this.emitStatus(s==="user"?"processing":"speaking"),this.emitTranscript(s,i.text??"",!0);break}case"audio_end":this.playback?this.playback.markStreamEnd():(this.isSpeaking=!1,this.emitStatus("listening"));break;case"metrics":this.emitMetrics({llmMs:i.llm_ms,ttsMs:i.tts_ms,firstAudioMs:i.first_audio_ms,totalMs:i.total_ms});break;case"error":this.emitError(new Error(i.error||"Voice error")),this.emitStatus("error");break}}handleAudioFrame(t,e){if(e!==this.callGeneration||!this.playback)return;let i=E(t);i.length!==0&&(this.isSpeaking||(this.isSpeaking=!0,this.emitStatus("speaking")),this.playback.enqueue(i))}cleanup(){if(this.callGeneration+=1,this.callLive=!1,this.isSpeaking=!1,this.processor&&(this.processor.onaudioprocess=null,this.processor.disconnect(),this.processor=null),this.sourceNode&&(this.sourceNode.disconnect(),this.sourceNode=null),this.mediaStream&&(this.mediaStream.getTracks().forEach(t=>t.stop()),this.mediaStream=null),this.captureContext&&(this.captureContext.close().catch(()=>{}),this.captureContext=null),this.playback&&(this.playback.destroy(),this.playback=null),this.ws){this.intentionalClose=!0;try{this.ws.close(1e3,"client ended call")}catch{}this.ws=null}}onResult(t){this.resultCallbacks.push(t)}onError(t){this.errorCallbacks.push(t)}onStatusChange(t){this.statusCallbacks.push(t)}onTranscript(t){this.transcriptCallbacks.push(t)}onMetrics(t){this.metricsCallbacks.push(t)}emitStatus(t){this.statusCallbacks.forEach(e=>e(t))}emitError(t){this.errorCallbacks.forEach(e=>e(t))}emitTranscript(t,e,i){this.transcriptCallbacks.forEach(s=>s(t,e,i))}emitMetrics(t){this.metricsCallbacks.forEach(e=>e(t))}};var h=class{constructor(t={}){this.config=t;this.type="browser";this.recognition=null;this.resultCallbacks=[];this.errorCallbacks=[];this.statusCallbacks=[];this.isListening=!1;this.w=typeof window<"u"?window:void 0}async connect(){this.statusCallbacks.forEach(t=>t("connected"))}async startListening(){try{if(this.isListening)throw new Error("Already listening");if(!this.w)throw new Error("Window object not available");let t=this.w.SpeechRecognition||this.w.webkitSpeechRecognition;if(!t)throw new Error("Browser speech recognition not supported");this.recognition=new t,this.recognition.lang=this.config?.language||"en-US",this.recognition.continuous=this.config?.continuous||!1,this.recognition.interimResults=!0,this.recognition.onresult=e=>{let i=Array.from(e.results).map(o=>o[0]).map(o=>o.transcript).join(""),s=e.results[e.results.length-1].isFinal;this.resultCallbacks.forEach(o=>o({text:i,confidence:s?.8:.5,provider:"browser"})),s&&!this.config?.continuous&&this.stopListening()},this.recognition.onerror=e=>{this.errorCallbacks.forEach(i=>i(new Error(e.error))),this.statusCallbacks.forEach(i=>i("error"))},this.recognition.onstart=()=>{this.isListening=!0,this.statusCallbacks.forEach(e=>e("listening"))},this.recognition.onend=()=>{this.isListening=!1,this.statusCallbacks.forEach(e=>e("idle"))},this.recognition.start()}catch(t){throw this.errorCallbacks.forEach(e=>e(t)),this.statusCallbacks.forEach(e=>e("error")),t}}async stopListening(){this.recognition&&(this.recognition.stop(),this.recognition=null),this.isListening=!1,this.statusCallbacks.forEach(t=>t("idle"))}onResult(t){this.resultCallbacks.push(t)}onError(t){this.errorCallbacks.push(t)}onStatusChange(t){this.statusCallbacks.push(t)}async disconnect(){await this.stopListening(),this.statusCallbacks.forEach(t=>t("disconnected"))}static isSupported(){return"SpeechRecognition"in window||"webkitSpeechRecognition"in window}};function v(r){switch(r.type){case"runtype":if(!r.runtype)throw new Error("Runtype voice provider requires configuration");return new d(r.runtype);case"browser":if(!h.isSupported())throw new Error("Browser speech recognition not supported");return new h(r.browser||{});case"custom":{let t=r.custom;if(!t)throw new Error("Custom voice provider requires a `custom` provider instance or factory");let e=typeof t=="function"?t():t;if(!e||typeof e.startListening!="function")throw new Error("Custom voice provider `custom` must be a VoiceProvider (or a factory returning one)");return e}default:throw new Error(`Unknown voice provider type: ${r.type}`)}}function w(r){if(r?.type==="custom"&&r.custom)return v({type:"custom",custom:r.custom});if(r?.type==="runtype"&&r.runtype)return v({type:"runtype",runtype:r.runtype});if(h.isSupported())return v({type:"browser",browser:r?.browser||{language:"en-US"}});throw new Error("No supported voice providers available")}function P(r){try{return w(r),!0}catch{return!1}}export{h as BrowserVoiceProvider,d as RuntypeVoiceProvider,w as createBestAvailableVoiceProvider,v as createVoiceProvider,P as isVoiceSupported};
|