@untemps/react-vocal 2.0.0-beta.5 → 2.0.0-beta.6
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/CHANGELOG.md +7 -0
- package/README.md +4 -1
- package/dev/src/index.jsx +11 -3
- package/dist/index.es.js +99 -78
- package/dist/index.es.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/index.umd.js +2 -2
- package/dist/index.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Vocal.jsx +38 -10
- package/src/components/__tests__/Vocal.test.jsx +152 -15
- package/src/components/__tests__/__snapshots__/Vocal.test.jsx.snap +1 -1
- package/src/hooks/__tests__/useVocal.test.js +7 -2
- package/src/hooks/useCommands.js +11 -11
- package/src/hooks/useVocal.js +3 -3
- package/vitest.setup.js +8 -2
package/package.json
CHANGED
package/src/components/Vocal.jsx
CHANGED
|
@@ -22,8 +22,10 @@ const Vocal = ({
|
|
|
22
22
|
lang = 'en-US',
|
|
23
23
|
grammars = null,
|
|
24
24
|
timeout = 3000,
|
|
25
|
+
silenceTimeout = null,
|
|
25
26
|
precision = 0.4, // Fuse.js score threshold for phrase commands only; single-word commands always use exact lookup
|
|
26
27
|
maxAlternatives = 1,
|
|
28
|
+
continuous = false,
|
|
27
29
|
ariaLabel = 'start recognition',
|
|
28
30
|
style = null,
|
|
29
31
|
className = null,
|
|
@@ -40,21 +42,31 @@ const Vocal = ({
|
|
|
40
42
|
const buttonRef = useRef(null)
|
|
41
43
|
const [isListening, setIsListening] = useState(false)
|
|
42
44
|
|
|
43
|
-
const [, { start, stop, subscribe, unsubscribe }] = useVocal(lang, grammars, maxAlternatives, __rsInstance)
|
|
45
|
+
const [, { start, stop, subscribe, unsubscribe }] = useVocal(lang, grammars, maxAlternatives, continuous, __rsInstance)
|
|
44
46
|
const triggerCommand = useCommands(commands, precision)
|
|
45
47
|
|
|
46
48
|
const propsRef = useRef({})
|
|
47
49
|
propsRef.current = { onStart, onEnd, onSpeechStart, onSpeechEnd, onResult, onError, onNoMatch }
|
|
48
50
|
|
|
51
|
+
const continuousRef = useRef(continuous)
|
|
52
|
+
continuousRef.current = continuous
|
|
53
|
+
|
|
54
|
+
// In continuous mode, transcript accumulates across segments and is only emitted via onResult on session end
|
|
55
|
+
const accumulatedRef = useRef({ transcript: '', event: null })
|
|
56
|
+
|
|
49
57
|
const triggerCommandRef = useRef(triggerCommand)
|
|
50
58
|
triggerCommandRef.current = triggerCommand
|
|
51
59
|
|
|
52
60
|
const unsubscribeAllRef = useRef(null)
|
|
53
61
|
const onEndRef = useRef(null)
|
|
54
62
|
|
|
63
|
+
const silenceTimeoutRef = useRef(silenceTimeout)
|
|
64
|
+
silenceTimeoutRef.current = silenceTimeout
|
|
65
|
+
|
|
55
66
|
// Breaks the circular dep: _onEnd → useTimeout(handler) → startTimer captures _onEnd
|
|
56
67
|
const stableTimerCb = useCallback(() => onEndRef.current?.(), [])
|
|
57
68
|
const [startTimer, stopTimer] = useTimeout(stableTimerCb, timeout)
|
|
69
|
+
const [startSilenceTimer, stopSilenceTimer] = useTimeout(stableTimerCb, silenceTimeout ?? 0)
|
|
58
70
|
|
|
59
71
|
const stopRecognition = useCallback(() => {
|
|
60
72
|
try {
|
|
@@ -107,11 +119,18 @@ const Vocal = ({
|
|
|
107
119
|
const transcript = segmentData.map((s) => s.best).join('')
|
|
108
120
|
|
|
109
121
|
stopTimer()
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
122
|
+
if (continuousRef.current) {
|
|
123
|
+
// Accumulate — onResult fires once at session end, not after each segment
|
|
124
|
+
accumulatedRef.current.transcript = transcript
|
|
125
|
+
accumulatedRef.current.event = event
|
|
126
|
+
if (silenceTimeoutRef.current > 0) startSilenceTimer()
|
|
127
|
+
} else {
|
|
128
|
+
tryMatchCommand(segmentData, triggerCommandRef.current)
|
|
129
|
+
stopRecognition()
|
|
130
|
+
propsRef.current.onResult?.(transcript, event)
|
|
131
|
+
}
|
|
113
132
|
},
|
|
114
|
-
[stopTimer, stopRecognition]
|
|
133
|
+
[stopTimer, startSilenceTimer, stopRecognition]
|
|
115
134
|
)
|
|
116
135
|
|
|
117
136
|
const _onError = useCallback(
|
|
@@ -134,14 +153,20 @@ const Vocal = ({
|
|
|
134
153
|
const _onEnd = useCallback(
|
|
135
154
|
(e) => {
|
|
136
155
|
stopTimer()
|
|
156
|
+
stopSilenceTimer()
|
|
137
157
|
try {
|
|
138
158
|
stopRecognition()
|
|
139
159
|
unsubscribeAllRef.current?.()
|
|
160
|
+
if (continuousRef.current && accumulatedRef.current.transcript) {
|
|
161
|
+
propsRef.current.onResult?.(accumulatedRef.current.transcript, accumulatedRef.current.event)
|
|
162
|
+
accumulatedRef.current.transcript = ''
|
|
163
|
+
accumulatedRef.current.event = null
|
|
164
|
+
}
|
|
140
165
|
} finally {
|
|
141
166
|
propsRef.current.onEnd?.(e)
|
|
142
167
|
}
|
|
143
168
|
},
|
|
144
|
-
[stopTimer, stopRecognition]
|
|
169
|
+
[stopTimer, stopSilenceTimer, stopRecognition]
|
|
145
170
|
)
|
|
146
171
|
|
|
147
172
|
onEndRef.current = _onEnd
|
|
@@ -164,13 +189,16 @@ const Vocal = ({
|
|
|
164
189
|
|
|
165
190
|
const startRecognition = useCallback(() => {
|
|
166
191
|
try {
|
|
192
|
+
accumulatedRef.current.transcript = ''
|
|
193
|
+
accumulatedRef.current.event = null
|
|
194
|
+
stopSilenceTimer()
|
|
167
195
|
setIsListening(true)
|
|
168
196
|
Object.entries(HANDLERS).forEach(([event, fn]) => subscribe(event, fn))
|
|
169
197
|
start()
|
|
170
198
|
} catch (error) {
|
|
171
199
|
_onError(error)
|
|
172
200
|
}
|
|
173
|
-
}, [HANDLERS, subscribe, start, _onError])
|
|
201
|
+
}, [HANDLERS, subscribe, start, stopSilenceTimer, _onError])
|
|
174
202
|
|
|
175
203
|
const _onFocus = () => {
|
|
176
204
|
if (!className && outlineStyle) {
|
|
@@ -188,8 +216,8 @@ const Vocal = ({
|
|
|
188
216
|
<button
|
|
189
217
|
data-testid="__vocal-root__"
|
|
190
218
|
ref={buttonRef}
|
|
191
|
-
role="button"
|
|
192
219
|
aria-label={ariaLabel}
|
|
220
|
+
aria-pressed={isListening}
|
|
193
221
|
style={
|
|
194
222
|
className
|
|
195
223
|
? null
|
|
@@ -199,14 +227,14 @@ const Vocal = ({
|
|
|
199
227
|
backgroundColor: 'transparent', // `background: none` shorthand resets all sub-properties; jsdom 29 + jest-dom v6 don't reflect that correctly via getComputedStyle
|
|
200
228
|
border: 'none',
|
|
201
229
|
padding: 0,
|
|
202
|
-
cursor: !isListening ? '
|
|
230
|
+
cursor: !continuous && isListening ? 'default' : 'pointer',
|
|
203
231
|
...style,
|
|
204
232
|
}
|
|
205
233
|
}
|
|
206
234
|
className={className}
|
|
207
235
|
onFocus={_onFocus}
|
|
208
236
|
onBlur={_onBlur}
|
|
209
|
-
onClick={startRecognition}
|
|
237
|
+
onClick={isListening ? stopRecognition : startRecognition}
|
|
210
238
|
>
|
|
211
239
|
<Icon isActive={isListening} color="#aaa" />
|
|
212
240
|
</button>
|
|
@@ -99,12 +99,24 @@ describe('Vocal', () => {
|
|
|
99
99
|
expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'pointer' })
|
|
100
100
|
})
|
|
101
101
|
|
|
102
|
-
it('renders default cursor when listening', () => {
|
|
102
|
+
it('renders default cursor when listening in non-continuous mode', () => {
|
|
103
103
|
const { getByTestId } = render(getInstance())
|
|
104
104
|
fireEvent.click(getByTestId('__vocal-root__'))
|
|
105
105
|
expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'default' })
|
|
106
106
|
})
|
|
107
107
|
|
|
108
|
+
it('renders pointer cursor when listening in continuous mode', () => {
|
|
109
|
+
const { getByTestId } = render(getInstance({ continuous: true }))
|
|
110
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
111
|
+
expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'pointer' })
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('sets aria-pressed when listening', () => {
|
|
115
|
+
const { getByTestId } = render(getInstance())
|
|
116
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
117
|
+
expect(getByTestId('__vocal-root__')).toHaveAttribute('aria-pressed', 'true')
|
|
118
|
+
})
|
|
119
|
+
|
|
108
120
|
it('renders outline when focused', () => {
|
|
109
121
|
const { getByTestId } = render(getInstance())
|
|
110
122
|
fireEvent.focus(getByTestId('__vocal-root__'))
|
|
@@ -145,7 +157,7 @@ describe('Vocal', () => {
|
|
|
145
157
|
await waitFor(() => flag)
|
|
146
158
|
|
|
147
159
|
recognition.instance.say('Foo')
|
|
148
|
-
await waitFor(() => expect(callback).toHaveBeenCalledWith('Foo'))
|
|
160
|
+
await waitFor(() => expect(callback).toHaveBeenCalledWith('Foo', 'foo'))
|
|
149
161
|
})
|
|
150
162
|
})
|
|
151
163
|
|
|
@@ -276,7 +288,7 @@ describe('Vocal', () => {
|
|
|
276
288
|
|
|
277
289
|
await act(async () => {
|
|
278
290
|
fireEvent.click(getByTestId('__vocal-root__'))
|
|
279
|
-
await waitFor(() => expect(getByTestId('__vocal-root__')).
|
|
291
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveAttribute('aria-pressed', 'true'))
|
|
280
292
|
})
|
|
281
293
|
|
|
282
294
|
await act(async () => {
|
|
@@ -298,7 +310,7 @@ describe('Vocal', () => {
|
|
|
298
310
|
|
|
299
311
|
await act(async () => {
|
|
300
312
|
fireEvent.click(getByTestId('__vocal-root__'))
|
|
301
|
-
await waitFor(() => expect(getByTestId('__vocal-root__')).
|
|
313
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveAttribute('aria-pressed', 'true'))
|
|
302
314
|
})
|
|
303
315
|
|
|
304
316
|
await act(async () => {
|
|
@@ -310,7 +322,7 @@ describe('Vocal', () => {
|
|
|
310
322
|
await waitFor(() => expect(onEnd).toHaveBeenCalled())
|
|
311
323
|
})
|
|
312
324
|
|
|
313
|
-
expect(getByTestId('__vocal-root__')).
|
|
325
|
+
expect(getByTestId('__vocal-root__')).toHaveAttribute('aria-pressed', 'false')
|
|
314
326
|
})
|
|
315
327
|
|
|
316
328
|
it('calls the updated onResult prop after a re-render during an active session', async () => {
|
|
@@ -321,7 +333,7 @@ describe('Vocal', () => {
|
|
|
321
333
|
|
|
322
334
|
await act(async () => {
|
|
323
335
|
fireEvent.click(getByTestId('__vocal-root__'))
|
|
324
|
-
await waitFor(() => expect(getByTestId('__vocal-root__')).
|
|
336
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveAttribute('aria-pressed', 'true'))
|
|
325
337
|
})
|
|
326
338
|
|
|
327
339
|
await act(async () => {
|
|
@@ -344,7 +356,7 @@ describe('Vocal', () => {
|
|
|
344
356
|
|
|
345
357
|
await act(async () => {
|
|
346
358
|
fireEvent.click(getByTestId('__vocal-root__'))
|
|
347
|
-
await waitFor(() => expect(getByTestId('__vocal-root__')).
|
|
359
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveAttribute('aria-pressed', 'true'))
|
|
348
360
|
})
|
|
349
361
|
|
|
350
362
|
await act(async () => {
|
|
@@ -367,7 +379,7 @@ describe('Vocal', () => {
|
|
|
367
379
|
|
|
368
380
|
await act(async () => {
|
|
369
381
|
fireEvent.click(getByTestId('__vocal-root__'))
|
|
370
|
-
await waitFor(() => expect(getByTestId('__vocal-root__')).
|
|
382
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveAttribute('aria-pressed', 'true'))
|
|
371
383
|
})
|
|
372
384
|
|
|
373
385
|
await act(async () => {
|
|
@@ -390,7 +402,7 @@ describe('Vocal', () => {
|
|
|
390
402
|
|
|
391
403
|
await act(async () => {
|
|
392
404
|
fireEvent.click(getByTestId('__vocal-root__'))
|
|
393
|
-
await waitFor(() => expect(getByTestId('__vocal-root__')).
|
|
405
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveAttribute('aria-pressed', 'true'))
|
|
394
406
|
})
|
|
395
407
|
|
|
396
408
|
await act(async () => {
|
|
@@ -413,7 +425,7 @@ describe('Vocal', () => {
|
|
|
413
425
|
|
|
414
426
|
await act(async () => {
|
|
415
427
|
fireEvent.click(getByTestId('__vocal-root__'))
|
|
416
|
-
await waitFor(() => expect(getByTestId('__vocal-root__')).
|
|
428
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveAttribute('aria-pressed', 'true'))
|
|
417
429
|
})
|
|
418
430
|
|
|
419
431
|
await act(async () => {
|
|
@@ -440,7 +452,7 @@ describe('Vocal', () => {
|
|
|
440
452
|
[{ transcript: 'hello', confidence: 0.9 }],
|
|
441
453
|
[{ transcript: 'world', confidence: 0.8 }],
|
|
442
454
|
])
|
|
443
|
-
await waitFor(() => expect(callback).toHaveBeenCalledWith('hello'))
|
|
455
|
+
await waitFor(() => expect(callback).toHaveBeenCalledWith('hello', 'hello'))
|
|
444
456
|
})
|
|
445
457
|
})
|
|
446
458
|
|
|
@@ -456,7 +468,7 @@ describe('Vocal', () => {
|
|
|
456
468
|
[{ transcript: 'hello', confidence: 0.9 }],
|
|
457
469
|
[{ transcript: 'world', confidence: 0.8 }],
|
|
458
470
|
])
|
|
459
|
-
await waitFor(() => expect(callback).toHaveBeenCalledWith('world'))
|
|
471
|
+
await waitFor(() => expect(callback).toHaveBeenCalledWith('world', 'world'))
|
|
460
472
|
})
|
|
461
473
|
})
|
|
462
474
|
|
|
@@ -491,7 +503,7 @@ describe('Vocal', () => {
|
|
|
491
503
|
[{ transcript: 'hello', confidence: 0.9 }],
|
|
492
504
|
[{ transcript: 'world', confidence: 0.8 }],
|
|
493
505
|
])
|
|
494
|
-
await waitFor(() => expect(callbackHello).toHaveBeenCalledWith('hello'))
|
|
506
|
+
await waitFor(() => expect(callbackHello).toHaveBeenCalledWith('hello', 'hello'))
|
|
495
507
|
})
|
|
496
508
|
|
|
497
509
|
expect(callbackWorld).not.toHaveBeenCalled()
|
|
@@ -553,7 +565,7 @@ describe('Vocal', () => {
|
|
|
553
565
|
await act(async () => {
|
|
554
566
|
fireEvent.click(getByTestId('__vocal-root__'))
|
|
555
567
|
recognition.instance.say([[{ transcript: 'je veux du rouge', confidence: 0.9 }]])
|
|
556
|
-
await waitFor(() => expect(callback).toHaveBeenCalledWith('rouge'))
|
|
568
|
+
await waitFor(() => expect(callback).toHaveBeenCalledWith('rouge', 'rouge'))
|
|
557
569
|
})
|
|
558
570
|
})
|
|
559
571
|
|
|
@@ -570,7 +582,7 @@ describe('Vocal', () => {
|
|
|
570
582
|
{ transcript: 'verre', confidence: 0.9 },
|
|
571
583
|
{ transcript: 'vert', confidence: 0.7 },
|
|
572
584
|
]])
|
|
573
|
-
await waitFor(() => expect(callback).toHaveBeenCalledWith('vert'))
|
|
585
|
+
await waitFor(() => expect(callback).toHaveBeenCalledWith('vert', 'vert'))
|
|
574
586
|
})
|
|
575
587
|
})
|
|
576
588
|
|
|
@@ -608,4 +620,129 @@ describe('Vocal', () => {
|
|
|
608
620
|
await waitFor(() => expect(onEnd).toHaveBeenCalled())
|
|
609
621
|
})
|
|
610
622
|
})
|
|
623
|
+
|
|
624
|
+
describe('Continuous sessions', () => {
|
|
625
|
+
it('keeps session active after first result without firing onResult', async () => {
|
|
626
|
+
const onResult = vi.fn()
|
|
627
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
628
|
+
const { getByTestId } = render(getInstance({ __rsInstance: recognition, onResult, continuous: true }))
|
|
629
|
+
|
|
630
|
+
await act(async () => {
|
|
631
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
await act(async () => {
|
|
635
|
+
recognition.instance.say('Foo')
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
expect(onResult).not.toHaveBeenCalled()
|
|
639
|
+
expect(getByTestId('__vocal-root__')).toHaveAttribute('aria-pressed', 'true')
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
it('fires onResult once at session end with full accumulated transcript', async () => {
|
|
643
|
+
const onResult = vi.fn()
|
|
644
|
+
const onEnd = vi.fn()
|
|
645
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
646
|
+
const { getByTestId } = render(
|
|
647
|
+
getInstance({ __rsInstance: recognition, onResult, onEnd, continuous: true })
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
await act(async () => {
|
|
651
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
await act(async () => {
|
|
655
|
+
recognition.instance.say('Hello')
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
await act(async () => {
|
|
659
|
+
recognition.instance.say(' world')
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
expect(onResult).not.toHaveBeenCalled()
|
|
663
|
+
|
|
664
|
+
await act(async () => {
|
|
665
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
666
|
+
await waitFor(() => expect(onEnd).toHaveBeenCalled())
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
expect(onResult).toHaveBeenCalledTimes(1)
|
|
670
|
+
expect(onResult).toHaveBeenCalledWith('Hello world', expect.anything())
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
it('stops session on explicit button click while listening', async () => {
|
|
674
|
+
const onEnd = vi.fn()
|
|
675
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
676
|
+
const { getByTestId } = render(getInstance({ __rsInstance: recognition, onEnd, continuous: true }))
|
|
677
|
+
|
|
678
|
+
await act(async () => {
|
|
679
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
await act(async () => {
|
|
683
|
+
recognition.instance.say('Foo')
|
|
684
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveAttribute('aria-pressed', 'true'))
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
await act(async () => {
|
|
688
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
689
|
+
await waitFor(() => expect(onEnd).toHaveBeenCalled())
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
expect(getByTestId('__vocal-root__')).toHaveAttribute('aria-pressed', 'false')
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
it('does not evaluate commands in continuous mode', async () => {
|
|
696
|
+
const commandFn = vi.fn()
|
|
697
|
+
const onEnd = vi.fn()
|
|
698
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
699
|
+
const { getByTestId } = render(
|
|
700
|
+
getInstance({ __rsInstance: recognition, commands: { rouge: commandFn }, onEnd, continuous: true })
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
await act(async () => {
|
|
704
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
await act(async () => {
|
|
708
|
+
recognition.instance.say('rouge')
|
|
709
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveAttribute('aria-pressed', 'true'))
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
await act(async () => {
|
|
713
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
714
|
+
await waitFor(() => expect(onEnd).toHaveBeenCalled())
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
expect(commandFn).not.toHaveBeenCalled()
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
it('auto-stops after silenceTimeout ms of inactivity following last result', async () => {
|
|
721
|
+
vi.useFakeTimers()
|
|
722
|
+
const onEnd = vi.fn()
|
|
723
|
+
const onResult = vi.fn()
|
|
724
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
725
|
+
const { getByTestId } = render(
|
|
726
|
+
getInstance({ __rsInstance: recognition, onEnd, onResult, continuous: true, silenceTimeout: 5000 })
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
await act(async () => {
|
|
730
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
act(() => {
|
|
734
|
+
recognition.instance.say('Hello')
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
expect(onEnd).not.toHaveBeenCalled()
|
|
738
|
+
|
|
739
|
+
act(() => {
|
|
740
|
+
vi.advanceTimersByTime(5000)
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
expect(onEnd).toHaveBeenCalled()
|
|
744
|
+
expect(onResult).toHaveBeenCalledWith('Hello', expect.anything())
|
|
745
|
+
vi.useRealTimers()
|
|
746
|
+
})
|
|
747
|
+
})
|
|
611
748
|
})
|
|
@@ -4,8 +4,8 @@ exports[`Vocal > matches snapshot 1`] = `
|
|
|
4
4
|
<DocumentFragment>
|
|
5
5
|
<button
|
|
6
6
|
aria-label="start recognition"
|
|
7
|
+
aria-pressed="false"
|
|
7
8
|
data-testid="__vocal-root__"
|
|
8
|
-
role="button"
|
|
9
9
|
style="width: 24px; height: 24px; background-color: transparent; border: medium; padding: 0px; cursor: pointer;"
|
|
10
10
|
>
|
|
11
11
|
<svg
|
|
@@ -126,7 +126,12 @@ describe('useVocal', () => {
|
|
|
126
126
|
|
|
127
127
|
it('passes maxAlternatives to SpeechRecognitionWrapper constructor', () => {
|
|
128
128
|
renderHook(() => useVocal('en-US', null, 5))
|
|
129
|
-
expect(SpeechRecognitionWrapper).toHaveBeenCalledWith({
|
|
129
|
+
expect(SpeechRecognitionWrapper).toHaveBeenCalledWith({
|
|
130
|
+
lang: 'en-US',
|
|
131
|
+
grammars: null,
|
|
132
|
+
maxAlternatives: 5,
|
|
133
|
+
continuous: false,
|
|
134
|
+
})
|
|
130
135
|
})
|
|
131
136
|
|
|
132
137
|
it('uses custom SpeechRecognition instance', () => {
|
|
@@ -135,7 +140,7 @@ describe('useVocal', () => {
|
|
|
135
140
|
result: {
|
|
136
141
|
current: [ref],
|
|
137
142
|
},
|
|
138
|
-
} = renderHook(() => useVocal(null, null, 1, foo))
|
|
143
|
+
} = renderHook(() => useVocal(null, null, 1, false, foo))
|
|
139
144
|
expect(ref.current).toBe(foo)
|
|
140
145
|
})
|
|
141
146
|
|
package/src/hooks/useCommands.js
CHANGED
|
@@ -38,33 +38,33 @@ const useCommands = (commands, precision = 0.4) => {
|
|
|
38
38
|
})
|
|
39
39
|
}, [hasPhraseKeys, keys])
|
|
40
40
|
|
|
41
|
-
const triggerCommand = (
|
|
41
|
+
const triggerCommand = (rawInput) => {
|
|
42
42
|
if (!keys.length) return null
|
|
43
43
|
|
|
44
44
|
if (!hasPhraseKeys) {
|
|
45
|
-
const words =
|
|
46
|
-
const targets = words.length > 1 ? words : [
|
|
45
|
+
const words = rawInput.trim().split(/\s+/)
|
|
46
|
+
const targets = words.length > 1 ? words : [rawInput.trim()]
|
|
47
47
|
for (const w of targets) {
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
48
|
+
const commandKey = w.toLowerCase()
|
|
49
|
+
if (commandKey in normalized) return normalized[commandKey]?.(w, commandKey)
|
|
50
50
|
}
|
|
51
51
|
return null
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
const fuse = fuseRef.current
|
|
55
55
|
if (fuse) {
|
|
56
|
-
const result = fuse.search(
|
|
56
|
+
const result = fuse.search(rawInput).filter((r) => r.score < precision)
|
|
57
57
|
if (result?.length) {
|
|
58
|
-
const
|
|
59
|
-
return normalized[
|
|
58
|
+
const commandKey = result[0].item.toLowerCase()
|
|
59
|
+
return normalized[commandKey]?.(rawInput, commandKey)
|
|
60
60
|
}
|
|
61
61
|
} else {
|
|
62
62
|
// `k.includes(lInput)` can produce false positives when input is short
|
|
63
63
|
// (e.g. "rouge" matches "change en rouge"). Accepted tradeoff: this branch
|
|
64
64
|
// only runs when fuse.js is absent, so degraded precision is expected.
|
|
65
|
-
const lInput =
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
65
|
+
const lInput = rawInput.toLowerCase()
|
|
66
|
+
const commandKey = keys.find((k) => lInput.includes(k) || k.includes(lInput))
|
|
67
|
+
if (commandKey) return normalized[commandKey]?.(rawInput, commandKey)
|
|
68
68
|
}
|
|
69
69
|
return null
|
|
70
70
|
}
|
package/src/hooks/useVocal.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from 'react'
|
|
2
2
|
import { Vocal as SpeechRecognitionWrapper } from '@untemps/vocal'
|
|
3
3
|
|
|
4
|
-
const useVocal = (lang = 'en-US', grammars = null, maxAlternatives = 1, __rsInstance = null) => {
|
|
4
|
+
const useVocal = (lang = 'en-US', grammars = null, maxAlternatives = 1, continuous = false, __rsInstance = null) => {
|
|
5
5
|
const ref = useRef(null)
|
|
6
6
|
|
|
7
7
|
useEffect(() => {
|
|
8
8
|
if (SpeechRecognitionWrapper.isSupported) {
|
|
9
|
-
ref.current = __rsInstance || new SpeechRecognitionWrapper({ lang, grammars, maxAlternatives })
|
|
9
|
+
ref.current = __rsInstance || new SpeechRecognitionWrapper({ lang, grammars, maxAlternatives, continuous })
|
|
10
10
|
return () => {
|
|
11
11
|
ref.current.abort()
|
|
12
12
|
ref.current.cleanup()
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
-
}, [lang, grammars, maxAlternatives, __rsInstance])
|
|
15
|
+
}, [lang, grammars, maxAlternatives, continuous, __rsInstance])
|
|
16
16
|
|
|
17
17
|
const start = useCallback(() => {
|
|
18
18
|
if (ref.current) {
|
package/vitest.setup.js
CHANGED
|
@@ -39,6 +39,7 @@ global.SpeechGrammarList = vi.fn(function () {
|
|
|
39
39
|
})
|
|
40
40
|
global.SpeechRecognition = vi.fn(function () {
|
|
41
41
|
const handlers = {}
|
|
42
|
+
let accumulatedResults = []
|
|
42
43
|
return {
|
|
43
44
|
addEventListener: vi.fn(function (type, callback) {
|
|
44
45
|
handlers[type] = callback
|
|
@@ -46,6 +47,7 @@ global.SpeechRecognition = vi.fn(function () {
|
|
|
46
47
|
removeEventListener: vi.fn(),
|
|
47
48
|
dispatchEvent: vi.fn(),
|
|
48
49
|
start: vi.fn(function () {
|
|
50
|
+
accumulatedResults = []
|
|
49
51
|
handlers.start?.()
|
|
50
52
|
}),
|
|
51
53
|
stop: vi.fn(function () {
|
|
@@ -57,9 +59,13 @@ global.SpeechRecognition = vi.fn(function () {
|
|
|
57
59
|
say: vi.fn(function (input) {
|
|
58
60
|
handlers.speechstart?.()
|
|
59
61
|
|
|
62
|
+
const newSegments = Array.isArray(input) ? input : input ? [[{ transcript: input }]] : []
|
|
63
|
+
const resultIndex = accumulatedResults.length
|
|
64
|
+
accumulatedResults = [...accumulatedResults, ...newSegments]
|
|
65
|
+
|
|
60
66
|
const resultEvent = new Event('result')
|
|
61
|
-
resultEvent.resultIndex =
|
|
62
|
-
resultEvent.results =
|
|
67
|
+
resultEvent.resultIndex = resultIndex
|
|
68
|
+
resultEvent.results = accumulatedResults
|
|
63
69
|
handlers.speechend?.()
|
|
64
70
|
if (input) {
|
|
65
71
|
handlers.result?.(resultEvent)
|