@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.
@@ -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
+ });