@runtypelabs/persona 1.46.1 → 1.47.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/README.md +414 -17
- package/dist/index.cjs +30 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +225 -1
- package/dist/index.d.ts +225 -1
- package/dist/index.global.js +54 -54
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +30 -30
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/composer-builder.ts +5 -2
- package/src/index.ts +13 -0
- package/src/session.ts +374 -2
- package/src/types.ts +186 -0
- package/src/ui.ts +140 -19
- package/src/voice/browser-voice-provider.ts +119 -0
- package/src/voice/index.ts +16 -0
- package/src/voice/runtype-voice-provider.ts +401 -0
- package/src/voice/voice-factory.ts +56 -0
- package/src/voice/voice.test.ts +219 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// Runtype Voice Provider
|
|
2
|
+
// WebSocket implementation for Runtype's voice service
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
VoiceProvider,
|
|
6
|
+
VoiceResult,
|
|
7
|
+
VoiceStatus,
|
|
8
|
+
VoiceConfig,
|
|
9
|
+
} from "../types";
|
|
10
|
+
|
|
11
|
+
export class RuntypeVoiceProvider implements VoiceProvider {
|
|
12
|
+
type: "runtype" = "runtype";
|
|
13
|
+
private ws: WebSocket | null = null;
|
|
14
|
+
private audioContext: AudioContext | null = null;
|
|
15
|
+
private w: any = typeof window !== "undefined" ? window : undefined;
|
|
16
|
+
private mediaRecorder: MediaRecorder | null = null;
|
|
17
|
+
private resultCallbacks: ((result: VoiceResult) => void)[] = [];
|
|
18
|
+
private errorCallbacks: ((error: Error) => void)[] = [];
|
|
19
|
+
private statusCallbacks: ((status: VoiceStatus) => void)[] = [];
|
|
20
|
+
private processingStartCallbacks: (() => void)[] = [];
|
|
21
|
+
private audioChunks: Blob[] = [];
|
|
22
|
+
private isProcessing = false;
|
|
23
|
+
|
|
24
|
+
// Silence detection
|
|
25
|
+
private analyserNode: AnalyserNode | null = null;
|
|
26
|
+
private mediaStream: MediaStream | null = null;
|
|
27
|
+
private silenceCheckInterval: ReturnType<typeof setInterval> | null = null;
|
|
28
|
+
private silenceStart: number | null = null;
|
|
29
|
+
|
|
30
|
+
constructor(private config: VoiceConfig["runtype"]) {}
|
|
31
|
+
|
|
32
|
+
async connect() {
|
|
33
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
34
|
+
return; // Already connected
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
// Ensure we're running in a browser environment
|
|
38
|
+
if (!this.w) {
|
|
39
|
+
throw new Error("Window object not available");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Temporary workaround for TypeScript issues
|
|
43
|
+
const w: any = this.w;
|
|
44
|
+
if (!w || !w.location) {
|
|
45
|
+
throw new Error("Window object or location not available");
|
|
46
|
+
}
|
|
47
|
+
const protocol = w.location.protocol === "https:" ? "wss:" : "ws:";
|
|
48
|
+
const host = this.config?.host;
|
|
49
|
+
const agentId = this.config?.agentId;
|
|
50
|
+
const clientToken = this.config?.clientToken;
|
|
51
|
+
if (!agentId || !clientToken) {
|
|
52
|
+
throw new Error("agentId and clientToken are required");
|
|
53
|
+
}
|
|
54
|
+
if (!host) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"host must be provided in Runtype voice provider configuration",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const wsUrl = `${protocol}//${host}/ws/agents/${agentId}/voice?token=${clientToken}`;
|
|
60
|
+
|
|
61
|
+
this.ws = new WebSocket(wsUrl);
|
|
62
|
+
this.setupWebSocketHandlers();
|
|
63
|
+
|
|
64
|
+
// Wait for WebSocket to actually open before resolving
|
|
65
|
+
const safeUrl = `${protocol}//${host}/ws/agents/${agentId}/voice?token=...`;
|
|
66
|
+
const hint =
|
|
67
|
+
" Check: API running on port 8787? Valid client token? Agent voice enabled? Token allowedOrigins includes this page?";
|
|
68
|
+
|
|
69
|
+
await new Promise<void>((resolve, reject) => {
|
|
70
|
+
if (!this.ws) return reject(new Error("WebSocket not created"));
|
|
71
|
+
let rejected = false;
|
|
72
|
+
const doReject = (msg: string) => {
|
|
73
|
+
if (rejected) return;
|
|
74
|
+
rejected = true;
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
reject(new Error(msg));
|
|
77
|
+
};
|
|
78
|
+
const timeout = setTimeout(
|
|
79
|
+
() => doReject("WebSocket connection timed out." + hint),
|
|
80
|
+
10000
|
|
81
|
+
);
|
|
82
|
+
this.ws!.addEventListener(
|
|
83
|
+
"open",
|
|
84
|
+
() => {
|
|
85
|
+
if (!rejected) {
|
|
86
|
+
rejected = true;
|
|
87
|
+
clearTimeout(timeout);
|
|
88
|
+
resolve();
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
{ once: true }
|
|
92
|
+
);
|
|
93
|
+
this.ws!.addEventListener(
|
|
94
|
+
"error",
|
|
95
|
+
() => {
|
|
96
|
+
doReject(
|
|
97
|
+
"WebSocket connection failed to " + safeUrl + "." + hint
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
{ once: true }
|
|
101
|
+
);
|
|
102
|
+
this.ws!.addEventListener(
|
|
103
|
+
"close",
|
|
104
|
+
(evt) => {
|
|
105
|
+
if (!evt.wasClean && !rejected) {
|
|
106
|
+
const codeMsg =
|
|
107
|
+
evt.code !== 1006 ? ` (code ${evt.code})` : "";
|
|
108
|
+
doReject(
|
|
109
|
+
"WebSocket connection failed" + codeMsg + "." + hint
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
{ once: true }
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
} catch (error) {
|
|
117
|
+
this.ws = null;
|
|
118
|
+
this.errorCallbacks.forEach((cb) => cb(error as Error));
|
|
119
|
+
this.statusCallbacks.forEach((cb) => cb("error"));
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private setupWebSocketHandlers() {
|
|
125
|
+
if (!this.ws) return;
|
|
126
|
+
|
|
127
|
+
this.ws.onopen = () => {
|
|
128
|
+
this.statusCallbacks.forEach((cb) => cb("connected"));
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
this.ws.onclose = () => {
|
|
132
|
+
this.statusCallbacks.forEach((cb) => cb("disconnected"));
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
this.ws.onerror = (_error) => {
|
|
136
|
+
this.errorCallbacks.forEach((cb) => cb(new Error("WebSocket error")));
|
|
137
|
+
this.statusCallbacks.forEach((cb) => cb("error"));
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
this.ws.onmessage = (event) => {
|
|
141
|
+
try {
|
|
142
|
+
const message = JSON.parse(event.data);
|
|
143
|
+
this.handleWebSocketMessage(message);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
this.errorCallbacks.forEach((cb) =>
|
|
146
|
+
cb(new Error("Message parsing failed")),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private handleWebSocketMessage(message: any) {
|
|
153
|
+
switch (message.type) {
|
|
154
|
+
case "voice_response":
|
|
155
|
+
// Play TTS audio if present
|
|
156
|
+
if (message.response.audio?.base64) {
|
|
157
|
+
this.playAudio(message.response.audio).catch((err) =>
|
|
158
|
+
this.errorCallbacks.forEach((cb) => cb(err instanceof Error ? err : new Error(String(err)))),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
// Use agentResponseText (the agent's reply) as the text result,
|
|
162
|
+
// falling back to transcript (user's STT input) for backwards compat
|
|
163
|
+
this.resultCallbacks.forEach((cb) =>
|
|
164
|
+
cb({
|
|
165
|
+
text: message.response.agentResponseText || message.response.transcript,
|
|
166
|
+
transcript: message.response.transcript,
|
|
167
|
+
audio: message.response.audio,
|
|
168
|
+
confidence: 0.95,
|
|
169
|
+
provider: "runtype",
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
this.isProcessing = false;
|
|
173
|
+
this.statusCallbacks.forEach((cb) => cb("idle"));
|
|
174
|
+
break;
|
|
175
|
+
|
|
176
|
+
case "error":
|
|
177
|
+
this.errorCallbacks.forEach((cb) => cb(new Error(message.error)));
|
|
178
|
+
this.statusCallbacks.forEach((cb) => cb("error"));
|
|
179
|
+
this.isProcessing = false;
|
|
180
|
+
break;
|
|
181
|
+
|
|
182
|
+
case "pong":
|
|
183
|
+
// Heartbeat response
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async startListening() {
|
|
189
|
+
try {
|
|
190
|
+
if (this.isProcessing) {
|
|
191
|
+
throw new Error("Already processing audio");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
195
|
+
this.mediaStream = stream;
|
|
196
|
+
const w = this.w!;
|
|
197
|
+
this.audioContext = new (w.AudioContext || w.webkitAudioContext)();
|
|
198
|
+
|
|
199
|
+
// Set up silence detection via AnalyserNode
|
|
200
|
+
const audioCtx = this.audioContext!;
|
|
201
|
+
const source = audioCtx.createMediaStreamSource(stream);
|
|
202
|
+
this.analyserNode = audioCtx.createAnalyser();
|
|
203
|
+
this.analyserNode.fftSize = 2048;
|
|
204
|
+
source.connect(this.analyserNode);
|
|
205
|
+
|
|
206
|
+
const pauseDuration = this.config?.pauseDuration ?? 2000;
|
|
207
|
+
const silenceThreshold = this.config?.silenceThreshold ?? 0.01;
|
|
208
|
+
this.silenceStart = null;
|
|
209
|
+
|
|
210
|
+
const dataArray = new Float32Array(this.analyserNode.fftSize);
|
|
211
|
+
this.silenceCheckInterval = setInterval(() => {
|
|
212
|
+
if (!this.analyserNode) return;
|
|
213
|
+
this.analyserNode.getFloatTimeDomainData(dataArray);
|
|
214
|
+
|
|
215
|
+
// Compute RMS volume
|
|
216
|
+
let sum = 0;
|
|
217
|
+
for (let i = 0; i < dataArray.length; i++) {
|
|
218
|
+
sum += dataArray[i] * dataArray[i];
|
|
219
|
+
}
|
|
220
|
+
const rms = Math.sqrt(sum / dataArray.length);
|
|
221
|
+
|
|
222
|
+
if (rms < silenceThreshold) {
|
|
223
|
+
if (this.silenceStart === null) {
|
|
224
|
+
this.silenceStart = Date.now();
|
|
225
|
+
} else if (Date.now() - this.silenceStart >= pauseDuration) {
|
|
226
|
+
// Silence exceeded threshold — auto-stop
|
|
227
|
+
this.stopListening();
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
// Sound detected — reset silence timer
|
|
231
|
+
this.silenceStart = null;
|
|
232
|
+
}
|
|
233
|
+
}, 100);
|
|
234
|
+
|
|
235
|
+
this.mediaRecorder = new MediaRecorder(stream);
|
|
236
|
+
this.audioChunks = [];
|
|
237
|
+
|
|
238
|
+
this.mediaRecorder.ondataavailable = (event) => {
|
|
239
|
+
if (event.data.size > 0) {
|
|
240
|
+
this.audioChunks.push(event.data);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
this.mediaRecorder.onstop = async () => {
|
|
245
|
+
if (this.audioChunks.length > 0) {
|
|
246
|
+
this.isProcessing = true;
|
|
247
|
+
this.statusCallbacks.forEach((cb) => cb("processing"));
|
|
248
|
+
this.processingStartCallbacks.forEach((cb) => cb());
|
|
249
|
+
|
|
250
|
+
const mimeType =
|
|
251
|
+
this.mediaRecorder?.mimeType || "audio/webm";
|
|
252
|
+
const audioBlob = new Blob(this.audioChunks, { type: mimeType });
|
|
253
|
+
await this.sendAudio(audioBlob);
|
|
254
|
+
this.audioChunks = [];
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
this.mediaRecorder.start(1000);
|
|
259
|
+
this.statusCallbacks.forEach((cb) => cb("listening"));
|
|
260
|
+
} catch (error) {
|
|
261
|
+
this.errorCallbacks.forEach((cb) => cb(error as Error));
|
|
262
|
+
this.statusCallbacks.forEach((cb) => cb("error"));
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async stopListening() {
|
|
268
|
+
// Clean up silence detection
|
|
269
|
+
if (this.silenceCheckInterval) {
|
|
270
|
+
clearInterval(this.silenceCheckInterval);
|
|
271
|
+
this.silenceCheckInterval = null;
|
|
272
|
+
}
|
|
273
|
+
this.analyserNode = null;
|
|
274
|
+
this.silenceStart = null;
|
|
275
|
+
|
|
276
|
+
if (this.mediaRecorder) {
|
|
277
|
+
this.mediaRecorder.stop();
|
|
278
|
+
this.mediaRecorder.stream.getTracks().forEach((track) => track.stop());
|
|
279
|
+
this.mediaRecorder = null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Clean up media stream reference
|
|
283
|
+
if (this.mediaStream) {
|
|
284
|
+
this.mediaStream.getTracks().forEach((track) => track.stop());
|
|
285
|
+
this.mediaStream = null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (this.audioContext) {
|
|
289
|
+
await this.audioContext.close();
|
|
290
|
+
this.audioContext = null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
this.statusCallbacks.forEach((cb) => cb("idle"));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private async sendAudio(audioBlob: Blob) {
|
|
297
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
298
|
+
this.errorCallbacks.forEach((cb) =>
|
|
299
|
+
cb(new Error("WebSocket not connected")),
|
|
300
|
+
);
|
|
301
|
+
this.statusCallbacks.forEach((cb) => cb("error"));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const base64Audio = await this.blobToBase64(audioBlob);
|
|
307
|
+
const format = this.getFormatFromMimeType(audioBlob.type);
|
|
308
|
+
|
|
309
|
+
this.ws.send(
|
|
310
|
+
JSON.stringify({
|
|
311
|
+
type: "audio_input",
|
|
312
|
+
audio: base64Audio,
|
|
313
|
+
format,
|
|
314
|
+
sampleRate: 16000,
|
|
315
|
+
voiceId: this.config?.voiceId,
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
this.errorCallbacks.forEach((cb) => cb(error as Error));
|
|
320
|
+
this.statusCallbacks.forEach((cb) => cb("error"));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private getFormatFromMimeType(mimeType: string): string {
|
|
325
|
+
if (mimeType.includes("wav")) return "wav";
|
|
326
|
+
if (mimeType.includes("mpeg") || mimeType.includes("mp3")) return "mp3";
|
|
327
|
+
return "webm";
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private blobToBase64(blob: Blob): Promise<string> {
|
|
331
|
+
return new Promise((resolve, reject) => {
|
|
332
|
+
const reader = new FileReader();
|
|
333
|
+
reader.onload = () => {
|
|
334
|
+
const result = reader.result as string;
|
|
335
|
+
// Remove data URL prefix
|
|
336
|
+
const base64 = result.split(",")[1];
|
|
337
|
+
resolve(base64);
|
|
338
|
+
};
|
|
339
|
+
reader.onerror = reject;
|
|
340
|
+
reader.readAsDataURL(blob);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Decode base64 audio and play it through the browser.
|
|
346
|
+
*/
|
|
347
|
+
private async playAudio(audio: { base64: string; format?: string }): Promise<void> {
|
|
348
|
+
if (!audio.base64) return;
|
|
349
|
+
const byteString = atob(audio.base64);
|
|
350
|
+
const bytes = new Uint8Array(byteString.length);
|
|
351
|
+
for (let i = 0; i < byteString.length; i++) {
|
|
352
|
+
bytes[i] = byteString.charCodeAt(i);
|
|
353
|
+
}
|
|
354
|
+
const format = audio.format || "mp3";
|
|
355
|
+
const mimeType =
|
|
356
|
+
format === "mp3" ? "audio/mpeg" : `audio/${format}`;
|
|
357
|
+
const blob = new Blob([bytes], { type: mimeType });
|
|
358
|
+
const url = URL.createObjectURL(blob);
|
|
359
|
+
const audioEl = new Audio(url);
|
|
360
|
+
audioEl.onended = () => URL.revokeObjectURL(url);
|
|
361
|
+
await audioEl.play();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
onResult(callback: (result: VoiceResult) => void): void {
|
|
365
|
+
this.resultCallbacks.push(callback);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
onError(callback: (error: Error) => void): void {
|
|
369
|
+
this.errorCallbacks.push(callback);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
onStatusChange(callback: (status: VoiceStatus) => void): void {
|
|
373
|
+
this.statusCallbacks.push(callback);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
onProcessingStart(callback: () => void): void {
|
|
377
|
+
this.processingStartCallbacks.push(callback);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async disconnect(): Promise<void> {
|
|
381
|
+
await this.stopListening();
|
|
382
|
+
|
|
383
|
+
if (this.ws) {
|
|
384
|
+
try {
|
|
385
|
+
this.ws.close();
|
|
386
|
+
} catch (error) {
|
|
387
|
+
// Ignore errors during disconnect
|
|
388
|
+
}
|
|
389
|
+
this.ws = null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
this.statusCallbacks.forEach((cb) => cb("disconnected"));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Heartbeat functionality
|
|
396
|
+
sendHeartbeat() {
|
|
397
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
398
|
+
this.ws.send(JSON.stringify({ type: "ping" }));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Voice Provider Factory
|
|
2
|
+
// Creates appropriate voice provider based on configuration
|
|
3
|
+
|
|
4
|
+
import type { VoiceProvider, VoiceConfig } from '../types';
|
|
5
|
+
import { RuntypeVoiceProvider } from './runtype-voice-provider';
|
|
6
|
+
import { BrowserVoiceProvider } from './browser-voice-provider';
|
|
7
|
+
|
|
8
|
+
export function createVoiceProvider(config: VoiceConfig): VoiceProvider {
|
|
9
|
+
switch (config.type) {
|
|
10
|
+
case 'runtype':
|
|
11
|
+
if (!config.runtype) {
|
|
12
|
+
throw new Error('Runtype voice provider requires configuration');
|
|
13
|
+
}
|
|
14
|
+
return new RuntypeVoiceProvider(config.runtype);
|
|
15
|
+
|
|
16
|
+
case 'browser':
|
|
17
|
+
if (!BrowserVoiceProvider.isSupported()) {
|
|
18
|
+
throw new Error('Browser speech recognition not supported');
|
|
19
|
+
}
|
|
20
|
+
return new BrowserVoiceProvider(config.browser || {});
|
|
21
|
+
|
|
22
|
+
case 'custom':
|
|
23
|
+
throw new Error('Custom voice providers not yet implemented');
|
|
24
|
+
|
|
25
|
+
default:
|
|
26
|
+
throw new Error(`Unknown voice provider type: ${config.type}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Auto-select the best available provider
|
|
31
|
+
export function createBestAvailableVoiceProvider(config?: Partial<VoiceConfig>): VoiceProvider {
|
|
32
|
+
// Prefer Runtype if configured
|
|
33
|
+
if (config?.type === 'runtype' && config.runtype) {
|
|
34
|
+
return createVoiceProvider({ type: 'runtype', runtype: config.runtype });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Fall back to browser if supported
|
|
38
|
+
if (BrowserVoiceProvider.isSupported()) {
|
|
39
|
+
return createVoiceProvider({
|
|
40
|
+
type: 'browser',
|
|
41
|
+
browser: config?.browser || { language: 'en-US' }
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
throw new Error('No supported voice providers available');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check if any voice provider is available
|
|
49
|
+
export function isVoiceSupported(config?: Partial<VoiceConfig>): boolean {
|
|
50
|
+
try {
|
|
51
|
+
createBestAvailableVoiceProvider(config);
|
|
52
|
+
return true;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Voice SDK Tests
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest';
|
|
3
|
+
import { VoiceProvider, VoiceResult, VoiceStatus, VoiceConfig } from './provider-interface';
|
|
4
|
+
import { RuntypeVoiceProvider } from './runtype-voice-provider';
|
|
5
|
+
import { BrowserVoiceProvider } from './browser-voice-provider';
|
|
6
|
+
import { createVoiceProvider, createBestAvailableVoiceProvider, isVoiceSupported } from './voice-factory';
|
|
7
|
+
|
|
8
|
+
// Mock window object for browser tests
|
|
9
|
+
const mockWindow = {
|
|
10
|
+
SpeechRecognition: undefined,
|
|
11
|
+
webkitSpeechRecognition: undefined
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
// @ts-ignore
|
|
16
|
+
global.window = mockWindow;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
// @ts-ignore
|
|
21
|
+
delete global.window;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function mockBrowserSupport(supported: boolean) {
|
|
25
|
+
if (supported) {
|
|
26
|
+
mockWindow.SpeechRecognition = class {};
|
|
27
|
+
mockWindow.webkitSpeechRecognition = class {};
|
|
28
|
+
} else {
|
|
29
|
+
delete mockWindow.SpeechRecognition;
|
|
30
|
+
delete mockWindow.webkitSpeechRecognition;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Note: TypeScript interfaces don't exist at runtime, so we can't test them directly
|
|
35
|
+
// We test the concrete implementations instead
|
|
36
|
+
|
|
37
|
+
describe('RuntypeVoiceProvider', () => {
|
|
38
|
+
it('should create instance with valid config', () => {
|
|
39
|
+
const config = {
|
|
40
|
+
agentId: 'test-agent',
|
|
41
|
+
clientToken: 'test-token',
|
|
42
|
+
host: 'localhost:8787',
|
|
43
|
+
voiceId: 'rachel'
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const provider = new RuntypeVoiceProvider(config);
|
|
47
|
+
expect(provider).toBeInstanceOf(RuntypeVoiceProvider);
|
|
48
|
+
expect(provider.type).toBe('runtype');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should have correct methods', () => {
|
|
52
|
+
const config = {
|
|
53
|
+
agentId: 'test-agent',
|
|
54
|
+
clientToken: 'test-token'
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const provider = new RuntypeVoiceProvider(config);
|
|
58
|
+
expect(typeof provider.connect).toBe('function');
|
|
59
|
+
expect(typeof provider.disconnect).toBe('function');
|
|
60
|
+
expect(typeof provider.startListening).toBe('function');
|
|
61
|
+
expect(typeof provider.stopListening).toBe('function');
|
|
62
|
+
expect(typeof provider.onResult).toBe('function');
|
|
63
|
+
expect(typeof provider.onError).toBe('function');
|
|
64
|
+
expect(typeof provider.onStatusChange).toBe('function');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('BrowserVoiceProvider', () => {
|
|
69
|
+
it('should create instance with default config', () => {
|
|
70
|
+
const provider = new BrowserVoiceProvider();
|
|
71
|
+
expect(provider).toBeInstanceOf(BrowserVoiceProvider);
|
|
72
|
+
expect(provider.type).toBe('browser');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should have correct methods', () => {
|
|
76
|
+
const provider = new BrowserVoiceProvider();
|
|
77
|
+
expect(typeof provider.connect).toBe('function');
|
|
78
|
+
expect(typeof provider.disconnect).toBe('function');
|
|
79
|
+
expect(typeof provider.startListening).toBe('function');
|
|
80
|
+
expect(typeof provider.stopListening).toBe('function');
|
|
81
|
+
expect(typeof provider.onResult).toBe('function');
|
|
82
|
+
expect(typeof provider.onError).toBe('function');
|
|
83
|
+
expect(typeof provider.onStatusChange).toBe('function');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should check browser support', () => {
|
|
87
|
+
// Test supported
|
|
88
|
+
mockBrowserSupport(true);
|
|
89
|
+
expect(BrowserVoiceProvider.isSupported()).toBe(true);
|
|
90
|
+
|
|
91
|
+
// Test unsupported
|
|
92
|
+
mockBrowserSupport(false);
|
|
93
|
+
expect(BrowserVoiceProvider.isSupported()).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('Voice Factory', () => {
|
|
98
|
+
it('should create Runtype provider', () => {
|
|
99
|
+
const config: VoiceConfig = {
|
|
100
|
+
type: 'runtype',
|
|
101
|
+
runtype: {
|
|
102
|
+
agentId: 'test-agent',
|
|
103
|
+
clientToken: 'test-token'
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const provider = createVoiceProvider(config);
|
|
108
|
+
expect(provider).toBeInstanceOf(RuntypeVoiceProvider);
|
|
109
|
+
expect(provider.type).toBe('runtype');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should create Browser provider when supported', () => {
|
|
113
|
+
// Mock browser support
|
|
114
|
+
mockBrowserSupport(true);
|
|
115
|
+
|
|
116
|
+
const config: VoiceConfig = {
|
|
117
|
+
type: 'browser',
|
|
118
|
+
browser: {
|
|
119
|
+
language: 'en-US'
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const provider = createVoiceProvider(config);
|
|
124
|
+
expect(provider).toBeInstanceOf(BrowserVoiceProvider);
|
|
125
|
+
expect(provider.type).toBe('browser');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should throw error for unsupported browser provider', () => {
|
|
129
|
+
// Mock no browser support
|
|
130
|
+
mockBrowserSupport(false);
|
|
131
|
+
|
|
132
|
+
const config: VoiceConfig = {
|
|
133
|
+
type: 'browser'
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
expect(() => createVoiceProvider(config)).toThrow('Browser speech recognition not supported');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should throw error for custom provider', () => {
|
|
140
|
+
const config: VoiceConfig = {
|
|
141
|
+
type: 'custom',
|
|
142
|
+
custom: {}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
expect(() => createVoiceProvider(config)).toThrow('Custom voice providers not yet implemented');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should throw error for unknown provider type', () => {
|
|
149
|
+
const config = {
|
|
150
|
+
type: 'unknown' as any
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
expect(() => createVoiceProvider(config)).toThrow('Unknown voice provider type: unknown');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('Best Available Voice Provider', () => {
|
|
158
|
+
it('should prefer Runtype when configured', () => {
|
|
159
|
+
// Mock no browser support
|
|
160
|
+
mockBrowserSupport(false);
|
|
161
|
+
|
|
162
|
+
const config = {
|
|
163
|
+
type: 'runtype',
|
|
164
|
+
runtype: {
|
|
165
|
+
agentId: 'test-agent',
|
|
166
|
+
clientToken: 'test-token'
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const provider = createBestAvailableVoiceProvider(config);
|
|
171
|
+
expect(provider).toBeInstanceOf(RuntypeVoiceProvider);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should fall back to browser when Runtype not configured', () => {
|
|
175
|
+
// Mock browser support
|
|
176
|
+
mockBrowserSupport(true);
|
|
177
|
+
|
|
178
|
+
const provider = createBestAvailableVoiceProvider();
|
|
179
|
+
expect(provider).toBeInstanceOf(BrowserVoiceProvider);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should throw error when no providers available', () => {
|
|
183
|
+
// Mock no browser support
|
|
184
|
+
mockBrowserSupport(false);
|
|
185
|
+
|
|
186
|
+
expect(() => createBestAvailableVoiceProvider()).toThrow('No supported voice providers available');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('Voice Support Check', () => {
|
|
191
|
+
it('should return true when voice is supported', () => {
|
|
192
|
+
// Mock browser support
|
|
193
|
+
mockBrowserSupport(true);
|
|
194
|
+
|
|
195
|
+
expect(isVoiceSupported()).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should return true when Runtype is configured', () => {
|
|
199
|
+
// Mock no browser support
|
|
200
|
+
mockBrowserSupport(false);
|
|
201
|
+
|
|
202
|
+
const config = {
|
|
203
|
+
type: 'runtype',
|
|
204
|
+
runtype: {
|
|
205
|
+
agentId: 'test-agent',
|
|
206
|
+
clientToken: 'test-token'
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
expect(isVoiceSupported(config)).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should return false when no voice support available', () => {
|
|
214
|
+
// Mock no browser support
|
|
215
|
+
mockBrowserSupport(false);
|
|
216
|
+
|
|
217
|
+
expect(isVoiceSupported()).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
});
|