@untemps/react-vocal 2.0.0-beta.6 → 2.0.0-beta.8
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 +24 -0
- package/README.md +23 -5
- package/dist/index.es.js +233 -759
- package/dist/index.js +2 -4
- package/package.json +7 -6
- package/.github/workflows/publish.yml +0 -32
- package/.husky/commit-msg +0 -1
- package/.husky/pre-commit +0 -1
- package/.prettierignore +0 -3
- package/.prettierrc +0 -29
- package/CLAUDE.md +0 -59
- package/assets/icon-idle.png +0 -0
- package/assets/icon-listening.png +0 -0
- package/assets/microphone.png +0 -0
- package/assets/react-vocal.png +0 -0
- package/commitlint.config.js +0 -7
- package/dev/index.html +0 -24
- package/dev/package.json +0 -18
- package/dev/public/index.html +0 -24
- package/dev/src/index.jsx +0 -66
- package/dev/vite.config.js +0 -10
- package/dev/yarn.lock +0 -325
- package/dist/index.es.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/index.umd.js +0 -9
- package/dist/index.umd.js.map +0 -1
- package/src/components/Icon.jsx +0 -24
- package/src/components/Vocal.jsx +0 -261
- package/src/components/__tests__/Icon.test.jsx +0 -38
- package/src/components/__tests__/Vocal.test.jsx +0 -748
- package/src/components/__tests__/VocalWithMockedUseVocal.test.jsx +0 -38
- package/src/components/__tests__/__snapshots__/Icon.test.jsx.snap +0 -21
- package/src/components/__tests__/__snapshots__/Vocal.test.jsx.snap +0 -28
- package/src/hooks/__tests__/useCommands.test.js +0 -115
- package/src/hooks/__tests__/useTimeout.test.js +0 -69
- package/src/hooks/__tests__/useVocal.test.js +0 -207
- package/src/hooks/useCommands.js +0 -75
- package/src/hooks/useTimeout.js +0 -21
- package/src/hooks/useVocal.js +0 -56
- package/src/index.js +0 -7
- package/vite.config.js +0 -36
- package/vitest.setup.js +0 -83
package/src/components/Vocal.jsx
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
import React, { cloneElement, isValidElement, useCallback, useMemo, useRef, useState } from 'react'
|
|
2
|
-
import { Vocal as SpeechRecognitionWrapper } from '@untemps/vocal'
|
|
3
|
-
import { isFunction } from '@untemps/utils/function/isFunction'
|
|
4
|
-
|
|
5
|
-
import useVocal from '../hooks/useVocal'
|
|
6
|
-
import useTimeout from '../hooks/useTimeout'
|
|
7
|
-
import useCommands from '../hooks/useCommands'
|
|
8
|
-
|
|
9
|
-
import Icon from './Icon'
|
|
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
|
-
|
|
19
|
-
const Vocal = ({
|
|
20
|
-
children,
|
|
21
|
-
commands = null,
|
|
22
|
-
lang = 'en-US',
|
|
23
|
-
grammars = null,
|
|
24
|
-
timeout = 3000,
|
|
25
|
-
silenceTimeout = null,
|
|
26
|
-
precision = 0.4, // Fuse.js score threshold for phrase commands only; single-word commands always use exact lookup
|
|
27
|
-
maxAlternatives = 1,
|
|
28
|
-
continuous = false,
|
|
29
|
-
ariaLabel = 'start recognition',
|
|
30
|
-
style = null,
|
|
31
|
-
className = null,
|
|
32
|
-
outlineStyle = '2px solid',
|
|
33
|
-
onStart = null,
|
|
34
|
-
onEnd = null,
|
|
35
|
-
onSpeechStart = null,
|
|
36
|
-
onSpeechEnd = null,
|
|
37
|
-
onResult = null,
|
|
38
|
-
onError = null,
|
|
39
|
-
onNoMatch = null,
|
|
40
|
-
__rsInstance,
|
|
41
|
-
}) => {
|
|
42
|
-
const buttonRef = useRef(null)
|
|
43
|
-
const [isListening, setIsListening] = useState(false)
|
|
44
|
-
|
|
45
|
-
const [, { start, stop, subscribe, unsubscribe }] = useVocal(lang, grammars, maxAlternatives, continuous, __rsInstance)
|
|
46
|
-
const triggerCommand = useCommands(commands, precision)
|
|
47
|
-
|
|
48
|
-
const propsRef = useRef({})
|
|
49
|
-
propsRef.current = { onStart, onEnd, onSpeechStart, onSpeechEnd, onResult, onError, onNoMatch }
|
|
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
|
-
|
|
57
|
-
const triggerCommandRef = useRef(triggerCommand)
|
|
58
|
-
triggerCommandRef.current = triggerCommand
|
|
59
|
-
|
|
60
|
-
const unsubscribeAllRef = useRef(null)
|
|
61
|
-
const onEndRef = useRef(null)
|
|
62
|
-
|
|
63
|
-
const silenceTimeoutRef = useRef(silenceTimeout)
|
|
64
|
-
silenceTimeoutRef.current = silenceTimeout
|
|
65
|
-
|
|
66
|
-
// Breaks the circular dep: _onEnd → useTimeout(handler) → startTimer captures _onEnd
|
|
67
|
-
const stableTimerCb = useCallback(() => onEndRef.current?.(), [])
|
|
68
|
-
const [startTimer, stopTimer] = useTimeout(stableTimerCb, timeout)
|
|
69
|
-
const [startSilenceTimer, stopSilenceTimer] = useTimeout(stableTimerCb, silenceTimeout ?? 0)
|
|
70
|
-
|
|
71
|
-
const stopRecognition = useCallback(() => {
|
|
72
|
-
try {
|
|
73
|
-
setIsListening(false)
|
|
74
|
-
stop()
|
|
75
|
-
} catch (error) {
|
|
76
|
-
propsRef.current.onError?.(error)
|
|
77
|
-
unsubscribeAllRef.current?.()
|
|
78
|
-
}
|
|
79
|
-
}, [stop])
|
|
80
|
-
|
|
81
|
-
const _onStart = useCallback(
|
|
82
|
-
(e) => {
|
|
83
|
-
startTimer()
|
|
84
|
-
propsRef.current.onStart?.(e)
|
|
85
|
-
},
|
|
86
|
-
[startTimer]
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
const _onSpeechStart = useCallback(
|
|
90
|
-
(e) => {
|
|
91
|
-
stopTimer()
|
|
92
|
-
propsRef.current.onSpeechStart?.(e)
|
|
93
|
-
},
|
|
94
|
-
[stopTimer]
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
const _onSpeechEnd = useCallback(
|
|
98
|
-
(e) => {
|
|
99
|
-
startTimer()
|
|
100
|
-
propsRef.current.onSpeechEnd?.(e)
|
|
101
|
-
},
|
|
102
|
-
[startTimer]
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
const _onResult = useCallback(
|
|
106
|
-
(event) => {
|
|
107
|
-
const segmentData = Array.from(event?.results ?? [], (segment) => {
|
|
108
|
-
let best = { confidence: -Infinity, transcript: '' }
|
|
109
|
-
const alternatives = []
|
|
110
|
-
for (let j = 0; j < segment.length; j++) {
|
|
111
|
-
const alt = segment[j]
|
|
112
|
-
alternatives.push(alt.transcript ?? '')
|
|
113
|
-
if (alt.confidence === undefined || alt.confidence > best.confidence) {
|
|
114
|
-
best = alt
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
return { best: best.transcript ?? '', alternatives }
|
|
118
|
-
})
|
|
119
|
-
const transcript = segmentData.map((s) => s.best).join('')
|
|
120
|
-
|
|
121
|
-
stopTimer()
|
|
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
|
-
}
|
|
132
|
-
},
|
|
133
|
-
[stopTimer, startSilenceTimer, stopRecognition]
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
const _onError = useCallback(
|
|
137
|
-
(error) => {
|
|
138
|
-
stopRecognition()
|
|
139
|
-
propsRef.current.onError?.(error)
|
|
140
|
-
},
|
|
141
|
-
[stopRecognition]
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
const _onNoMatch = useCallback(
|
|
145
|
-
(e) => {
|
|
146
|
-
stopTimer()
|
|
147
|
-
stopRecognition()
|
|
148
|
-
propsRef.current.onNoMatch?.(e)
|
|
149
|
-
},
|
|
150
|
-
[stopTimer, stopRecognition]
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
const _onEnd = useCallback(
|
|
154
|
-
(e) => {
|
|
155
|
-
stopTimer()
|
|
156
|
-
stopSilenceTimer()
|
|
157
|
-
try {
|
|
158
|
-
stopRecognition()
|
|
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
|
-
}
|
|
165
|
-
} finally {
|
|
166
|
-
propsRef.current.onEnd?.(e)
|
|
167
|
-
}
|
|
168
|
-
},
|
|
169
|
-
[stopTimer, stopSilenceTimer, stopRecognition]
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
onEndRef.current = _onEnd
|
|
173
|
-
|
|
174
|
-
const HANDLERS = useMemo(
|
|
175
|
-
() => ({
|
|
176
|
-
start: _onStart,
|
|
177
|
-
end: _onEnd,
|
|
178
|
-
speechstart: _onSpeechStart,
|
|
179
|
-
speechend: _onSpeechEnd,
|
|
180
|
-
result: _onResult,
|
|
181
|
-
error: _onError,
|
|
182
|
-
nomatch: _onNoMatch,
|
|
183
|
-
}),
|
|
184
|
-
[_onStart, _onEnd, _onSpeechStart, _onSpeechEnd, _onResult, _onError, _onNoMatch]
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
// Assigned inline (not in useEffect) so it's ready before any event fires
|
|
188
|
-
unsubscribeAllRef.current = () => Object.entries(HANDLERS).forEach(([event, fn]) => unsubscribe?.(event, fn))
|
|
189
|
-
|
|
190
|
-
const startRecognition = useCallback(() => {
|
|
191
|
-
try {
|
|
192
|
-
accumulatedRef.current.transcript = ''
|
|
193
|
-
accumulatedRef.current.event = null
|
|
194
|
-
stopSilenceTimer()
|
|
195
|
-
setIsListening(true)
|
|
196
|
-
Object.entries(HANDLERS).forEach(([event, fn]) => subscribe(event, fn))
|
|
197
|
-
start()
|
|
198
|
-
} catch (error) {
|
|
199
|
-
_onError(error)
|
|
200
|
-
}
|
|
201
|
-
}, [HANDLERS, subscribe, start, stopSilenceTimer, _onError])
|
|
202
|
-
|
|
203
|
-
const _onFocus = () => {
|
|
204
|
-
if (!className && outlineStyle) {
|
|
205
|
-
buttonRef.current.style.outline = outlineStyle
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const _onBlur = () => {
|
|
210
|
-
if (!className && outlineStyle) {
|
|
211
|
-
buttonRef.current.style.outline = 'none'
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const _renderDefault = () => (
|
|
216
|
-
<button
|
|
217
|
-
data-testid="__vocal-root__"
|
|
218
|
-
ref={buttonRef}
|
|
219
|
-
aria-label={ariaLabel}
|
|
220
|
-
aria-pressed={isListening}
|
|
221
|
-
style={
|
|
222
|
-
className
|
|
223
|
-
? null
|
|
224
|
-
: {
|
|
225
|
-
width: 24,
|
|
226
|
-
height: 24,
|
|
227
|
-
backgroundColor: 'transparent', // `background: none` shorthand resets all sub-properties; jsdom 29 + jest-dom v6 don't reflect that correctly via getComputedStyle
|
|
228
|
-
border: 'none',
|
|
229
|
-
padding: 0,
|
|
230
|
-
cursor: !continuous && isListening ? 'default' : 'pointer',
|
|
231
|
-
...style,
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
className={className}
|
|
235
|
-
onFocus={_onFocus}
|
|
236
|
-
onBlur={_onBlur}
|
|
237
|
-
onClick={isListening ? stopRecognition : startRecognition}
|
|
238
|
-
>
|
|
239
|
-
<Icon isActive={isListening} color="#aaa" />
|
|
240
|
-
</button>
|
|
241
|
-
)
|
|
242
|
-
|
|
243
|
-
const _renderChildren = () => {
|
|
244
|
-
if (SpeechRecognitionWrapper.isSupported) {
|
|
245
|
-
if (isFunction(children)) {
|
|
246
|
-
return children(startRecognition, stopRecognition, isListening)
|
|
247
|
-
} else if (isValidElement(children)) {
|
|
248
|
-
return cloneElement(children, {
|
|
249
|
-
...(!isListening && { onClick: startRecognition }),
|
|
250
|
-
})
|
|
251
|
-
} else {
|
|
252
|
-
return _renderDefault()
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
return null
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
return _renderChildren()
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
export default Vocal
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { render } from '@testing-library/react'
|
|
3
|
-
|
|
4
|
-
import Icon from '../Icon'
|
|
5
|
-
|
|
6
|
-
const defaultProps = {}
|
|
7
|
-
const getInstance = (props = {}) => <Icon {...defaultProps} {...props} />
|
|
8
|
-
|
|
9
|
-
describe('Icon', () => {
|
|
10
|
-
it('matches snapshot', () => {
|
|
11
|
-
const { asFragment } = render(getInstance())
|
|
12
|
-
expect(asFragment()).toMatchSnapshot()
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
it('renders component', () => {
|
|
16
|
-
const { queryByTestId } = render(getInstance())
|
|
17
|
-
expect(queryByTestId('__icon-root__')).toBeInTheDocument()
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
it('renders component color', () => {
|
|
21
|
-
const color = 'green'
|
|
22
|
-
const { queryByTestId } = render(getInstance({ color }))
|
|
23
|
-
expect(queryByTestId('__icon-path__')).toHaveAttribute('fill', color)
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
it('renders active component', () => {
|
|
27
|
-
const isActive = true
|
|
28
|
-
const { queryByTestId } = render(getInstance({ isActive }))
|
|
29
|
-
expect(queryByTestId('__icon-active__')).toBeInTheDocument()
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it('renders active component color', () => {
|
|
33
|
-
const isActive = true
|
|
34
|
-
const activeColor = 'blue'
|
|
35
|
-
const { queryByTestId } = render(getInstance({ isActive, activeColor }))
|
|
36
|
-
expect(queryByTestId('__icon-active__')).toHaveAttribute('fill', activeColor)
|
|
37
|
-
})
|
|
38
|
-
})
|