@untemps/react-vocal 2.0.0-beta.3 → 2.0.0-beta.5
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 +19 -0
- package/README.md +28 -15
- package/dev/src/index.jsx +39 -26
- package/dist/index.es.js +172 -1297
- 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 +9 -3
- package/src/components/Vocal.jsx +25 -9
- package/src/components/__tests__/Vocal.test.jsx +144 -9
- package/src/hooks/__tests__/useCommands.test.js +53 -2
- package/src/hooks/__tests__/useVocal.test.js +6 -1
- package/src/hooks/useCommands.js +63 -9
- package/src/hooks/useVocal.js +3 -3
- package/vite.config.js +2 -1
- package/vitest.setup.js +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@untemps/react-vocal",
|
|
3
|
-
"version": "2.0.0-beta.
|
|
3
|
+
"version": "2.0.0-beta.5",
|
|
4
4
|
"author": "Vincent Le Badezet <v.lebadezet@untemps.net>",
|
|
5
5
|
"repository": "git@github.com:untemps/react-vocal.git",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@commitlint/cli": "^20.5.3",
|
|
30
|
+
"fuse.js": "^7.3.0",
|
|
30
31
|
"@commitlint/config-conventional": "^20.5.3",
|
|
31
32
|
"@semantic-release/changelog": "^6.0.3",
|
|
32
33
|
"@semantic-release/git": "^10.0.1",
|
|
@@ -47,12 +48,17 @@
|
|
|
47
48
|
"vitest": "^4.1.5"
|
|
48
49
|
},
|
|
49
50
|
"peerDependencies": {
|
|
51
|
+
"fuse.js": ">=6.0.0",
|
|
50
52
|
"react": ">=16.13.1",
|
|
51
53
|
"react-dom": ">=16.13.1"
|
|
52
54
|
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"fuse.js": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
53
60
|
"dependencies": {
|
|
54
|
-
"@untemps/vocal": "^1.3.0"
|
|
55
|
-
"fuse.js": "^7.3.0"
|
|
61
|
+
"@untemps/vocal": "^1.3.0"
|
|
56
62
|
},
|
|
57
63
|
"release": {
|
|
58
64
|
"branches": [
|
package/src/components/Vocal.jsx
CHANGED
|
@@ -8,12 +8,22 @@ import useCommands from '../hooks/useCommands'
|
|
|
8
8
|
|
|
9
9
|
import Icon from './Icon'
|
|
10
10
|
|
|
11
|
+
const tryMatchCommand = (segmentData, trigger) => {
|
|
12
|
+
for (const { alternatives } of segmentData) {
|
|
13
|
+
for (const a of alternatives) {
|
|
14
|
+
if (trigger(a) !== null) return
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
const Vocal = ({
|
|
12
20
|
children,
|
|
13
21
|
commands = null,
|
|
14
22
|
lang = 'en-US',
|
|
15
23
|
grammars = null,
|
|
16
24
|
timeout = 3000,
|
|
25
|
+
precision = 0.4, // Fuse.js score threshold for phrase commands only; single-word commands always use exact lookup
|
|
26
|
+
maxAlternatives = 1,
|
|
17
27
|
ariaLabel = 'start recognition',
|
|
18
28
|
style = null,
|
|
19
29
|
className = null,
|
|
@@ -30,8 +40,8 @@ const Vocal = ({
|
|
|
30
40
|
const buttonRef = useRef(null)
|
|
31
41
|
const [isListening, setIsListening] = useState(false)
|
|
32
42
|
|
|
33
|
-
const [, { start, stop, subscribe, unsubscribe }] = useVocal(lang, grammars, __rsInstance)
|
|
34
|
-
const triggerCommand = useCommands(commands)
|
|
43
|
+
const [, { start, stop, subscribe, unsubscribe }] = useVocal(lang, grammars, maxAlternatives, __rsInstance)
|
|
44
|
+
const triggerCommand = useCommands(commands, precision)
|
|
35
45
|
|
|
36
46
|
const propsRef = useRef({})
|
|
37
47
|
propsRef.current = { onStart, onEnd, onSpeechStart, onSpeechEnd, onResult, onError, onNoMatch }
|
|
@@ -52,7 +62,6 @@ const Vocal = ({
|
|
|
52
62
|
stop()
|
|
53
63
|
} catch (error) {
|
|
54
64
|
propsRef.current.onError?.(error)
|
|
55
|
-
} finally {
|
|
56
65
|
unsubscribeAllRef.current?.()
|
|
57
66
|
}
|
|
58
67
|
}, [stop])
|
|
@@ -83,20 +92,23 @@ const Vocal = ({
|
|
|
83
92
|
|
|
84
93
|
const _onResult = useCallback(
|
|
85
94
|
(event) => {
|
|
86
|
-
const
|
|
95
|
+
const segmentData = Array.from(event?.results ?? [], (segment) => {
|
|
87
96
|
let best = { confidence: -Infinity, transcript: '' }
|
|
97
|
+
const alternatives = []
|
|
88
98
|
for (let j = 0; j < segment.length; j++) {
|
|
89
99
|
const alt = segment[j]
|
|
100
|
+
alternatives.push(alt.transcript ?? '')
|
|
90
101
|
if (alt.confidence === undefined || alt.confidence > best.confidence) {
|
|
91
102
|
best = alt
|
|
92
103
|
}
|
|
93
104
|
}
|
|
94
|
-
return best.transcript ?? ''
|
|
95
|
-
})
|
|
105
|
+
return { best: best.transcript ?? '', alternatives }
|
|
106
|
+
})
|
|
107
|
+
const transcript = segmentData.map((s) => s.best).join('')
|
|
96
108
|
|
|
97
109
|
stopTimer()
|
|
98
110
|
stopRecognition()
|
|
99
|
-
triggerCommandRef.current
|
|
111
|
+
tryMatchCommand(segmentData, triggerCommandRef.current)
|
|
100
112
|
propsRef.current.onResult?.(transcript, event)
|
|
101
113
|
},
|
|
102
114
|
[stopTimer, stopRecognition]
|
|
@@ -122,8 +134,12 @@ const Vocal = ({
|
|
|
122
134
|
const _onEnd = useCallback(
|
|
123
135
|
(e) => {
|
|
124
136
|
stopTimer()
|
|
125
|
-
|
|
126
|
-
|
|
137
|
+
try {
|
|
138
|
+
stopRecognition()
|
|
139
|
+
unsubscribeAllRef.current?.()
|
|
140
|
+
} finally {
|
|
141
|
+
propsRef.current.onEnd?.(e)
|
|
142
|
+
}
|
|
127
143
|
},
|
|
128
144
|
[stopTimer, stopRecognition]
|
|
129
145
|
)
|
|
@@ -428,7 +428,92 @@ describe('Vocal', () => {
|
|
|
428
428
|
expect(onErrorV1).not.toHaveBeenCalled()
|
|
429
429
|
})
|
|
430
430
|
|
|
431
|
-
it('
|
|
431
|
+
it('triggers command matched on first segment in multi-segment result', async () => {
|
|
432
|
+
const callback = vi.fn()
|
|
433
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
434
|
+
const commands = { hello: callback }
|
|
435
|
+
const { getByTestId } = render(getInstance({ __rsInstance: recognition, commands }))
|
|
436
|
+
|
|
437
|
+
await act(async () => {
|
|
438
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
439
|
+
recognition.instance.say([
|
|
440
|
+
[{ transcript: 'hello', confidence: 0.9 }],
|
|
441
|
+
[{ transcript: 'world', confidence: 0.8 }],
|
|
442
|
+
])
|
|
443
|
+
await waitFor(() => expect(callback).toHaveBeenCalledWith('hello'))
|
|
444
|
+
})
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('triggers command matched on second segment in multi-segment result', async () => {
|
|
448
|
+
const callback = vi.fn()
|
|
449
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
450
|
+
const commands = { world: callback }
|
|
451
|
+
const { getByTestId } = render(getInstance({ __rsInstance: recognition, commands }))
|
|
452
|
+
|
|
453
|
+
await act(async () => {
|
|
454
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
455
|
+
recognition.instance.say([
|
|
456
|
+
[{ transcript: 'hello', confidence: 0.9 }],
|
|
457
|
+
[{ transcript: 'world', confidence: 0.8 }],
|
|
458
|
+
])
|
|
459
|
+
await waitFor(() => expect(callback).toHaveBeenCalledWith('world'))
|
|
460
|
+
})
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('does not trigger command when no segment matches', async () => {
|
|
464
|
+
const callback = vi.fn()
|
|
465
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
466
|
+
const commands = { foo: callback }
|
|
467
|
+
const { getByTestId } = render(getInstance({ __rsInstance: recognition, commands }))
|
|
468
|
+
|
|
469
|
+
await act(async () => {
|
|
470
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
471
|
+
recognition.instance.say([
|
|
472
|
+
[{ transcript: 'hello', confidence: 0.9 }],
|
|
473
|
+
[{ transcript: 'world', confidence: 0.8 }],
|
|
474
|
+
])
|
|
475
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
expect(callback).not.toHaveBeenCalled()
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it('fires only the first matching command when multiple segments each match a different command', async () => {
|
|
482
|
+
const callbackHello = vi.fn()
|
|
483
|
+
const callbackWorld = vi.fn()
|
|
484
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
485
|
+
const commands = { hello: callbackHello, world: callbackWorld }
|
|
486
|
+
const { getByTestId } = render(getInstance({ __rsInstance: recognition, commands }))
|
|
487
|
+
|
|
488
|
+
await act(async () => {
|
|
489
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
490
|
+
recognition.instance.say([
|
|
491
|
+
[{ transcript: 'hello', confidence: 0.9 }],
|
|
492
|
+
[{ transcript: 'world', confidence: 0.8 }],
|
|
493
|
+
])
|
|
494
|
+
await waitFor(() => expect(callbackHello).toHaveBeenCalledWith('hello'))
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
expect(callbackWorld).not.toHaveBeenCalled()
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('passes full joined transcript to onResult regardless of command segment matching', async () => {
|
|
501
|
+
const onResult = vi.fn()
|
|
502
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
503
|
+
const commands = { hello: vi.fn() }
|
|
504
|
+
const { getByTestId } = render(getInstance({ __rsInstance: recognition, commands, onResult }))
|
|
505
|
+
|
|
506
|
+
await act(async () => {
|
|
507
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
508
|
+
recognition.instance.say([
|
|
509
|
+
[{ transcript: 'hello ', confidence: 0.9 }],
|
|
510
|
+
[{ transcript: 'world', confidence: 0.8 }],
|
|
511
|
+
])
|
|
512
|
+
await waitFor(() => expect(onResult).toHaveBeenCalledWith('hello world', expect.anything()))
|
|
513
|
+
})
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('returns the most confident alternative as the onResult transcript', async () => {
|
|
432
517
|
const onResult = vi.fn()
|
|
433
518
|
const recognition = new SpeechRecognitionWrapper()
|
|
434
519
|
const { getByTestId } = render(getInstance({ __rsInstance: recognition, onResult }))
|
|
@@ -444,7 +529,7 @@ describe('Vocal', () => {
|
|
|
444
529
|
})
|
|
445
530
|
})
|
|
446
531
|
|
|
447
|
-
it('joins all segments
|
|
532
|
+
it('joins all segments into the onResult transcript', async () => {
|
|
448
533
|
const onResult = vi.fn()
|
|
449
534
|
const recognition = new SpeechRecognitionWrapper()
|
|
450
535
|
const { getByTestId } = render(getInstance({ __rsInstance: recognition, onResult }))
|
|
@@ -459,18 +544,68 @@ describe('Vocal', () => {
|
|
|
459
544
|
})
|
|
460
545
|
})
|
|
461
546
|
|
|
462
|
-
it('
|
|
547
|
+
it('triggers command matched on a word within a multi-word segment', async () => {
|
|
548
|
+
const callback = vi.fn()
|
|
549
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
550
|
+
const commands = { rouge: callback }
|
|
551
|
+
const { getByTestId } = render(getInstance({ __rsInstance: recognition, commands }))
|
|
552
|
+
|
|
553
|
+
await act(async () => {
|
|
554
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
555
|
+
recognition.instance.say([[{ transcript: 'je veux du rouge', confidence: 0.9 }]])
|
|
556
|
+
await waitFor(() => expect(callback).toHaveBeenCalledWith('rouge'))
|
|
557
|
+
})
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
it('triggers command matched on a secondary alternative (homophone)', async () => {
|
|
561
|
+
const callback = vi.fn()
|
|
562
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
563
|
+
const commands = { vert: callback }
|
|
564
|
+
const { getByTestId } = render(getInstance({ __rsInstance: recognition, commands, maxAlternatives: 3 }))
|
|
565
|
+
|
|
566
|
+
await act(async () => {
|
|
567
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
568
|
+
// Primary alternative is the homophone; secondary is the correct word
|
|
569
|
+
recognition.instance.say([[
|
|
570
|
+
{ transcript: 'verre', confidence: 0.9 },
|
|
571
|
+
{ transcript: 'vert', confidence: 0.7 },
|
|
572
|
+
]])
|
|
573
|
+
await waitFor(() => expect(callback).toHaveBeenCalledWith('vert'))
|
|
574
|
+
})
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('passes the most confident transcript to onResult even when command matches a secondary alternative', async () => {
|
|
463
578
|
const onResult = vi.fn()
|
|
464
579
|
const recognition = new SpeechRecognitionWrapper()
|
|
465
|
-
const
|
|
580
|
+
const commands = { vert: vi.fn() }
|
|
581
|
+
const { getByTestId } = render(getInstance({ __rsInstance: recognition, commands, onResult, maxAlternatives: 3 }))
|
|
466
582
|
|
|
467
583
|
await act(async () => {
|
|
468
584
|
fireEvent.click(getByTestId('__vocal-root__'))
|
|
469
|
-
recognition.instance.say([
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
])
|
|
473
|
-
await waitFor(() => expect(onResult).toHaveBeenCalledWith('
|
|
585
|
+
recognition.instance.say([[
|
|
586
|
+
{ transcript: 'verre', confidence: 0.9 },
|
|
587
|
+
{ transcript: 'vert', confidence: 0.7 },
|
|
588
|
+
]])
|
|
589
|
+
await waitFor(() => expect(onResult).toHaveBeenCalledWith('verre', expect.anything()))
|
|
590
|
+
})
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it('calls onEnd via the end event when stop is asynchronous', async () => {
|
|
594
|
+
const onEnd = vi.fn()
|
|
595
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
596
|
+
const { getByTestId } = render(getInstance({ __rsInstance: recognition, onEnd }))
|
|
597
|
+
|
|
598
|
+
// Simulate async stop: override stop() so the end event does not fire immediately
|
|
599
|
+
recognition.instance.stop = vi.fn()
|
|
600
|
+
|
|
601
|
+
await act(async () => {
|
|
602
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
603
|
+
recognition.instance.say('Foo')
|
|
604
|
+
// stopRecognition was called but end has not fired yet — onEnd must not be called
|
|
605
|
+
expect(onEnd).not.toHaveBeenCalled()
|
|
606
|
+
// Browser fires end asynchronously after recognition stops
|
|
607
|
+
recognition.instance.end()
|
|
608
|
+
await waitFor(() => expect(onEnd).toHaveBeenCalled())
|
|
474
609
|
})
|
|
475
610
|
})
|
|
476
611
|
})
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import { renderHook } from '@testing-library/react'
|
|
1
|
+
import { act, renderHook } from '@testing-library/react'
|
|
2
2
|
|
|
3
3
|
import useCommands from '../useCommands'
|
|
4
4
|
|
|
5
|
+
// Static import anchors fuse.js in the module graph so vi.mock intercepts the
|
|
6
|
+
// dynamic import('fuse.js') inside the hook. Without it the dynamic import resolves
|
|
7
|
+
// against the real module before the async mock factory completes.
|
|
8
|
+
import 'fuse.js'
|
|
9
|
+
|
|
10
|
+
vi.mock('fuse.js', async () => {
|
|
11
|
+
const actual = await vi.importActual('fuse.js')
|
|
12
|
+
return actual
|
|
13
|
+
})
|
|
14
|
+
|
|
5
15
|
describe('useCommands', () => {
|
|
6
16
|
it('returns triggerCommand function', () => {
|
|
7
17
|
const triggerCommand = renderHook(() => useCommands())
|
|
@@ -41,13 +51,15 @@ describe('useCommands', () => {
|
|
|
41
51
|
['Change la bordure en vert', 'Change la bordure en verre de rouge', null],
|
|
42
52
|
['Change la bordure en vert', 'Change la bordure en violet', null],
|
|
43
53
|
['Change la bordure en vert', 'Modifie la bordure en violet', null],
|
|
44
|
-
])('triggers callback mapped to approximate inputs', (command, input, expected) => {
|
|
54
|
+
])('triggers callback mapped to approximate inputs', async (command, input, expected) => {
|
|
45
55
|
const commands = {
|
|
46
56
|
[command]: () => value,
|
|
47
57
|
}
|
|
48
58
|
const {
|
|
49
59
|
result: { current: triggerCommand },
|
|
50
60
|
} = renderHook(() => useCommands(commands))
|
|
61
|
+
// Flush the dynamic import microtask
|
|
62
|
+
await act(async () => {})
|
|
51
63
|
expect(triggerCommand(input)).toBe(expected)
|
|
52
64
|
})
|
|
53
65
|
})
|
|
@@ -61,4 +73,43 @@ describe('useCommands', () => {
|
|
|
61
73
|
} = renderHook(() => useCommands(commands))
|
|
62
74
|
expect(triggerCommand('gag')).toBeNull()
|
|
63
75
|
})
|
|
76
|
+
|
|
77
|
+
it('triggers all registered commands when multiple commands are defined', () => {
|
|
78
|
+
const commands = {
|
|
79
|
+
rouge: () => 'red',
|
|
80
|
+
bleu: () => 'blue',
|
|
81
|
+
jaune: () => 'yellow',
|
|
82
|
+
}
|
|
83
|
+
const {
|
|
84
|
+
result: { current: triggerCommand },
|
|
85
|
+
} = renderHook(() => useCommands(commands))
|
|
86
|
+
expect(triggerCommand('rouge')).toBe('red')
|
|
87
|
+
expect(triggerCommand('bleu')).toBe('blue')
|
|
88
|
+
expect(triggerCommand('jaune')).toBe('yellow')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('does not match near-homophones with strict precision — rely on maxAlternatives instead', () => {
|
|
92
|
+
const commands = { vert: () => 'green' }
|
|
93
|
+
const {
|
|
94
|
+
result: { current: triggerCommand },
|
|
95
|
+
} = renderHook(() => useCommands(commands))
|
|
96
|
+
// 'verre' scores 0.4 against 'vert' — not strictly < STRICT_PRECISION (0.4)
|
|
97
|
+
expect(triggerCommand('verre')).toBeNull()
|
|
98
|
+
// The engine surfaces 'vert' as a secondary alternative (score 0) — exact match
|
|
99
|
+
expect(triggerCommand('vert')).toBe('green')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('falls back to contains matching when fuse.js is not available', async () => {
|
|
103
|
+
vi.doMock('fuse.js', () => {
|
|
104
|
+
throw new Error('fuse.js not installed')
|
|
105
|
+
})
|
|
106
|
+
const { default: useCommandsWithoutFuse } = await import('../useCommands')
|
|
107
|
+
const commands = { 'change color': () => 'matched' }
|
|
108
|
+
const {
|
|
109
|
+
result: { current: triggerCommand },
|
|
110
|
+
} = renderHook(() => useCommandsWithoutFuse(commands))
|
|
111
|
+
await act(async () => {})
|
|
112
|
+
expect(triggerCommand('change color')).toBe('matched')
|
|
113
|
+
vi.doUnmock('fuse.js')
|
|
114
|
+
})
|
|
64
115
|
})
|
|
@@ -124,13 +124,18 @@ describe('useVocal', () => {
|
|
|
124
124
|
expect(ref.current).toBeDefined()
|
|
125
125
|
})
|
|
126
126
|
|
|
127
|
+
it('passes maxAlternatives to SpeechRecognitionWrapper constructor', () => {
|
|
128
|
+
renderHook(() => useVocal('en-US', null, 5))
|
|
129
|
+
expect(SpeechRecognitionWrapper).toHaveBeenCalledWith({ lang: 'en-US', grammars: null, maxAlternatives: 5 })
|
|
130
|
+
})
|
|
131
|
+
|
|
127
132
|
it('uses custom SpeechRecognition instance', () => {
|
|
128
133
|
const foo = new SpeechRecognitionWrapper()
|
|
129
134
|
const {
|
|
130
135
|
result: {
|
|
131
136
|
current: [ref],
|
|
132
137
|
},
|
|
133
|
-
} = renderHook(() => useVocal(null, null, foo))
|
|
138
|
+
} = renderHook(() => useVocal(null, null, 1, foo))
|
|
134
139
|
expect(ref.current).toBe(foo)
|
|
135
140
|
})
|
|
136
141
|
|
package/src/hooks/useCommands.js
CHANGED
|
@@ -1,16 +1,70 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { useEffect, useMemo, useRef } from 'react'
|
|
2
2
|
|
|
3
3
|
const useCommands = (commands, precision = 0.4) => {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
const normalized = useMemo(
|
|
5
|
+
() =>
|
|
6
|
+
!!commands
|
|
7
|
+
? Object.entries(commands).reduce((acc, [key, value]) => ({ ...acc, [key.toLowerCase()]: value }), {})
|
|
8
|
+
: {},
|
|
9
|
+
[commands]
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
const keys = useMemo(() => Object.keys(normalized), [normalized])
|
|
13
|
+
|
|
14
|
+
// Fuzzy matching is only needed for phrase command keys.
|
|
15
|
+
// Single-word keys use exact case-insensitive lookup — simpler and no false positives.
|
|
16
|
+
const hasPhraseKeys = useMemo(() => keys.some((k) => k.includes(' ')), [keys])
|
|
17
|
+
|
|
18
|
+
// Lazy-loaded so consumers using only single-word commands incur no bundle cost.
|
|
19
|
+
const fuseRef = useRef(null)
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!hasPhraseKeys) {
|
|
23
|
+
fuseRef.current = null
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
import('fuse.js')
|
|
27
|
+
.then((module) => {
|
|
28
|
+
const Fuse = module.default ?? module
|
|
29
|
+
fuseRef.current = new Fuse(keys, { includeScore: true, ignoreLocation: true })
|
|
30
|
+
})
|
|
31
|
+
.catch(() => {
|
|
32
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
33
|
+
console.warn(
|
|
34
|
+
'[react-vocal] fuse.js is not installed. Phrase command keys will fall back to exact matching. ' +
|
|
35
|
+
'Install fuse.js to enable fuzzy matching: npm install fuse.js'
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
}, [hasPhraseKeys, keys])
|
|
7
40
|
|
|
8
41
|
const triggerCommand = (input) => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (
|
|
12
|
-
const
|
|
13
|
-
|
|
42
|
+
if (!keys.length) return null
|
|
43
|
+
|
|
44
|
+
if (!hasPhraseKeys) {
|
|
45
|
+
const words = input.trim().split(/\s+/)
|
|
46
|
+
const targets = words.length > 1 ? words : [input.trim()]
|
|
47
|
+
for (const w of targets) {
|
|
48
|
+
const key = w.toLowerCase()
|
|
49
|
+
if (key in normalized) return normalized[key]?.(w)
|
|
50
|
+
}
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const fuse = fuseRef.current
|
|
55
|
+
if (fuse) {
|
|
56
|
+
const result = fuse.search(input).filter((r) => r.score < precision)
|
|
57
|
+
if (result?.length) {
|
|
58
|
+
const key = result[0].item.toLowerCase()
|
|
59
|
+
return normalized[key]?.(input)
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
// `k.includes(lInput)` can produce false positives when input is short
|
|
63
|
+
// (e.g. "rouge" matches "change en rouge"). Accepted tradeoff: this branch
|
|
64
|
+
// only runs when fuse.js is absent, so degraded precision is expected.
|
|
65
|
+
const lInput = input.toLowerCase()
|
|
66
|
+
const match = keys.find((k) => lInput.includes(k) || k.includes(lInput))
|
|
67
|
+
if (match) return normalized[match]?.(input)
|
|
14
68
|
}
|
|
15
69
|
return null
|
|
16
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, __rsInstance = null) => {
|
|
4
|
+
const useVocal = (lang = 'en-US', grammars = null, maxAlternatives = 1, __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 })
|
|
9
|
+
ref.current = __rsInstance || new SpeechRecognitionWrapper({ lang, grammars, maxAlternatives })
|
|
10
10
|
return () => {
|
|
11
11
|
ref.current.abort()
|
|
12
12
|
ref.current.cleanup()
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
-
}, [lang, grammars, __rsInstance])
|
|
15
|
+
}, [lang, grammars, maxAlternatives, __rsInstance])
|
|
16
16
|
|
|
17
17
|
const start = useCallback(() => {
|
|
18
18
|
if (ref.current) {
|
package/vite.config.js
CHANGED
|
@@ -11,11 +11,12 @@ export default defineConfig({
|
|
|
11
11
|
fileName: (format) => ({ es: 'index.es.js', umd: 'index.umd.js', cjs: 'index.js' })[format],
|
|
12
12
|
},
|
|
13
13
|
rollupOptions: {
|
|
14
|
-
external: ['react', 'react-dom'],
|
|
14
|
+
external: ['react', 'react-dom', 'fuse.js'],
|
|
15
15
|
output: {
|
|
16
16
|
globals: {
|
|
17
17
|
react: 'React',
|
|
18
18
|
'react-dom': 'ReactDOM',
|
|
19
|
+
'fuse.js': 'Fuse',
|
|
19
20
|
},
|
|
20
21
|
},
|
|
21
22
|
},
|