@shvm/vani-client 0.0.2

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/index.js ADDED
@@ -0,0 +1,651 @@
1
+ import { useRef, useCallback, useEffect } from 'react';
2
+ import { useMicVAD } from '@ricky0123/vad-react';
3
+ import * as ort from 'onnxruntime-web';
4
+ import { useActor } from '@xstate/react';
5
+ import { fromCallback, setup, assign } from 'xstate';
6
+
7
+ // src/shared/constants/models.ts
8
+ var STT_MODELS = ["@cf/openai/whisper", "@cf/openai/whisper-tiny-en"];
9
+ var LLM_MODELS = [
10
+ "@cf/meta/llama-3.1-8b-instruct",
11
+ "@cf/meta/llama-3-8b-instruct",
12
+ "@cf/meta/llama-2-7b-chat-int8",
13
+ "@cf/mistral/mistral-7b-instruct-v0.1"
14
+ ];
15
+ var TTS_MODELS = ["@cf/deepgram/aura-2-en", "@cf/deepgram/aura-1"];
16
+ var TTS_MODEL_VOICES = {
17
+ "@cf/deepgram/aura-2-en": ["asteria", "luna", "arcas", "athena", "helios", "orpheus", "perseus", "angus"],
18
+ "@cf/deepgram/aura-1": [
19
+ "asteria",
20
+ "luna",
21
+ "stella",
22
+ "athena",
23
+ "hera",
24
+ "orion",
25
+ "arcas",
26
+ "perseus",
27
+ "angus",
28
+ "orpheus",
29
+ "helios",
30
+ "zeus"
31
+ ]
32
+ };
33
+
34
+ // src/headless/adapters/blobUrl.ts
35
+ function createBlobUrl(blob) {
36
+ try {
37
+ if (typeof URL === "undefined") return void 0;
38
+ if (typeof URL.createObjectURL !== "function") return void 0;
39
+ return URL.createObjectURL(blob);
40
+ } catch {
41
+ return void 0;
42
+ }
43
+ }
44
+ fromCallback(() => {
45
+ return () => {
46
+ };
47
+ });
48
+ fromCallback(() => {
49
+ return () => {
50
+ };
51
+ });
52
+ var clientMachine = setup({
53
+ types: {
54
+ context: {},
55
+ events: {}
56
+ },
57
+ actions: {
58
+ setStatusConfig: assign({
59
+ status: () => "connecting"
60
+ }),
61
+ setConnected: assign({
62
+ status: () => "idle",
63
+ history: ({ context }) => [
64
+ ...context.history,
65
+ {
66
+ id: Math.random().toString(36).slice(2),
67
+ type: "socket_event",
68
+ timestamp: Date.now(),
69
+ details: { status: "connected" }
70
+ }
71
+ ]
72
+ }),
73
+ setDisconnected: assign({
74
+ status: () => "disconnected",
75
+ history: ({ context }) => [
76
+ ...context.history,
77
+ {
78
+ id: Math.random().toString(36).slice(2),
79
+ type: "socket_event",
80
+ timestamp: Date.now(),
81
+ details: { status: "disconnected" }
82
+ }
83
+ ]
84
+ }),
85
+ setError: assign({
86
+ status: () => "error",
87
+ error: ({ event }) => event.type === "SET_ERROR" ? event.error : null,
88
+ history: ({ context, event }) => [
89
+ ...context.history,
90
+ {
91
+ id: Math.random().toString(36).slice(2),
92
+ type: "error",
93
+ timestamp: Date.now(),
94
+ details: { message: event.type === "SET_ERROR" ? event.error : "Unknown error" }
95
+ }
96
+ ]
97
+ }),
98
+ updateServerStatus: assign({
99
+ serverStatus: ({ context, event }) => event.type === "SERVER_STATE_CHANGE" ? event.status : context.serverStatus,
100
+ history: ({ context, event }) => {
101
+ if (event.type !== "SERVER_STATE_CHANGE") return context.history;
102
+ return [
103
+ ...context.history,
104
+ {
105
+ id: Math.random().toString(36).slice(2),
106
+ type: "state_change",
107
+ timestamp: Date.now(),
108
+ details: { from: context.serverStatus, to: event.status, source: "server" }
109
+ }
110
+ ];
111
+ }
112
+ }),
113
+ setPlaying: assign({
114
+ isPlaying: ({ event }) => event.type === "AUDIO_PLAYBACK_START"
115
+ }),
116
+ addMessage: assign({
117
+ transcript: ({ context, event }) => {
118
+ if (event.type !== "ADD_MESSAGE") return context.transcript;
119
+ return [
120
+ ...context.transcript,
121
+ {
122
+ id: Math.random().toString(36).slice(2),
123
+ role: event.role,
124
+ content: event.content,
125
+ timestamp: Date.now()
126
+ }
127
+ ];
128
+ },
129
+ history: ({ context, event }) => {
130
+ if (event.type !== "ADD_MESSAGE") return context.history;
131
+ return [
132
+ ...context.history,
133
+ {
134
+ id: Math.random().toString(36).slice(2),
135
+ type: "transcript",
136
+ timestamp: Date.now(),
137
+ details: { role: event.role, text: event.content }
138
+ }
139
+ ];
140
+ }
141
+ }),
142
+ logEvent: assign({
143
+ history: ({ context, event }) => {
144
+ if (event.type !== "LOG_EVENT") return context.history;
145
+ return [
146
+ ...context.history,
147
+ {
148
+ id: Math.random().toString(36).slice(2),
149
+ type: event.eventType,
150
+ timestamp: Date.now(),
151
+ details: event.details,
152
+ blobUrl: event.blob ? createBlobUrl(event.blob) : void 0
153
+ }
154
+ ];
155
+ }
156
+ }),
157
+ addToolCallStart: assign({
158
+ transcript: ({ context, event }) => {
159
+ if (event.type !== "TOOL_CALL_START") return context.transcript;
160
+ const newTranscript = [...context.transcript];
161
+ if (newTranscript.length === 0 || newTranscript[newTranscript.length - 1].role !== "assistant") {
162
+ newTranscript.push({
163
+ id: Math.random().toString(36).slice(2),
164
+ role: "assistant",
165
+ content: "",
166
+ timestamp: Date.now(),
167
+ toolCalls: []
168
+ });
169
+ }
170
+ const lastMsg = newTranscript[newTranscript.length - 1];
171
+ lastMsg.toolCalls = lastMsg.toolCalls || [];
172
+ lastMsg.toolCalls.push({ name: event.toolName, status: "calling" });
173
+ return newTranscript;
174
+ }
175
+ }),
176
+ addToolCallEnd: assign({
177
+ transcript: ({ context, event }) => {
178
+ if (event.type !== "TOOL_CALL_END") return context.transcript;
179
+ const newTranscript = [...context.transcript];
180
+ if (newTranscript.length > 0) {
181
+ const lastMsg = newTranscript[newTranscript.length - 1];
182
+ if (lastMsg.role === "assistant" && lastMsg.toolCalls) {
183
+ const activeTool = lastMsg.toolCalls.slice().reverse().find((t) => t.name === event.toolName && t.status === "calling");
184
+ if (activeTool) activeTool.status = "finished";
185
+ }
186
+ }
187
+ return newTranscript;
188
+ }
189
+ }),
190
+ clearError: assign({
191
+ error: null
192
+ })
193
+ },
194
+ guards: {
195
+ isServerThinkingOrSpeaking: ({ context, event }) => {
196
+ const status = event.type === "SERVER_STATE_CHANGE" ? event.status : context.serverStatus;
197
+ return status === "thinking" || status === "speaking";
198
+ }
199
+ }
200
+ }).createMachine({
201
+ id: "client",
202
+ initial: "disconnected",
203
+ context: {
204
+ status: "disconnected",
205
+ serverStatus: "idle",
206
+ transcript: [],
207
+ history: [],
208
+ error: null,
209
+ isPlaying: false
210
+ },
211
+ on: {
212
+ LOG_EVENT: { actions: "logEvent" },
213
+ ADD_MESSAGE: { actions: ["addMessage", "clearError"] },
214
+ TOOL_CALL_START: { actions: "addToolCallStart" },
215
+ TOOL_CALL_END: { actions: "addToolCallEnd" },
216
+ SET_ERROR: { target: ".error", actions: "setError" },
217
+ DISCONNECT: { target: ".disconnected", actions: "setDisconnected" }
218
+ },
219
+ states: {
220
+ disconnected: {
221
+ on: {
222
+ CONNECT: { target: "connecting", actions: "setStatusConfig" }
223
+ }
224
+ },
225
+ connecting: {
226
+ on: {
227
+ CONNECTED: { target: "connected", actions: "setConnected" }
228
+ }
229
+ },
230
+ connected: {
231
+ initial: "idle",
232
+ states: {
233
+ idle: {
234
+ entry: assign({ status: "idle" }),
235
+ on: {
236
+ START_LISTENING: { target: "#client.listening", actions: "clearError" },
237
+ AUDIO_PLAYBACK_START: { target: "#client.speaking", actions: "setPlaying" },
238
+ SERVER_STATE_CHANGE: [
239
+ {
240
+ guard: "isServerThinkingOrSpeaking",
241
+ target: "processing",
242
+ actions: "updateServerStatus"
243
+ },
244
+ {
245
+ actions: "updateServerStatus"
246
+ }
247
+ ]
248
+ }
249
+ },
250
+ processing: {
251
+ entry: assign({ status: "processing" }),
252
+ after: {
253
+ 2e4: { target: "idle", actions: assign({ error: "Server timed out. Interactions will reset." }) }
254
+ },
255
+ on: {
256
+ CANCEL: { target: "idle", actions: "clearError" },
257
+ START_LISTENING: { target: "#client.listening", actions: "clearError" },
258
+ AUDIO_PLAYBACK_START: { target: "#client.speaking", actions: "setPlaying" },
259
+ SERVER_STATE_CHANGE: [
260
+ {
261
+ guard: ({ event }) => event.status === "listening" || event.status === "idle",
262
+ target: "idle",
263
+ actions: "updateServerStatus"
264
+ },
265
+ {
266
+ actions: "updateServerStatus"
267
+ }
268
+ ]
269
+ }
270
+ }
271
+ }
272
+ },
273
+ listening: {
274
+ entry: assign({ status: "listening" }),
275
+ on: {
276
+ STOP_LISTENING: { target: "connected.processing" },
277
+ SERVER_STATE_CHANGE: { actions: "updateServerStatus" }
278
+ }
279
+ },
280
+ speaking: {
281
+ entry: assign({ status: "speaking" }),
282
+ on: {
283
+ AUDIO_PLAYBACK_END: {
284
+ target: "connected.idle",
285
+ actions: assign({ isPlaying: false })
286
+ },
287
+ SERVER_STATE_CHANGE: { actions: "updateServerStatus" }
288
+ }
289
+ },
290
+ error: {
291
+ on: {
292
+ CONNECT: { target: "connecting", actions: "setStatusConfig" },
293
+ START_LISTENING: { target: "listening", actions: "clearError" }
294
+ }
295
+ }
296
+ }
297
+ });
298
+
299
+ // src/headless/utils/webSocketUrl.ts
300
+ var DEFAULT_VOICE_SERVER_URL = "https://shvm.in";
301
+ function buildVoiceWebSocketUrl({
302
+ sessionId,
303
+ serverUrl,
304
+ wsPath,
305
+ getWebSocketUrlOverride
306
+ }) {
307
+ if (getWebSocketUrlOverride) return getWebSocketUrlOverride(sessionId);
308
+ const wsPathValue = wsPath ? wsPath(sessionId) : `/ws/${sessionId}`;
309
+ const base = new URL(serverUrl ?? DEFAULT_VOICE_SERVER_URL);
310
+ const protocol = base.protocol === "https:" ? "wss:" : base.protocol === "http:" ? "ws:" : base.protocol;
311
+ base.protocol = protocol;
312
+ return new URL(wsPathValue, base).toString();
313
+ }
314
+
315
+ // src/headless/hooks/useVoiceSession.ts
316
+ var ONNX_WASM_BASE_PATH = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/dist/";
317
+ var VAD_BASE_ASSET_PATH = "https://cdn.jsdelivr.net/npm/@ricky0123/vad-web@0.0.29/dist/";
318
+ var VAD_MODEL_URL = "https://cdn.jsdelivr.net/npm/@ricky0123/vad-web@0.0.29/dist/silero_vad_v5.onnx";
319
+ var ortConfigured = false;
320
+ function ensureOrtConfig() {
321
+ if (ortConfigured) return;
322
+ if (typeof window !== "undefined") {
323
+ ort.env.wasm.wasmPaths = ONNX_WASM_BASE_PATH;
324
+ ort.env.wasm.proxy = false;
325
+ }
326
+ ortConfigured = true;
327
+ }
328
+ var VAD_SAMPLE_RATE = 16e3;
329
+ function writeString(view, offset, text) {
330
+ for (let i = 0; i < text.length; i += 1) {
331
+ view.setUint8(offset + i, text.charCodeAt(i));
332
+ }
333
+ }
334
+ function encodeWav(audio, sampleRate) {
335
+ const buffer = new ArrayBuffer(44 + audio.length * 2);
336
+ const view = new DataView(buffer);
337
+ writeString(view, 0, "RIFF");
338
+ view.setUint32(4, 36 + audio.length * 2, true);
339
+ writeString(view, 8, "WAVE");
340
+ writeString(view, 12, "fmt ");
341
+ view.setUint32(16, 16, true);
342
+ view.setUint16(20, 1, true);
343
+ view.setUint16(22, 1, true);
344
+ view.setUint32(24, sampleRate, true);
345
+ view.setUint32(28, sampleRate * 2, true);
346
+ view.setUint16(32, 2, true);
347
+ view.setUint16(34, 16, true);
348
+ writeString(view, 36, "data");
349
+ view.setUint32(40, audio.length * 2, true);
350
+ let offset = 44;
351
+ for (let i = 0; i < audio.length; i += 1) {
352
+ const sample = Math.max(-1, Math.min(1, audio[i]));
353
+ view.setInt16(offset, sample < 0 ? sample * 32768 : sample * 32767, true);
354
+ offset += 2;
355
+ }
356
+ return buffer;
357
+ }
358
+ function useVoiceSession(props = {}) {
359
+ const {
360
+ onError,
361
+ onMessage,
362
+ onFeedback,
363
+ initialTranscript,
364
+ config,
365
+ serverUrl,
366
+ getWebSocketUrl: getWebSocketUrlOverride,
367
+ sessionId,
368
+ wsPath
369
+ } = props;
370
+ ensureOrtConfig();
371
+ const [snapshot, send, actorRef] = useActor(clientMachine);
372
+ const state = snapshot.context;
373
+ const wsRef = useRef(null);
374
+ const audioContextRef = useRef(null);
375
+ const audioQueueRef = useRef([]);
376
+ const isPlaybackLoopRunning = useRef(false);
377
+ const onErrorCallbackRef = useRef(onError);
378
+ const onMessageCallbackRef = useRef(onMessage);
379
+ const onFeedbackCallbackRef = useRef(onFeedback);
380
+ const configRef = useRef(config);
381
+ const turnActiveRef = useRef(false);
382
+ const lastVADErrorRef = useRef(null);
383
+ const hasSeededTranscriptRef = useRef(false);
384
+ const sessionIdRef = useRef(null);
385
+ if (sessionIdRef.current === null) {
386
+ sessionIdRef.current = sessionId ?? (typeof crypto !== "undefined" && "randomUUID" in crypto ? (
387
+ // @ts-ignore
388
+ crypto.randomUUID()
389
+ ) : "session-" + Math.floor(Math.random() * 1e4));
390
+ }
391
+ const buildWebSocketUrl = useCallback((activeSessionId) => {
392
+ return buildVoiceWebSocketUrl({
393
+ sessionId: activeSessionId,
394
+ serverUrl,
395
+ wsPath,
396
+ getWebSocketUrlOverride
397
+ });
398
+ }, [getWebSocketUrlOverride, serverUrl, wsPath]);
399
+ useEffect(() => {
400
+ onErrorCallbackRef.current = onError;
401
+ onMessageCallbackRef.current = onMessage;
402
+ onFeedbackCallbackRef.current = onFeedback;
403
+ configRef.current = config;
404
+ }, [onError, onMessage, onFeedback, config]);
405
+ useEffect(() => {
406
+ if (hasSeededTranscriptRef.current) return;
407
+ if (!initialTranscript || initialTranscript.length === 0) return;
408
+ if (actorRef.getSnapshot().context.transcript.length > 0) {
409
+ hasSeededTranscriptRef.current = true;
410
+ return;
411
+ }
412
+ initialTranscript.forEach((msg) => {
413
+ send({ type: "ADD_MESSAGE", role: msg.role, content: msg.content });
414
+ });
415
+ hasSeededTranscriptRef.current = true;
416
+ }, [actorRef, initialTranscript, send]);
417
+ const initAudio = async () => {
418
+ if (audioContextRef.current && audioContextRef.current.state !== "closed") {
419
+ if (audioContextRef.current.state === "suspended") {
420
+ await audioContextRef.current.resume();
421
+ }
422
+ return;
423
+ }
424
+ try {
425
+ audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 24e3 });
426
+ if (audioContextRef.current.state === "suspended") {
427
+ await audioContextRef.current.resume();
428
+ }
429
+ } catch (e) {
430
+ console.error("[Voice] Audio init error:", e);
431
+ const msg = "Audio initialization failed: " + e.message;
432
+ console.error("[Voice] Sending SET_ERROR (Audio Init)", msg);
433
+ send({ type: "SET_ERROR", error: msg });
434
+ throw e;
435
+ }
436
+ };
437
+ const handleSpeechStart = useCallback(() => {
438
+ const ws = wsRef.current;
439
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
440
+ if (turnActiveRef.current) return;
441
+ const currentContext = actorRef.getSnapshot().context;
442
+ const status = currentContext.status;
443
+ currentContext.error;
444
+ if (status === "speaking" || status === "processing") {
445
+ console.log("[Voice] Busy, rejecting speech input (strict turn-by-turn).");
446
+ return;
447
+ }
448
+ if (status !== "idle" && status !== "listening" && status !== "error") return;
449
+ turnActiveRef.current = true;
450
+ ws.send(JSON.stringify({
451
+ type: "start",
452
+ config: configRef.current
453
+ }));
454
+ send({ type: "START_LISTENING" });
455
+ }, [actorRef, send]);
456
+ const sendMessage = useCallback((text) => {
457
+ const ws = wsRef.current;
458
+ if (ws && ws.readyState === WebSocket.OPEN) {
459
+ ws.send(JSON.stringify({ type: "text.message", content: text }));
460
+ send({ type: "ADD_MESSAGE", role: "user", content: text });
461
+ send({ type: "SERVER_STATE_CHANGE", status: "speaking" });
462
+ }
463
+ }, [send]);
464
+ const handleSpeechEnd = useCallback(async (audio) => {
465
+ const ws = wsRef.current;
466
+ if (!turnActiveRef.current) return;
467
+ turnActiveRef.current = false;
468
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
469
+ send({ type: "SERVER_STATE_CHANGE", status: "idle" });
470
+ return;
471
+ }
472
+ const wavBuffer = encodeWav(audio, VAD_SAMPLE_RATE);
473
+ const wavBlob = new Blob([wavBuffer], { type: "audio/wav" });
474
+ ws.send(wavBuffer);
475
+ send({ type: "LOG_EVENT", eventType: "audio_input", details: { size: wavBuffer.byteLength }, blob: wavBlob });
476
+ ws.send(JSON.stringify({ type: "stop" }));
477
+ send({ type: "STOP_LISTENING" });
478
+ }, [send]);
479
+ const handleVADMisfire = useCallback(() => {
480
+ if (!turnActiveRef.current) return;
481
+ turnActiveRef.current = false;
482
+ send({ type: "SERVER_STATE_CHANGE", status: "idle" });
483
+ }, [send]);
484
+ const vad = useMicVAD({
485
+ startOnLoad: false,
486
+ onSpeechStart: handleSpeechStart,
487
+ onSpeechEnd: handleSpeechEnd,
488
+ onVADMisfire: handleVADMisfire,
489
+ // @ts-expect-error
490
+ workletURL: VAD_BASE_ASSET_PATH + "vad.worklet.bundle.min.js",
491
+ modelURL: VAD_MODEL_URL,
492
+ onnxWASMBasePath: ONNX_WASM_BASE_PATH,
493
+ baseAssetPath: VAD_BASE_ASSET_PATH
494
+ });
495
+ useEffect(() => {
496
+ if (!vad.errored) return;
497
+ const message = typeof vad.errored === "string" ? vad.errored : vad.errored.message || "VAD failed to load";
498
+ if (lastVADErrorRef.current === message) return;
499
+ lastVADErrorRef.current = message;
500
+ console.error("[Voice] Sending SET_ERROR (VAD)", message);
501
+ send({ type: "SET_ERROR", error: message });
502
+ }, [vad.errored, send]);
503
+ useEffect(() => {
504
+ const shouldListen = state.status === "idle" || state.status === "listening";
505
+ if (shouldListen && !vad.listening && !vad.loading && !vad.errored) {
506
+ vad.start();
507
+ } else if (!shouldListen && vad.listening) {
508
+ vad.pause();
509
+ }
510
+ }, [state.status, vad.listening, vad.loading, vad.errored, vad.start, vad.pause]);
511
+ const connect = useCallback(() => {
512
+ if (wsRef.current) wsRef.current.close();
513
+ initAudio().catch((err) => console.warn("[Voice] Early audio init failed", err));
514
+ console.log("[Voice] Connect called");
515
+ send({ type: "CONNECT" });
516
+ const sessionId2 = sessionIdRef.current || "session-" + Math.floor(Math.random() * 1e4);
517
+ const url = buildWebSocketUrl(sessionId2);
518
+ const ws = new WebSocket(url);
519
+ wsRef.current = ws;
520
+ ws.onopen = () => {
521
+ send({ type: "CONNECTED" });
522
+ initAudio().catch(() => {
523
+ });
524
+ };
525
+ ws.onmessage = async (event) => {
526
+ if (event.data instanceof Blob) {
527
+ const buf = await event.data.arrayBuffer();
528
+ send({ type: "LOG_EVENT", eventType: "audio_output", details: { size: buf.byteLength }, blob: event.data });
529
+ queueAudio(buf);
530
+ return;
531
+ }
532
+ try {
533
+ const data = JSON.parse(event.data);
534
+ handleMessage(data);
535
+ } catch (e) {
536
+ console.error("[Voice] Parse error", e);
537
+ }
538
+ };
539
+ ws.onclose = (e) => {
540
+ console.log("[Voice] Closed", e.code);
541
+ send({ type: "DISCONNECT" });
542
+ };
543
+ ws.onerror = (e) => {
544
+ console.error("[Voice] WS Error", e);
545
+ send({ type: "SET_ERROR", error: "Connection failed: " + (e instanceof ErrorEvent ? e.message : "Unknown") });
546
+ };
547
+ }, [buildWebSocketUrl, send]);
548
+ const handleMessage = (data) => {
549
+ switch (data.type) {
550
+ case "state":
551
+ send({ type: "SERVER_STATE_CHANGE", status: data.value });
552
+ break;
553
+ case "transcript.final":
554
+ send({ type: "ADD_MESSAGE", role: "user", content: data.text });
555
+ onMessageCallbackRef.current?.({ role: "user", content: data.text });
556
+ break;
557
+ case "assistant.message":
558
+ send({ type: "ADD_MESSAGE", role: "assistant", content: data.message.content });
559
+ onMessageCallbackRef.current?.({ role: "assistant", content: data.message.content });
560
+ break;
561
+ case "assistant.partial":
562
+ break;
563
+ case "error":
564
+ console.error("[Voice] Sending SET_ERROR (Server)", data.reason);
565
+ send({ type: "SET_ERROR", error: data.reason });
566
+ break;
567
+ case "feedback":
568
+ onFeedbackCallbackRef.current?.(data.message);
569
+ break;
570
+ case "tool.call.start":
571
+ send({ type: "TOOL_CALL_START", toolName: data.toolName });
572
+ break;
573
+ case "tool.call.end":
574
+ send({ type: "TOOL_CALL_END", toolName: data.toolName });
575
+ break;
576
+ }
577
+ };
578
+ const queueAudio = (buffer) => {
579
+ audioQueueRef.current.push(buffer);
580
+ if (!isPlaybackLoopRunning.current) {
581
+ playQueue();
582
+ }
583
+ };
584
+ const playQueue = async () => {
585
+ isPlaybackLoopRunning.current = true;
586
+ while (audioQueueRef.current.length > 0) {
587
+ send({ type: "AUDIO_PLAYBACK_START" });
588
+ const buffer = audioQueueRef.current.shift();
589
+ if (!buffer) continue;
590
+ try {
591
+ const ctx = audioContextRef.current;
592
+ const decoded = await ctx.decodeAudioData(buffer.slice(0));
593
+ await new Promise((resolve) => {
594
+ const source = ctx.createBufferSource();
595
+ source.buffer = decoded;
596
+ source.connect(ctx.destination);
597
+ source.onended = () => resolve();
598
+ source.start(0);
599
+ });
600
+ } catch (e) {
601
+ console.warn("[Voice] Decode failed, trying fallback", e);
602
+ try {
603
+ const blob = new Blob([buffer], { type: "audio/wav" });
604
+ const url = URL.createObjectURL(blob);
605
+ const audio = new Audio(url);
606
+ await new Promise((resolve, reject) => {
607
+ audio.onended = () => {
608
+ URL.revokeObjectURL(url);
609
+ resolve();
610
+ };
611
+ audio.onerror = reject;
612
+ audio.play().catch(reject);
613
+ });
614
+ } catch (err) {
615
+ console.error("[Voice] Playback failed completely", err);
616
+ }
617
+ }
618
+ }
619
+ send({ type: "AUDIO_PLAYBACK_END" });
620
+ isPlaybackLoopRunning.current = false;
621
+ };
622
+ const disconnect = useCallback(() => {
623
+ if (wsRef.current) {
624
+ wsRef.current.close();
625
+ wsRef.current = null;
626
+ }
627
+ send({ type: "DISCONNECT" });
628
+ }, [send]);
629
+ const cancel = useCallback(() => {
630
+ const ws = wsRef.current;
631
+ if (ws && ws.readyState === WebSocket.OPEN) {
632
+ ws.send(JSON.stringify({ type: "reset" }));
633
+ }
634
+ send({ type: "CANCEL" });
635
+ }, [send]);
636
+ return {
637
+ ...state,
638
+ vadListening: vad.listening,
639
+ vadLoading: vad.loading,
640
+ vadErrored: vad.errored,
641
+ userSpeaking: vad.userSpeaking,
642
+ connect,
643
+ disconnect,
644
+ sendMessage,
645
+ cancel
646
+ };
647
+ }
648
+
649
+ export { LLM_MODELS, STT_MODELS, TTS_MODELS, TTS_MODEL_VOICES, clientMachine, createBlobUrl, useVoiceSession };
650
+ //# sourceMappingURL=index.js.map
651
+ //# sourceMappingURL=index.js.map