@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@untemps/react-vocal",
3
- "version": "2.0.0-beta.5",
3
+ "version": "2.0.0-beta.6",
4
4
  "author": "Vincent Le Badezet <v.lebadezet@untemps.net>",
5
5
  "repository": "git@github.com:untemps/react-vocal.git",
6
6
  "license": "MIT",
@@ -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
- stopRecognition()
111
- tryMatchCommand(segmentData, triggerCommandRef.current)
112
- propsRef.current.onResult?.(transcript, event)
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 ? 'pointer' : 'default',
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__')).toHaveStyle({ cursor: 'default' }))
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__')).toHaveStyle({ cursor: 'default' }))
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__')).toHaveStyle({ cursor: 'pointer' })
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__')).toHaveStyle({ cursor: 'default' }))
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__')).toHaveStyle({ cursor: 'default' }))
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__')).toHaveStyle({ cursor: 'default' }))
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__')).toHaveStyle({ cursor: 'default' }))
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__')).toHaveStyle({ cursor: 'default' }))
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({ lang: 'en-US', grammars: null, maxAlternatives: 5 })
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
 
@@ -38,33 +38,33 @@ const useCommands = (commands, precision = 0.4) => {
38
38
  })
39
39
  }, [hasPhraseKeys, keys])
40
40
 
41
- const triggerCommand = (input) => {
41
+ const triggerCommand = (rawInput) => {
42
42
  if (!keys.length) return null
43
43
 
44
44
  if (!hasPhraseKeys) {
45
- const words = input.trim().split(/\s+/)
46
- const targets = words.length > 1 ? words : [input.trim()]
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 key = w.toLowerCase()
49
- if (key in normalized) return normalized[key]?.(w)
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(input).filter((r) => r.score < precision)
56
+ const result = fuse.search(rawInput).filter((r) => r.score < precision)
57
57
  if (result?.length) {
58
- const key = result[0].item.toLowerCase()
59
- return normalized[key]?.(input)
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 = input.toLowerCase()
66
- const match = keys.find((k) => lInput.includes(k) || k.includes(lInput))
67
- if (match) return normalized[match]?.(input)
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
  }
@@ -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 = 0
62
- resultEvent.results = Array.isArray(input) ? input : input ? [[{ transcript: input }]] : []
67
+ resultEvent.resultIndex = resultIndex
68
+ resultEvent.results = accumulatedResults
63
69
  handlers.speechend?.()
64
70
  if (input) {
65
71
  handlers.result?.(resultEvent)