react-hook-toolkit 3.0.1 → 3.0.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/dist/chunk1213/chunk158261.js +22 -22
- package/dist/chunk1415/chunk143.js +21 -25
- package/dist/chunk1516/chunk0021.d.ts +0 -1
- package/dist/chunk1516/chunk0021.js +263 -362
- package/dist/chunk1516/chunk0022.js +159 -277
- package/dist/chunk1516/chunk3312.d.ts +63 -0
- package/dist/chunk1516/chunk3312.js +462 -0
- package/dist/chunk1516/chunk726433.js +172 -206
- package/dist/chunk1516/chunk940514.js +284 -415
- package/dist/index.d.ts +2 -1
- package/dist/index.js +3 -2
- package/dist/utils.d.ts +0 -1
- package/dist/utils.js +42 -69
- package/package.json +1 -1
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
3
|
+
function getSpeechRecognitionCtor() {
|
|
4
|
+
if (typeof window === 'undefined')
|
|
5
|
+
return null;
|
|
6
|
+
const w = window;
|
|
7
|
+
return (w.SpeechRecognition ||
|
|
8
|
+
w.webkitSpeechRecognition ||
|
|
9
|
+
w.mozSpeechRecognition ||
|
|
10
|
+
w.msSpeechRecognition ||
|
|
11
|
+
null);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Word-boundary match — avoids "home" firing on "homework".
|
|
15
|
+
* Splits both transcript and keyword on whitespace and checks for an
|
|
16
|
+
* exact word (or multi-word phrase) match.
|
|
17
|
+
*/
|
|
18
|
+
export function matchVoiceCommand(transcript, commands) {
|
|
19
|
+
const words = transcript.toLowerCase().trim().split(/\s+/);
|
|
20
|
+
if (!words.length)
|
|
21
|
+
return null;
|
|
22
|
+
for (const cmd of commands) {
|
|
23
|
+
for (const kw of cmd.keywords) {
|
|
24
|
+
const kwWords = kw.toLowerCase().trim().split(/\s+/);
|
|
25
|
+
// Sliding-window check for multi-word keywords
|
|
26
|
+
for (let i = 0; i <= words.length - kwWords.length; i++) {
|
|
27
|
+
if (kwWords.every((w, j) => words[i + j] === w))
|
|
28
|
+
return cmd;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Returns the command whose keywords share the most words with the transcript.
|
|
36
|
+
* Used for "did you mean?" hints on no-match.
|
|
37
|
+
*/
|
|
38
|
+
export function closestVoiceCommand(transcript, commands) {
|
|
39
|
+
if (!commands.length)
|
|
40
|
+
return null;
|
|
41
|
+
const words = new Set(transcript.toLowerCase().trim().split(/\s+/));
|
|
42
|
+
let best = null;
|
|
43
|
+
let bestScore = 0;
|
|
44
|
+
for (const cmd of commands) {
|
|
45
|
+
for (const kw of cmd.keywords) {
|
|
46
|
+
const overlap = kw
|
|
47
|
+
.toLowerCase()
|
|
48
|
+
.split(/\s+/)
|
|
49
|
+
.filter((w) => words.has(w)).length;
|
|
50
|
+
if (overlap > bestScore) {
|
|
51
|
+
bestScore = overlap;
|
|
52
|
+
best = cmd;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return bestScore > 0 ? best : null;
|
|
57
|
+
}
|
|
58
|
+
/** Detects iOS Safari where continuous mode silently breaks. */
|
|
59
|
+
function detectContinuousSupport() {
|
|
60
|
+
if (typeof window === 'undefined')
|
|
61
|
+
return false;
|
|
62
|
+
const ua = navigator.userAgent;
|
|
63
|
+
const isIOS = /iPad|iPhone|iPod/.test(ua) && !window.MSStream;
|
|
64
|
+
const isSafari = /^((?!chrome|android).)*safari/i.test(ua);
|
|
65
|
+
return !(isIOS && isSafari);
|
|
66
|
+
}
|
|
67
|
+
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
|
68
|
+
export function useVoice(options = {}) {
|
|
69
|
+
const { commands = [], language = 'en-US', continuous = false, interimResults = true, autoNavigate = true, historySize = 10, minConfidence = 0.4, idleTimeoutMs = 0, wakeWord, shortcut, navigate: navigateProp, onMatch, onNoMatch, onError, onStart, onEnd, onToast, } = options;
|
|
70
|
+
// ── Refs ────────────────────────────────────────────────────────────────────
|
|
71
|
+
const recognitionRef = useRef(null);
|
|
72
|
+
const audioContextRef = useRef(null);
|
|
73
|
+
const analyserRef = useRef(null);
|
|
74
|
+
const streamRef = useRef(null);
|
|
75
|
+
const animFrameRef = useRef(0);
|
|
76
|
+
const idleTimerRef = useRef(null);
|
|
77
|
+
const mountedRef = useRef(true);
|
|
78
|
+
const startingRef = useRef(false); // guard for concurrent start() calls
|
|
79
|
+
// Stable refs for values that must not be in effect deps
|
|
80
|
+
const commandsRef = useRef(commands);
|
|
81
|
+
const navigateRef = useRef(navigateProp);
|
|
82
|
+
const executeRef = useRef(null);
|
|
83
|
+
const callbacksRef = useRef({ onMatch, onNoMatch, onError, onStart, onEnd, onToast });
|
|
84
|
+
// Keep refs in sync every render (no extra re-renders, no stale closures)
|
|
85
|
+
useEffect(() => { commandsRef.current = commands; });
|
|
86
|
+
useEffect(() => { navigateRef.current = navigateProp; });
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
callbacksRef.current = { onMatch, onNoMatch, onError, onStart, onEnd, onToast };
|
|
89
|
+
});
|
|
90
|
+
// ── State ───────────────────────────────────────────────────────────────────
|
|
91
|
+
const [status, setStatus] = useState('idle');
|
|
92
|
+
const [transcript, setTranscript] = useState('');
|
|
93
|
+
const [audioLevel, setAudioLevel] = useState(0);
|
|
94
|
+
const [history, setHistory] = useState([]);
|
|
95
|
+
const [lastMatch, setLastMatch] = useState(null);
|
|
96
|
+
const [pendingConfirm, setPendingConfirm] = useState(null);
|
|
97
|
+
const isSupported = useMemo(() => !!getSpeechRecognitionCtor(), []);
|
|
98
|
+
const isContinuousSupported = useMemo(() => detectContinuousSupport(), []);
|
|
99
|
+
// ── Idle timer ──────────────────────────────────────────────────────────────
|
|
100
|
+
const clearIdleTimer = useCallback(() => {
|
|
101
|
+
if (idleTimerRef.current !== null) {
|
|
102
|
+
clearTimeout(idleTimerRef.current);
|
|
103
|
+
idleTimerRef.current = null;
|
|
104
|
+
}
|
|
105
|
+
}, []);
|
|
106
|
+
const resetIdleTimer = useCallback(() => {
|
|
107
|
+
clearIdleTimer();
|
|
108
|
+
if (idleTimeoutMs > 0) {
|
|
109
|
+
idleTimerRef.current = setTimeout(() => {
|
|
110
|
+
var _a;
|
|
111
|
+
if (!mountedRef.current)
|
|
112
|
+
return;
|
|
113
|
+
try {
|
|
114
|
+
(_a = recognitionRef.current) === null || _a === void 0 ? void 0 : _a.stop();
|
|
115
|
+
}
|
|
116
|
+
catch ( /* ignore */_b) { /* ignore */ }
|
|
117
|
+
}, idleTimeoutMs);
|
|
118
|
+
}
|
|
119
|
+
}, [idleTimeoutMs, clearIdleTimer]);
|
|
120
|
+
// ── Audio analysis ──────────────────────────────────────────────────────────
|
|
121
|
+
const stopAudioAnalysis = useCallback(() => {
|
|
122
|
+
var _a;
|
|
123
|
+
clearIdleTimer();
|
|
124
|
+
cancelAnimationFrame(animFrameRef.current);
|
|
125
|
+
if (mountedRef.current)
|
|
126
|
+
setAudioLevel(0);
|
|
127
|
+
(_a = streamRef.current) === null || _a === void 0 ? void 0 : _a.getTracks().forEach((t) => t.stop());
|
|
128
|
+
streamRef.current = null;
|
|
129
|
+
// Keep a local ref before nulling so the async close doesn't re-read a
|
|
130
|
+
// new context that was created after cleanup.
|
|
131
|
+
const ctx = audioContextRef.current;
|
|
132
|
+
audioContextRef.current = null;
|
|
133
|
+
analyserRef.current = null;
|
|
134
|
+
ctx === null || ctx === void 0 ? void 0 : ctx.close().catch(() => { });
|
|
135
|
+
}, [clearIdleTimer]);
|
|
136
|
+
const startAudioAnalysis = useCallback(async () => {
|
|
137
|
+
try {
|
|
138
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
139
|
+
if (!mountedRef.current) {
|
|
140
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
streamRef.current = stream;
|
|
144
|
+
const ctx = new AudioContext();
|
|
145
|
+
audioContextRef.current = ctx;
|
|
146
|
+
const analyser = ctx.createAnalyser();
|
|
147
|
+
analyser.fftSize = 256;
|
|
148
|
+
analyserRef.current = analyser;
|
|
149
|
+
ctx.createMediaStreamSource(stream).connect(analyser);
|
|
150
|
+
const data = new Uint8Array(analyser.frequencyBinCount);
|
|
151
|
+
const tick = () => {
|
|
152
|
+
if (!analyserRef.current || !mountedRef.current)
|
|
153
|
+
return;
|
|
154
|
+
analyserRef.current.getByteFrequencyData(data);
|
|
155
|
+
let sum = 0;
|
|
156
|
+
for (let i = 0; i < data.length; i++)
|
|
157
|
+
sum += data[i];
|
|
158
|
+
setAudioLevel(Math.min(100, (sum / data.length) * 2));
|
|
159
|
+
animFrameRef.current = requestAnimationFrame(tick);
|
|
160
|
+
};
|
|
161
|
+
tick();
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
catch (_a) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}, []);
|
|
168
|
+
// ── Execute command ─────────────────────────────────────────────────────────
|
|
169
|
+
const executeCommand = useCallback((text) => {
|
|
170
|
+
var _a, _b, _c, _d;
|
|
171
|
+
const trimmed = text.trim();
|
|
172
|
+
if (!trimmed)
|
|
173
|
+
return;
|
|
174
|
+
const cb = callbacksRef.current;
|
|
175
|
+
// Wake-word gate
|
|
176
|
+
if (wakeWord) {
|
|
177
|
+
const ww = wakeWord.toLowerCase();
|
|
178
|
+
if (!trimmed.toLowerCase().includes(ww))
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const matched = matchVoiceCommand(trimmed, commandsRef.current);
|
|
182
|
+
const time = new Date().toLocaleTimeString();
|
|
183
|
+
setHistory((prev) => [
|
|
184
|
+
{ text: trimmed, matched: !!matched, label: matched === null || matched === void 0 ? void 0 : matched.label, time },
|
|
185
|
+
...prev.slice(0, Math.max(0, historySize - 1)),
|
|
186
|
+
]);
|
|
187
|
+
if (matched) {
|
|
188
|
+
setLastMatch(matched);
|
|
189
|
+
(_a = cb.onToast) === null || _a === void 0 ? void 0 : _a.call(cb, 'success', `Matched: ${matched.label}`);
|
|
190
|
+
(_b = cb.onMatch) === null || _b === void 0 ? void 0 : _b.call(cb, matched, trimmed);
|
|
191
|
+
if (matched.requiresConfirmation) {
|
|
192
|
+
setPendingConfirm(matched);
|
|
193
|
+
setStatus('confirming');
|
|
194
|
+
return; // wait for a follow-up "yes" / "confirm"
|
|
195
|
+
}
|
|
196
|
+
// Fire the command
|
|
197
|
+
if (matched.action) {
|
|
198
|
+
matched.action();
|
|
199
|
+
}
|
|
200
|
+
else if (autoNavigate && matched.path && navigateRef.current) {
|
|
201
|
+
navigateRef.current(matched.path);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
const closest = closestVoiceCommand(trimmed, commandsRef.current);
|
|
206
|
+
(_c = cb.onToast) === null || _c === void 0 ? void 0 : _c.call(cb, 'warn', closest ? `Did you mean: ${closest.label}?` : 'Command not recognized');
|
|
207
|
+
(_d = cb.onNoMatch) === null || _d === void 0 ? void 0 : _d.call(cb, trimmed, closest);
|
|
208
|
+
}
|
|
209
|
+
if (mountedRef.current)
|
|
210
|
+
setStatus('idle');
|
|
211
|
+
}, [autoNavigate, historySize, wakeWord]);
|
|
212
|
+
// Keep a stable ref so the recognition effect can call it without being
|
|
213
|
+
// in its own dep array (fixes the stale-closure / rebuild-on-every-render bug).
|
|
214
|
+
useEffect(() => { executeRef.current = executeCommand; }, [executeCommand]);
|
|
215
|
+
// ── Confirmation handler ────────────────────────────────────────────────────
|
|
216
|
+
const resolveConfirm = useCallback((text) => {
|
|
217
|
+
var _a, _b, _c, _d;
|
|
218
|
+
if (!pendingConfirm)
|
|
219
|
+
return;
|
|
220
|
+
const yes = /\b(yes|confirm|ok|okay|sure|do it)\b/i.test(text);
|
|
221
|
+
if (yes) {
|
|
222
|
+
if (pendingConfirm.action) {
|
|
223
|
+
pendingConfirm.action();
|
|
224
|
+
}
|
|
225
|
+
else if (autoNavigate && pendingConfirm.path && navigateRef.current) {
|
|
226
|
+
navigateRef.current(pendingConfirm.path);
|
|
227
|
+
}
|
|
228
|
+
(_b = (_a = callbacksRef.current).onToast) === null || _b === void 0 ? void 0 : _b.call(_a, 'success', `Confirmed: ${pendingConfirm.label}`);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
(_d = (_c = callbacksRef.current).onToast) === null || _d === void 0 ? void 0 : _d.call(_c, 'warn', 'Cancelled');
|
|
232
|
+
}
|
|
233
|
+
setPendingConfirm(null);
|
|
234
|
+
setStatus('idle');
|
|
235
|
+
}, [pendingConfirm, autoNavigate]);
|
|
236
|
+
// ── SpeechRecognition setup ─────────────────────────────────────────────────
|
|
237
|
+
// executeCommand is intentionally NOT in this dep array.
|
|
238
|
+
// We call it via executeRef.current to avoid rebuilding recognition on every render.
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
const Ctor = getSpeechRecognitionCtor();
|
|
241
|
+
if (!Ctor) {
|
|
242
|
+
setStatus('unsupported');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const recognition = new Ctor();
|
|
246
|
+
recognition.continuous = continuous && isContinuousSupported;
|
|
247
|
+
recognition.interimResults = interimResults;
|
|
248
|
+
recognition.lang = language;
|
|
249
|
+
recognition.onstart = () => {
|
|
250
|
+
var _a, _b;
|
|
251
|
+
if (!mountedRef.current)
|
|
252
|
+
return;
|
|
253
|
+
setStatus('listening');
|
|
254
|
+
(_b = (_a = callbacksRef.current).onStart) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
255
|
+
resetIdleTimer();
|
|
256
|
+
};
|
|
257
|
+
recognition.onresult = (event) => {
|
|
258
|
+
var _a;
|
|
259
|
+
if (!mountedRef.current)
|
|
260
|
+
return;
|
|
261
|
+
let interim = '';
|
|
262
|
+
let finalText = '';
|
|
263
|
+
let highestConfidence = 0;
|
|
264
|
+
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
265
|
+
const result = event.results[i];
|
|
266
|
+
const txt = result[0].transcript;
|
|
267
|
+
// Chrome often returns confidence=0 even for correct final results;
|
|
268
|
+
// treat 0 (and missing) as "unknown" so we don't drop every redirect.
|
|
269
|
+
const conf = result[0].confidence || 1;
|
|
270
|
+
if (result.isFinal) {
|
|
271
|
+
if (conf >= minConfidence)
|
|
272
|
+
finalText += txt;
|
|
273
|
+
highestConfidence = Math.max(highestConfidence, conf);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
interim += txt;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
setTranscript(finalText || interim);
|
|
280
|
+
if (finalText) {
|
|
281
|
+
setStatus('processing');
|
|
282
|
+
stopAudioAnalysis();
|
|
283
|
+
clearIdleTimer();
|
|
284
|
+
// Resolve a pending confirmation or run normal command matching
|
|
285
|
+
if (pendingConfirm) {
|
|
286
|
+
resolveConfirm(finalText);
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
(_a = executeRef.current) === null || _a === void 0 ? void 0 : _a.call(executeRef, finalText);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
// Interim speech resets the idle timer
|
|
294
|
+
resetIdleTimer();
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
recognition.onerror = (event) => {
|
|
298
|
+
var _a, _b, _c, _d;
|
|
299
|
+
if (!mountedRef.current)
|
|
300
|
+
return;
|
|
301
|
+
// These are non-fatal — ignore silently
|
|
302
|
+
if (event.error === 'aborted' || event.error === 'no-speech')
|
|
303
|
+
return;
|
|
304
|
+
setStatus('error');
|
|
305
|
+
const msg = event.error === 'not-allowed'
|
|
306
|
+
? 'Microphone permission denied'
|
|
307
|
+
: `Speech error: ${event.error}`;
|
|
308
|
+
(_b = (_a = callbacksRef.current).onToast) === null || _b === void 0 ? void 0 : _b.call(_a, 'error', msg);
|
|
309
|
+
(_d = (_c = callbacksRef.current).onError) === null || _d === void 0 ? void 0 : _d.call(_c, event.error);
|
|
310
|
+
stopAudioAnalysis();
|
|
311
|
+
// Auto-recover to idle after 2s so UI isn't stuck on error
|
|
312
|
+
setTimeout(() => {
|
|
313
|
+
if (mountedRef.current)
|
|
314
|
+
setStatus('idle');
|
|
315
|
+
}, 2000);
|
|
316
|
+
};
|
|
317
|
+
recognition.onend = () => {
|
|
318
|
+
var _a, _b;
|
|
319
|
+
if (!mountedRef.current)
|
|
320
|
+
return;
|
|
321
|
+
// Only reset to idle if we weren't already in processing/error/confirming
|
|
322
|
+
setStatus((prev) => prev === 'listening' || prev === 'processing' ? 'idle' : prev);
|
|
323
|
+
stopAudioAnalysis();
|
|
324
|
+
(_b = (_a = callbacksRef.current).onEnd) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
325
|
+
};
|
|
326
|
+
recognitionRef.current = recognition;
|
|
327
|
+
return () => {
|
|
328
|
+
var _a;
|
|
329
|
+
try {
|
|
330
|
+
(_a = recognitionRef.current) === null || _a === void 0 ? void 0 : _a.abort();
|
|
331
|
+
}
|
|
332
|
+
catch ( /* ignore */_b) { /* ignore */ }
|
|
333
|
+
recognitionRef.current = null;
|
|
334
|
+
stopAudioAnalysis();
|
|
335
|
+
};
|
|
336
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
337
|
+
}, [continuous, interimResults, isContinuousSupported]);
|
|
338
|
+
// ↑ language intentionally omitted from deps — updated via separate effect below
|
|
339
|
+
// ↑ executeCommand intentionally omitted — called via executeRef to avoid rebuilds
|
|
340
|
+
// Update lang on the existing recognition instance without rebuilding it
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
if (recognitionRef.current) {
|
|
343
|
+
recognitionRef.current.lang = language;
|
|
344
|
+
}
|
|
345
|
+
}, [language]);
|
|
346
|
+
// Keep pendingConfirm and resolveConfirm accessible inside the recognition
|
|
347
|
+
// onresult handler without adding them to the effect deps.
|
|
348
|
+
const pendingConfirmRef = useRef(pendingConfirm);
|
|
349
|
+
const resolveConfirmRef = useRef(resolveConfirm);
|
|
350
|
+
useEffect(() => { pendingConfirmRef.current = pendingConfirm; }, [pendingConfirm]);
|
|
351
|
+
useEffect(() => { resolveConfirmRef.current = resolveConfirm; }, [resolveConfirm]);
|
|
352
|
+
// ── Keyboard shortcut ───────────────────────────────────────────────────────
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
if (!shortcut)
|
|
355
|
+
return;
|
|
356
|
+
const handler = (e) => {
|
|
357
|
+
var _a, _b;
|
|
358
|
+
// e.g. shortcut = "alt+v"
|
|
359
|
+
const parts = shortcut.toLowerCase().split('+');
|
|
360
|
+
const key = parts[parts.length - 1];
|
|
361
|
+
const needsAlt = parts.includes('alt');
|
|
362
|
+
const needsCtrl = parts.includes('ctrl');
|
|
363
|
+
const needsShift = parts.includes('shift');
|
|
364
|
+
const needsMeta = parts.includes('meta');
|
|
365
|
+
if (e.key.toLowerCase() === key &&
|
|
366
|
+
e.altKey === needsAlt &&
|
|
367
|
+
e.ctrlKey === needsCtrl &&
|
|
368
|
+
e.shiftKey === needsShift &&
|
|
369
|
+
e.metaKey === needsMeta) {
|
|
370
|
+
e.preventDefault();
|
|
371
|
+
// Call toggle via refs to avoid stale closure
|
|
372
|
+
if (status === 'listening') {
|
|
373
|
+
try {
|
|
374
|
+
(_a = recognitionRef.current) === null || _a === void 0 ? void 0 : _a.stop();
|
|
375
|
+
}
|
|
376
|
+
catch ( /* ignore */_c) { /* ignore */ }
|
|
377
|
+
stopAudioAnalysis();
|
|
378
|
+
setStatus('idle');
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
(_b = startRef.current) === null || _b === void 0 ? void 0 : _b.call(startRef);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
window.addEventListener('keydown', handler);
|
|
386
|
+
return () => window.removeEventListener('keydown', handler);
|
|
387
|
+
}, [shortcut, status, stopAudioAnalysis]);
|
|
388
|
+
// ── Public actions ──────────────────────────────────────────────────────────
|
|
389
|
+
const start = useCallback(async () => {
|
|
390
|
+
var _a, _b, _c, _d;
|
|
391
|
+
if (!recognitionRef.current)
|
|
392
|
+
return;
|
|
393
|
+
if (status === 'listening' || startingRef.current)
|
|
394
|
+
return;
|
|
395
|
+
startingRef.current = true;
|
|
396
|
+
setTranscript('');
|
|
397
|
+
const micOk = await startAudioAnalysis();
|
|
398
|
+
startingRef.current = false;
|
|
399
|
+
if (!mountedRef.current)
|
|
400
|
+
return;
|
|
401
|
+
if (!micOk) {
|
|
402
|
+
(_b = (_a = callbacksRef.current).onToast) === null || _b === void 0 ? void 0 : _b.call(_a, 'error', 'Microphone access denied');
|
|
403
|
+
(_d = (_c = callbacksRef.current).onError) === null || _d === void 0 ? void 0 : _d.call(_c, 'not-allowed');
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
recognitionRef.current.start();
|
|
408
|
+
}
|
|
409
|
+
catch ( /* already started */_e) { /* already started */ }
|
|
410
|
+
}, [status, startAudioAnalysis]);
|
|
411
|
+
// Keep a ref so the shortcut handler can call start() without being stale
|
|
412
|
+
const startRef = useRef(start);
|
|
413
|
+
useEffect(() => { startRef.current = start; }, [start]);
|
|
414
|
+
const stop = useCallback(() => {
|
|
415
|
+
var _a;
|
|
416
|
+
try {
|
|
417
|
+
(_a = recognitionRef.current) === null || _a === void 0 ? void 0 : _a.stop();
|
|
418
|
+
}
|
|
419
|
+
catch ( /* ignore */_b) { /* ignore */ }
|
|
420
|
+
stopAudioAnalysis();
|
|
421
|
+
if (mountedRef.current)
|
|
422
|
+
setStatus('idle');
|
|
423
|
+
}, [stopAudioAnalysis]);
|
|
424
|
+
const toggle = useCallback(async () => {
|
|
425
|
+
if (status === 'listening')
|
|
426
|
+
stop();
|
|
427
|
+
else
|
|
428
|
+
await start();
|
|
429
|
+
}, [status, start, stop]);
|
|
430
|
+
const reset = useCallback(() => {
|
|
431
|
+
setTranscript('');
|
|
432
|
+
setHistory([]);
|
|
433
|
+
setLastMatch(null);
|
|
434
|
+
setPendingConfirm(null);
|
|
435
|
+
}, []);
|
|
436
|
+
// ── Mount / unmount guard ───────────────────────────────────────────────────
|
|
437
|
+
useEffect(() => {
|
|
438
|
+
mountedRef.current = true;
|
|
439
|
+
return () => {
|
|
440
|
+
mountedRef.current = false;
|
|
441
|
+
clearIdleTimer();
|
|
442
|
+
};
|
|
443
|
+
}, [clearIdleTimer]);
|
|
444
|
+
// ── Result ──────────────────────────────────────────────────────────────────
|
|
445
|
+
return {
|
|
446
|
+
status,
|
|
447
|
+
transcript,
|
|
448
|
+
audioLevel,
|
|
449
|
+
history,
|
|
450
|
+
lastMatch,
|
|
451
|
+
pendingConfirm,
|
|
452
|
+
isSupported,
|
|
453
|
+
isContinuousSupported,
|
|
454
|
+
isListening: status === 'listening',
|
|
455
|
+
isProcessing: status === 'processing',
|
|
456
|
+
start,
|
|
457
|
+
stop,
|
|
458
|
+
toggle,
|
|
459
|
+
reset,
|
|
460
|
+
executeCommand,
|
|
461
|
+
};
|
|
462
|
+
}
|