@webotme/react-native 0.1.1 → 0.1.3

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/src/voice.ts ADDED
@@ -0,0 +1,722 @@
1
+ /**
2
+ * One-function voice chat, the same way `chatWithUs` is one-function text chat.
3
+ *
4
+ * ```tsx
5
+ * const res = await voiceWithUs(); // tap the mic
6
+ * // user speaks -> device transcribes -> chatWithUs() -> reply spoken aloud
7
+ * ```
8
+ *
9
+ * Like the web widget, everything runs on the device: the phone's own speech
10
+ * recogniser transcribes the question and the phone's own synthesiser speaks the
11
+ * answer. Your server only ever sees plain text, exactly as it does today, so
12
+ * no server change and no API keys are required.
13
+ *
14
+ * Both native modules are optional peer dependencies and are resolved on first
15
+ * use, so an app that only sends text never loads them and never breaks.
16
+ */
17
+ import { useEffect, useState } from "react";
18
+ import {
19
+ chatWithUs,
20
+ type ChatResult,
21
+ } from "./chat";
22
+ import type { WebotMeMessage } from "./types";
23
+
24
+ /** Mirrors the web widget's voice states (minus wake-word, which is browser-only). */
25
+ export type VoiceState =
26
+ | "idle"
27
+ | "listening"
28
+ | "thinking"
29
+ | "speaking"
30
+ | "unsupported"
31
+ | "denied";
32
+
33
+ export type VoiceOptions = {
34
+ /** BCP-47 language for both recognition and speech. Default "en-US". */
35
+ lang?: string;
36
+ rate?: number;
37
+ pitch?: number;
38
+ speakAllReplies?: boolean;
39
+ silenceTimeout?: number;
40
+ onStateChange?: (state: VoiceState) => void;
41
+ onPartial?: (text: string) => void;
42
+ onUserQuery?: (text: string) => void;
43
+ onError?: (message: string) => void;
44
+ /** Wake words that enable the bot once recognised (e.g. ["hello botName"]). */
45
+ activateCommands?: string[];
46
+ /** Shuts down voice once recognised (e.g. ["off botName"]). */
47
+ shutdownCommands?: string[];
48
+ /** Name used to substitute {botName} in the activation/shutdown phrases. */
49
+ botName?: string;
50
+ onActivated?: (transcript: string) => void;
51
+ onDeactivated?: (transcript: string) => void;
52
+ };
53
+
54
+ /** What `voiceWithUs` hands back, mirroring `ChatResult`. */
55
+ export type VoiceResult = {
56
+ ok: boolean;
57
+ /** What the user actually said. */
58
+ transcript: string;
59
+ /** The bot's reply, or an empty string when it failed. */
60
+ reply: string;
61
+ userMessage: WebotMeMessage | null;
62
+ botMessage: WebotMeMessage | null;
63
+ /** False when the reply was not spoken (voice replies disabled, or TTS failed). */
64
+ spoken: boolean;
65
+ error?: string;
66
+ };
67
+
68
+ // ── Optional native modules, resolved on first use ──────────────────────────
69
+
70
+ type SpeechLike = {
71
+ speak: (text: string, options?: Record<string, unknown>) => void;
72
+ stop: () => Promise<void> | void;
73
+ isSpeakingAsync?: () => Promise<boolean>;
74
+ };
75
+
76
+ type SpeechEvent = { results?: Array<{ transcript?: string } | string> };
77
+
78
+ function normalizeTranscript(v: unknown): string {
79
+ return String(v ?? "")
80
+ .toLowerCase()
81
+ .replace(/[^a-z0-9\s]/g, " ")
82
+ .replace(/\s+/g, " ")
83
+ .trim();
84
+ }
85
+
86
+ type SpeechRecognitionLike = {
87
+ start: (options?: Record<string, unknown>) => Promise<void> | void;
88
+ stop: () => Promise<void> | void;
89
+ abort: () => Promise<void> | void;
90
+ requestPermissionsAsync?: () => Promise<{ granted?: boolean; status?: string }>;
91
+ isRecognitionAvailable?: () => Promise<boolean>;
92
+ addListener?: (
93
+ event: string,
94
+ listener: (event: any) => void,
95
+ ) => { remove: () => void };
96
+ };
97
+
98
+ let speechCache: SpeechLike | null | undefined;
99
+ let recognitionCache: SpeechRecognitionLike | null | undefined;
100
+
101
+ const REBUILD_HINT =
102
+ "Then rebuild the app — a JS reload is not enough, the native module has to be compiled in: npx expo run:android / run:ios, or eas build.";
103
+
104
+ const MISSING_STT = `Voice input is not available in this build. Install it with "npx expo install expo-speech-recognition". ${REBUILD_HINT}`;
105
+ const MISSING_TTS = `Voice replies are not available in this build. Install them with "npx expo install expo-speech". ${REBUILD_HINT}`;
106
+
107
+ /**
108
+ * Both packages call `requireNativeModule` at module scope, which throws when
109
+ * the native side is missing — for example after adding the package but before
110
+ * rebuilding, or inside a build made before the package existed. Asking the
111
+ * native registry first lets us report "unsupported" instead of throwing on
112
+ * every mount.
113
+ */
114
+ function hasNativeModule(name: string): boolean {
115
+ try {
116
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
117
+ const { NativeModules } = require("react-native");
118
+ if (NativeModules?.[name]) return true;
119
+ // TurboModules are not always mirrored into NativeModules, so fall back to
120
+ // a probe that returns null instead of throwing when the module is absent.
121
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
122
+ const { requireOptionalNativeModule } = require("expo");
123
+ return Boolean(requireOptionalNativeModule?.(name));
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ /** `expo-speech`, or null when the app has not installed it. */
130
+ function loadSpeech(): SpeechLike | null {
131
+ if (speechCache !== undefined) return speechCache;
132
+ if (!hasNativeModule("ExpoSpeech")) {
133
+ speechCache = null;
134
+ return speechCache;
135
+ }
136
+ try {
137
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
138
+ const mod = require("expo-speech");
139
+ const candidate = (mod?.Speech ?? mod?.default) as SpeechLike | undefined;
140
+ speechCache = typeof candidate?.speak === "function" ? candidate : null;
141
+ } catch {
142
+ speechCache = null;
143
+ }
144
+ return speechCache;
145
+ }
146
+
147
+ /** `expo-speech-recognition`, or null when the app has not installed it. */
148
+ function loadRecognition(): SpeechRecognitionLike | null {
149
+ if (recognitionCache !== undefined) return recognitionCache;
150
+ if (!hasNativeModule("ExpoSpeechRecognition")) {
151
+ recognitionCache = null;
152
+ return recognitionCache;
153
+ }
154
+ try {
155
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
156
+ const mod = require("expo-speech-recognition");
157
+ const candidate = (mod?.ExpoSpeechRecognitionModule ??
158
+ mod?.default ??
159
+ mod) as SpeechRecognitionLike | undefined;
160
+ recognitionCache =
161
+ candidate && typeof candidate.start === "function" ? candidate : null;
162
+ } catch {
163
+ recognitionCache = null;
164
+ }
165
+ return recognitionCache;
166
+ }
167
+
168
+ function readTranscript(event: SpeechEvent | undefined): string {
169
+ const first = event?.results?.[0];
170
+ if (typeof first === "string") return first.trim();
171
+ if (first && typeof first.transcript === "string") return first.transcript.trim();
172
+ return "";
173
+ }
174
+
175
+ // ── Engine ──────────────────────────────────────────────────────────────────
176
+
177
+ type Listener = () => void;
178
+
179
+ type VoiceStateShape = {
180
+ state: VoiceState;
181
+ supported: boolean;
182
+ transcript: string;
183
+ error: string | null;
184
+ voiceReplies: boolean;
185
+ };
186
+
187
+ class VoiceEngine {
188
+ private options: VoiceOptions = {};
189
+ private listeners = new Set<Listener>();
190
+ private subscriptions: Array<{ remove: () => void }> = [];
191
+ private state: VoiceStateShape;
192
+
193
+ private transcript = "";
194
+ private settleTimer: ReturnType<typeof setTimeout> | null = null;
195
+ private onFinal: ((text: string) => void) | null = null;
196
+ private session: Promise<VoiceResult> | null = null;
197
+ private resolveSession: ((result: VoiceResult) => void) | null = null;
198
+ private speaking = false;
199
+ private continuous = false;
200
+ private loopStopped = false;
201
+ private activated = true;
202
+ private commandsConfigured = false;
203
+
204
+ constructor() {
205
+ this.state = {
206
+ state: "idle",
207
+ supported: false,
208
+ transcript: "",
209
+ error: null,
210
+ voiceReplies: false,
211
+ };
212
+ }
213
+
214
+ private patch(next: Partial<VoiceStateShape> & { state?: VoiceState }) {
215
+ const previous = this.state.state;
216
+ this.state = { ...this.state, ...next };
217
+ if (next.state && next.state !== previous) {
218
+ this.options.onStateChange?.(next.state);
219
+ }
220
+ for (const listener of this.listeners) listener();
221
+ }
222
+
223
+ subscribe(listener: Listener) {
224
+ this.listeners.add(listener);
225
+ return () => {
226
+ this.listeners.delete(listener);
227
+ };
228
+ }
229
+
230
+ getState(): VoiceStateShape {
231
+ return this.state;
232
+ }
233
+
234
+ configure(options: VoiceOptions) {
235
+ this.options = { ...this.options, ...options };
236
+ if (typeof options.speakAllReplies === "boolean") {
237
+ this.patch({ voiceReplies: options.speakAllReplies });
238
+ }
239
+ if (Array.isArray(options.activateCommands) && options.activateCommands.length > 0) {
240
+ // Lock voice until an activation phrase is heard, but only the first time
241
+ // the commands are applied so re-configuring never re-locks a live bot.
242
+ if (!this.commandsConfigured) this.activated = false;
243
+ this.commandsConfigured = true;
244
+ } else if (options.activateCommands !== undefined) {
245
+ // Commands were explicitly cleared -> fall back to always-available voice.
246
+ this.commandsConfigured = false;
247
+ this.activated = true;
248
+ }
249
+ this.patch({ supported: this.isSupported() });
250
+ }
251
+
252
+ setVoiceReplies(enabled: boolean) {
253
+ this.patch({ voiceReplies: enabled });
254
+ if (!enabled) void this.stopSpeaking();
255
+ }
256
+
257
+ isSupported() {
258
+ try {
259
+ return loadRecognition() !== null && loadSpeech() !== null;
260
+ } catch {
261
+ return false;
262
+ }
263
+ }
264
+
265
+ private async ensureMicPermission(): Promise<boolean> {
266
+ const recognition = loadRecognition();
267
+ if (!recognition?.requestPermissionsAsync) return true;
268
+ try {
269
+ const result = await recognition.requestPermissionsAsync();
270
+ return result?.granted !== false;
271
+ } catch {
272
+ return true;
273
+ }
274
+ }
275
+
276
+ private attachListeners() {
277
+ if (this.subscriptions.length > 0) return;
278
+ const recognition = loadRecognition();
279
+ if (!recognition?.addListener) return;
280
+
281
+ const push = (event: any) => {
282
+ const text = readTranscript(event);
283
+ if (!text) return;
284
+ this.transcript = text;
285
+ this.patch({ transcript: text });
286
+ this.options.onPartial?.(text);
287
+ this.scheduleSettle();
288
+ };
289
+
290
+ const fail = (event: any) => {
291
+ const message: string =
292
+ event?.error?.message ?? event?.message ?? event?.error ?? "Voice input failed.";
293
+ this.failRecognition(message);
294
+ };
295
+
296
+ this.subscriptions = [
297
+ recognition.addListener("result", push),
298
+ recognition.addListener("end", () => {
299
+ // The recogniser finalises on its own after a short pause; settle
300
+ // whatever it heard so a dropped event can never hang the promise.
301
+ this.settle();
302
+ }),
303
+ recognition.addListener("error", fail),
304
+ ];
305
+ }
306
+
307
+ private scheduleSettle() {
308
+ if (this.settleTimer) clearTimeout(this.settleTimer);
309
+ this.settleTimer = setTimeout(
310
+ () => this.settle(),
311
+ this.options.silenceTimeout ?? 2200,
312
+ );
313
+ }
314
+
315
+ private settle() {
316
+ if (this.settleTimer) {
317
+ clearTimeout(this.settleTimer);
318
+ this.settleTimer = null;
319
+ }
320
+ const text = this.transcript.trim();
321
+ if (!text) return;
322
+ void loadRecognition()?.stop();
323
+ this.onFinal?.(text);
324
+ }
325
+
326
+ /** Listen once, then resolve with the transcript and the spoken reply. */
327
+ listen(options?: { continuous?: boolean }): Promise<VoiceResult> {
328
+ if (this.session) return this.session;
329
+
330
+ if (typeof options?.continuous === "boolean") {
331
+ this.continuous = options.continuous;
332
+ }
333
+
334
+ if (!this.isSupported()) {
335
+ const error = loadRecognition()
336
+ ? MISSING_TTS
337
+ : MISSING_STT;
338
+ this.patch({ state: "unsupported", supported: false, error });
339
+ this.options.onError?.(error);
340
+ return Promise.resolve(this.failure(error));
341
+ }
342
+
343
+ this.session = new Promise<VoiceResult>((resolve) => {
344
+ this.resolveSession = resolve;
345
+ this.onFinal = (text) => {
346
+ this.onFinal = null;
347
+ this.patch({ state: "thinking" });
348
+ this.askBot(text);
349
+ };
350
+ });
351
+
352
+ void this.beginListening();
353
+ return this.session;
354
+ }
355
+
356
+ private async beginListening() {
357
+ const recognition = loadRecognition();
358
+ if (!recognition) return;
359
+
360
+ await this.ensureMicPermission();
361
+ this.attachListeners();
362
+ this.transcript = "";
363
+ this.patch({ state: "listening", transcript: "", error: null });
364
+
365
+ try {
366
+ await recognition.start({
367
+ lang: this.options.lang ?? "en-US",
368
+ interimResults: true,
369
+ continuous: false,
370
+ // Keep audio on the phone where the OS supports it.
371
+ requiresOnDeviceRecognition: undefined,
372
+ });
373
+ } catch (err) {
374
+ const message =
375
+ err instanceof Error ? err.message : "Could not start voice input.";
376
+ // If continuous loop fails to restart, exit loop gracefully.
377
+ if (this.continuous) {
378
+ this.continuous = false;
379
+ this.loopStopped = true;
380
+ }
381
+ this.fail(message);
382
+ }
383
+ }
384
+
385
+ /** [a-z0-9] tokens that make up the bot's name (used for phrase matching). */
386
+ private static botNameTokens(botName: string): string[] {
387
+ return String(botName || "assistant")
388
+ .toLowerCase()
389
+ .replace(/[^a-z0-9\s]/g, " ")
390
+ .split(/\s+/)
391
+ .filter(Boolean);
392
+ }
393
+
394
+ /** Remove every mention of the bot's name from a lowercase phrase. */
395
+ private static stripName(text: string, botName: string): string {
396
+ const tokens = VoiceEngine.botNameTokens(botName);
397
+ if (tokens.length === 0) return text;
398
+ let clean = " " + (text || "").toLowerCase() + " ";
399
+ for (const token of tokens) {
400
+ if (!token) continue;
401
+ clean = clean.replace(
402
+ new RegExp(`(^|[^a-z0-9])${token}(?=$|[^a-z0-9])`, "g"),
403
+ "$1 ",
404
+ );
405
+ }
406
+ return clean.replace(/\s+/g, " ").trim();
407
+ }
408
+
409
+ /**
410
+ * True when the spoken phrase matches one of the commands. The bot name is
411
+ * matched both inside the phrase and without it, so "hello rover", "hello",
412
+ * and "hey rover" all activate when the command is "hello {botName}".
413
+ */
414
+ private static matchesAny(
415
+ normalized: string,
416
+ commands: string[] | undefined,
417
+ botName: string,
418
+ ): boolean {
419
+ if (!Array.isArray(commands) || commands.length === 0) return false;
420
+ const stripped = VoiceEngine.stripName(normalized, botName);
421
+ return commands.some((cmd) => {
422
+ const raw = cmd
423
+ .toLowerCase()
424
+ .replace(/{botname}/g, botName.toLowerCase())
425
+ .replace(/\s+/g, " ")
426
+ .trim();
427
+ if (!raw) return false;
428
+ if (normalized.includes(raw)) return true;
429
+ const rawNoName = VoiceEngine.stripName(raw, botName);
430
+ if (!rawNoName) return false;
431
+ return stripped.includes(rawNoName) || stripped === rawNoName;
432
+ });
433
+ }
434
+
435
+ private async askBot(transcript: string) {
436
+ const normalized = transcript.toLowerCase().replace(/[^a-z0-9\s]/gi, "").trim();
437
+ const botName = this.options.botName || "assistant";
438
+ const wantsActivation = VoiceEngine.matchesAny(normalized, this.options.activateCommands, botName);
439
+ const wantsShutdown = VoiceEngine.matchesAny(normalized, this.options.shutdownCommands, botName);
440
+
441
+ if (wantsShutdown) {
442
+ this.activated = false;
443
+ this.options.onDeactivated?.(transcript);
444
+ this.continuous = false;
445
+ this.loopStopped = true;
446
+ this.stop();
447
+ this.finishSession({
448
+ ok: true,
449
+ transcript,
450
+ reply: "",
451
+ userMessage: null,
452
+ botMessage: null,
453
+ spoken: false,
454
+ });
455
+ return;
456
+ }
457
+
458
+ if (this.options.activateCommands && this.options.activateCommands.length > 0) {
459
+ if (!this.activated) {
460
+ if (wantsActivation) {
461
+ this.activated = true;
462
+ this.options.onActivated?.(transcript);
463
+ } else {
464
+ // Ignore everything before activation; start loop again if continuous.
465
+ if (this.continuous && !this.loopStopped) {
466
+ this.continueLoop();
467
+ } else {
468
+ this.finishSession({
469
+ ok: true,
470
+ transcript,
471
+ reply: "",
472
+ userMessage: null,
473
+ botMessage: null,
474
+ spoken: false,
475
+ });
476
+ }
477
+ return;
478
+ }
479
+ }
480
+ }
481
+
482
+ // Send the phrase without the bare bot-name mention (like the web engine),
483
+ // so "hello rover" reaches the bot as just "hello".
484
+ const forwardText = VoiceEngine.stripName(transcript, botName) || transcript;
485
+
486
+ let result: ChatResult;
487
+ try {
488
+ result = await chatWithUs(forwardText);
489
+ } catch (err) {
490
+ // chatWithUs already reports its own failures, so this is a real crash.
491
+ this.fail(
492
+ err instanceof Error ? err.message : "Could not reach the bot.",
493
+ "denied",
494
+ );
495
+ return;
496
+ }
497
+ this.options.onUserQuery?.(transcript);
498
+
499
+ if (!result.ok) {
500
+ this.fail(result.error ?? "Could not reach the bot.");
501
+ return;
502
+ }
503
+
504
+ const spoken = await this.speakReply(result.reply);
505
+ if (this.continuous && !this.loopStopped) {
506
+ this.continueLoop();
507
+ } else {
508
+ this.finishSession({
509
+ ok: true,
510
+ transcript,
511
+ reply: result.reply,
512
+ userMessage: result.userMessage,
513
+ botMessage: result.botMessage,
514
+ spoken,
515
+ });
516
+ }
517
+ }
518
+
519
+ private continueLoop() {
520
+ this.loopStopped = false;
521
+ this.transcript = "";
522
+ this.patch({ transcript: "", error: null });
523
+ void this.beginListening();
524
+ }
525
+
526
+ /** Speak a reply and resolve when playback ends. */
527
+ private speakReply(text: string): Promise<boolean> {
528
+ const speech = loadSpeech();
529
+ const trimmed = text.trim();
530
+ if (!speech || !trimmed) return Promise.resolve(false);
531
+
532
+ return new Promise<boolean>((resolve) => {
533
+ this.speaking = true;
534
+ this.patch({ state: "speaking" });
535
+
536
+ let settled = false;
537
+ const finish = (ok: boolean) => {
538
+ if (settled) return;
539
+ settled = true;
540
+ this.speaking = false;
541
+ if (this.state.state === "speaking") this.patch({ state: "idle" });
542
+ resolve(ok);
543
+ };
544
+
545
+ // iOS never fires an end event for empty or whitespace-only text.
546
+ try {
547
+ speech.speak(trimmed, {
548
+ language: this.options.lang ?? "en-US",
549
+ rate: this.options.rate ?? 0.9,
550
+ pitch: this.options.pitch ?? 1,
551
+ onDone: () => finish(true),
552
+ onStopped: () => finish(false),
553
+ onError: () => finish(false),
554
+ });
555
+ } catch {
556
+ finish(false);
557
+ return;
558
+ }
559
+
560
+ // Safety net: never let a missing end event hang the promise.
561
+ setTimeout(() => finish(true), Math.max(8000, trimmed.length * 120));
562
+ });
563
+ }
564
+
565
+ /** Speak any text, e.g. to read a reply the user typed. */
566
+ speak(text: string): Promise<boolean> {
567
+ return this.speakReply(text);
568
+ }
569
+
570
+ async stopSpeaking() {
571
+ const speech = loadSpeech();
572
+ if (!speech) return;
573
+ this.speaking = false;
574
+ try {
575
+ await speech.stop();
576
+ } catch {
577
+ /* nothing playing */
578
+ }
579
+ if (this.state.state === "speaking") this.patch({ state: "idle" });
580
+ }
581
+
582
+ /** Stop listening and speaking; a pending `voiceWithUs()` promise resolves. */
583
+ stop() {
584
+ if (this.settleTimer) {
585
+ clearTimeout(this.settleTimer);
586
+ this.settleTimer = null;
587
+ }
588
+ const recognition = loadRecognition();
589
+ void recognition?.abort?.();
590
+ void recognition?.stop?.();
591
+ void this.stopSpeaking();
592
+ this.continuous = false;
593
+ this.loopStopped = true;
594
+ if (this.state.state === "listening" || this.state.state === "thinking" || this.state.state === "speaking") {
595
+ this.patch({ state: "idle" });
596
+ }
597
+ // Never leave a caller hanging on `await voiceWithUs()`.
598
+ if (this.resolveSession) {
599
+ this.finishSession({
600
+ ok: true,
601
+ transcript: this.transcript.trim(),
602
+ reply: "",
603
+ userMessage: null,
604
+ botMessage: null,
605
+ spoken: false,
606
+ });
607
+ }
608
+ }
609
+
610
+ /** A recogniser failure mid-session: report it and go back to idle. */
611
+ private failRecognition(message: string) {
612
+ this.patch({ state: "idle", error: message });
613
+ this.options.onError?.(message);
614
+ this.finishSession(this.failure(message, this.transcript.trim()));
615
+ }
616
+
617
+ /** Voice could not start at all (permission denied, module missing, no mic). */
618
+ private fail(message: string, state: VoiceState = "denied") {
619
+ this.patch({ state, error: message });
620
+ this.options.onError?.(message);
621
+ this.finishSession(this.failure(message, this.transcript.trim()));
622
+ }
623
+
624
+ private failure(error: string, transcript = ""): VoiceResult {
625
+ return {
626
+ ok: false,
627
+ transcript,
628
+ reply: "",
629
+ userMessage: null,
630
+ botMessage: null,
631
+ spoken: false,
632
+ error,
633
+ };
634
+ }
635
+
636
+ private finishSession(result: VoiceResult) {
637
+ const resolve = this.resolveSession;
638
+ this.resolveSession = null;
639
+ this.onFinal = null;
640
+ this.session = null;
641
+ this.loopStopped = true;
642
+ this.continuous = false;
643
+ if (this.state.state !== "unsupported") this.patch({ state: "idle" });
644
+ resolve?.(result);
645
+ }
646
+ }
647
+
648
+ const engine = new VoiceEngine();
649
+
650
+ /**
651
+ * Point the voice engine at a language and wire the callbacks. Optional — every
652
+ * setting has a sensible default, so a bare `voiceWithUs()` works too.
653
+ *
654
+ * ```tsx
655
+ * configureVoice({
656
+ * lang: "en-US",
657
+ * speakAllReplies: true,
658
+ * onPartial: (text) => setText(text),
659
+ * });
660
+ * ```
661
+ */
662
+ export function configureVoice(options: VoiceOptions) {
663
+ engine.configure(options);
664
+ }
665
+
666
+ /** Read every bot reply aloud, not only the ones that were spoken. */
667
+ export function setVoiceReplies(enabled: boolean) {
668
+ engine.setVoiceReplies(enabled);
669
+ }
670
+
671
+ /** Stop listening and stop talking. */
672
+ export function stopVoice() {
673
+ engine.stop();
674
+ }
675
+
676
+ /**
677
+ * Ask out loud and hear the answer.
678
+ *
679
+ * ```tsx
680
+ * const res = await voiceWithUs();
681
+ * if (res.ok) console.log(res.transcript, "->", res.reply);
682
+ * ```
683
+ */
684
+ export function voiceWithUs(options?: { continuous?: boolean }): Promise<VoiceResult> {
685
+ return engine.listen(options);
686
+ }
687
+
688
+ /** Say a line with the device synthesiser, e.g. to read a typed reply. */
689
+ export function speakWithUs(text: string): Promise<boolean> {
690
+ return engine.speak(text);
691
+ }
692
+
693
+ /** Read a reply aloud only when `configureVoice({ speakAllReplies: true })`. */
694
+ export function speakReplyIfEnabled(reply: string): Promise<boolean> {
695
+ return engine.getState().voiceReplies ? engine.speak(reply) : Promise.resolve(false);
696
+ }
697
+
698
+ /** True when both native modules are installed. */
699
+ export function isVoiceSupported(): boolean {
700
+ return engine.isSupported();
701
+ }
702
+
703
+ /** Live voice state for rendering, the same way `useChat` exposes chat state. */
704
+ export function useVoiceChat() {
705
+ const [state, setState] = useState<VoiceStateShape>(() => engine.getState());
706
+
707
+ useEffect(() => {
708
+ setState(engine.getState());
709
+ return engine.subscribe(() => setState(engine.getState()));
710
+ }, []);
711
+
712
+ return {
713
+ ...state,
714
+ listening: state.state === "listening",
715
+ thinking: state.state === "thinking",
716
+ speaking: state.state === "speaking",
717
+ start: (options?: { continuous?: boolean }) => voiceWithUs(options),
718
+ speak: speakWithUs,
719
+ stop: stopVoice,
720
+ setVoiceReplies,
721
+ };
722
+ }