@untemps/react-vocal 2.0.0-beta.1 → 2.0.0-beta.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/.prettierrc +1 -1
- package/CHANGELOG.md +14 -0
- package/dev/yarn.lock +296 -172
- package/dist/index.es.js +319 -277
- 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 +114 -65
- package/src/components/__tests__/Vocal.test.jsx +206 -0
- package/vitest.setup.js +6 -3
package/package.json
CHANGED
package/src/components/Vocal.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { cloneElement, isValidElement, useRef, useState } from 'react'
|
|
1
|
+
import React, { cloneElement, isValidElement, useCallback, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { Vocal as SpeechRecognitionWrapper } from '@untemps/vocal'
|
|
3
3
|
import { isFunction } from '@untemps/utils/function/isFunction'
|
|
4
4
|
|
|
@@ -33,33 +33,128 @@ const Vocal = ({
|
|
|
33
33
|
const [, { start, stop, subscribe, unsubscribe }] = useVocal(lang, grammars, __rsInstance)
|
|
34
34
|
const triggerCommand = useCommands(commands)
|
|
35
35
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
const propsRef = useRef({})
|
|
37
|
+
propsRef.current = { onStart, onEnd, onSpeechStart, onSpeechEnd, onResult, onError, onNoMatch }
|
|
38
|
+
|
|
39
|
+
const triggerCommandRef = useRef(triggerCommand)
|
|
40
|
+
triggerCommandRef.current = triggerCommand
|
|
41
|
+
|
|
42
|
+
const unsubscribeAllRef = useRef(null)
|
|
43
|
+
const onEndRef = useRef(null)
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
// Breaks the circular dep: _onEnd → useTimeout(handler) → startTimer captures _onEnd
|
|
46
|
+
const stableTimerCb = useCallback(() => onEndRef.current?.(), [])
|
|
47
|
+
const [startTimer, stopTimer] = useTimeout(stableTimerCb, timeout)
|
|
44
48
|
|
|
45
|
-
const
|
|
49
|
+
const stopRecognition = useCallback(() => {
|
|
46
50
|
try {
|
|
47
|
-
setIsListening(
|
|
48
|
-
|
|
49
|
-
start()
|
|
51
|
+
setIsListening(false)
|
|
52
|
+
stop()
|
|
50
53
|
} catch (error) {
|
|
51
|
-
|
|
54
|
+
propsRef.current.onError?.(error)
|
|
55
|
+
} finally {
|
|
56
|
+
unsubscribeAllRef.current?.()
|
|
52
57
|
}
|
|
53
|
-
}
|
|
58
|
+
}, [stop])
|
|
59
|
+
|
|
60
|
+
const _onStart = useCallback(
|
|
61
|
+
(e) => {
|
|
62
|
+
startTimer()
|
|
63
|
+
propsRef.current.onStart?.(e)
|
|
64
|
+
},
|
|
65
|
+
[startTimer]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const _onSpeechStart = useCallback(
|
|
69
|
+
(e) => {
|
|
70
|
+
stopTimer()
|
|
71
|
+
propsRef.current.onSpeechStart?.(e)
|
|
72
|
+
},
|
|
73
|
+
[stopTimer]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const _onSpeechEnd = useCallback(
|
|
77
|
+
(e) => {
|
|
78
|
+
startTimer()
|
|
79
|
+
propsRef.current.onSpeechEnd?.(e)
|
|
80
|
+
},
|
|
81
|
+
[startTimer]
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const _onResult = useCallback(
|
|
85
|
+
(event) => {
|
|
86
|
+
const transcript = Array.from(event?.results ?? [], (segment) => {
|
|
87
|
+
let best = { confidence: -Infinity, transcript: '' }
|
|
88
|
+
for (let j = 0; j < segment.length; j++) {
|
|
89
|
+
const alt = segment[j]
|
|
90
|
+
if (alt.confidence === undefined || alt.confidence > best.confidence) {
|
|
91
|
+
best = alt
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return best.transcript ?? ''
|
|
95
|
+
}).join('')
|
|
96
|
+
|
|
97
|
+
stopTimer()
|
|
98
|
+
stopRecognition()
|
|
99
|
+
triggerCommandRef.current(transcript)
|
|
100
|
+
propsRef.current.onResult?.(transcript, event)
|
|
101
|
+
},
|
|
102
|
+
[stopTimer, stopRecognition]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const _onError = useCallback(
|
|
106
|
+
(error) => {
|
|
107
|
+
stopRecognition()
|
|
108
|
+
propsRef.current.onError?.(error)
|
|
109
|
+
},
|
|
110
|
+
[stopRecognition]
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const _onNoMatch = useCallback(
|
|
114
|
+
(e) => {
|
|
115
|
+
stopTimer()
|
|
116
|
+
stopRecognition()
|
|
117
|
+
propsRef.current.onNoMatch?.(e)
|
|
118
|
+
},
|
|
119
|
+
[stopTimer, stopRecognition]
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const _onEnd = useCallback(
|
|
123
|
+
(e) => {
|
|
124
|
+
stopTimer()
|
|
125
|
+
stopRecognition()
|
|
126
|
+
propsRef.current.onEnd?.(e)
|
|
127
|
+
},
|
|
128
|
+
[stopTimer, stopRecognition]
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
onEndRef.current = _onEnd
|
|
132
|
+
|
|
133
|
+
const HANDLERS = useMemo(
|
|
134
|
+
() => ({
|
|
135
|
+
start: _onStart,
|
|
136
|
+
end: _onEnd,
|
|
137
|
+
speechstart: _onSpeechStart,
|
|
138
|
+
speechend: _onSpeechEnd,
|
|
139
|
+
result: _onResult,
|
|
140
|
+
error: _onError,
|
|
141
|
+
nomatch: _onNoMatch,
|
|
142
|
+
}),
|
|
143
|
+
[_onStart, _onEnd, _onSpeechStart, _onSpeechEnd, _onResult, _onError, _onNoMatch]
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
// Assigned inline (not in useEffect) so it's ready before any event fires
|
|
147
|
+
unsubscribeAllRef.current = () => Object.entries(HANDLERS).forEach(([event, fn]) => unsubscribe?.(event, fn))
|
|
54
148
|
|
|
55
|
-
const
|
|
149
|
+
const startRecognition = useCallback(() => {
|
|
56
150
|
try {
|
|
57
|
-
setIsListening(
|
|
58
|
-
|
|
151
|
+
setIsListening(true)
|
|
152
|
+
Object.entries(HANDLERS).forEach(([event, fn]) => subscribe(event, fn))
|
|
153
|
+
start()
|
|
59
154
|
} catch (error) {
|
|
60
|
-
|
|
155
|
+
_onError(error)
|
|
61
156
|
}
|
|
62
|
-
}
|
|
157
|
+
}, [HANDLERS, subscribe, start, _onError])
|
|
63
158
|
|
|
64
159
|
const _onFocus = () => {
|
|
65
160
|
if (!className && outlineStyle) {
|
|
@@ -73,52 +168,6 @@ const Vocal = ({
|
|
|
73
168
|
}
|
|
74
169
|
}
|
|
75
170
|
|
|
76
|
-
const _onStart = (e) => {
|
|
77
|
-
startTimer()
|
|
78
|
-
onStart?.(e)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const _onSpeechStart = (e) => {
|
|
82
|
-
stopTimer()
|
|
83
|
-
onSpeechStart?.(e)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const _onSpeechEnd = (e) => {
|
|
87
|
-
startTimer()
|
|
88
|
-
onSpeechEnd?.(e)
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const _onResult = (event, result) => {
|
|
92
|
-
stopTimer()
|
|
93
|
-
stopRecognition()
|
|
94
|
-
triggerCommand(result)
|
|
95
|
-
onResult?.(result, event)
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const _onError = (error) => {
|
|
99
|
-
stopRecognition()
|
|
100
|
-
onError?.(error)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const _onNoMatch = (e) => {
|
|
104
|
-
stopTimer()
|
|
105
|
-
stopRecognition()
|
|
106
|
-
onNoMatch?.(e)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const HANDLERS = {
|
|
110
|
-
start: _onStart,
|
|
111
|
-
end: _onEnd,
|
|
112
|
-
speechstart: _onSpeechStart,
|
|
113
|
-
speechend: _onSpeechEnd,
|
|
114
|
-
result: _onResult,
|
|
115
|
-
error: _onError,
|
|
116
|
-
nomatch: _onNoMatch,
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const subscribeAll = () => Object.entries(HANDLERS).forEach(([event, handler]) => subscribe(event, handler))
|
|
120
|
-
const unsubscribeAll = () => Object.entries(HANDLERS).forEach(([event, handler]) => unsubscribe(event, handler))
|
|
121
|
-
|
|
122
171
|
const _renderDefault = () => (
|
|
123
172
|
<button
|
|
124
173
|
data-testid="__vocal-root__"
|
|
@@ -267,4 +267,210 @@ describe('Vocal', () => {
|
|
|
267
267
|
await waitFor(() => expect(onEnd).toHaveBeenCalled())
|
|
268
268
|
})
|
|
269
269
|
})
|
|
270
|
+
|
|
271
|
+
it('calls the updated onEnd prop after a re-render during an active session', async () => {
|
|
272
|
+
const onEndV1 = vi.fn()
|
|
273
|
+
const onEndV2 = vi.fn()
|
|
274
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
275
|
+
const { getByTestId, rerender } = render(getInstance({ __rsInstance: recognition, onEnd: onEndV1 }))
|
|
276
|
+
|
|
277
|
+
await act(async () => {
|
|
278
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
279
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'default' }))
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
await act(async () => {
|
|
283
|
+
rerender(getInstance({ __rsInstance: recognition, onEnd: onEndV2 }))
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
await act(async () => {
|
|
287
|
+
recognition.instance.say('Foo')
|
|
288
|
+
await waitFor(() => expect(onEndV2).toHaveBeenCalled())
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
expect(onEndV1).not.toHaveBeenCalled()
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('resets to idle after a re-render during an active session', async () => {
|
|
295
|
+
const onEnd = vi.fn()
|
|
296
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
297
|
+
const { getByTestId, rerender } = render(getInstance({ __rsInstance: recognition, onEnd }))
|
|
298
|
+
|
|
299
|
+
await act(async () => {
|
|
300
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
301
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'default' }))
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
await act(async () => {
|
|
305
|
+
rerender(getInstance({ __rsInstance: recognition, onEnd }))
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
await act(async () => {
|
|
309
|
+
recognition.instance.say('Foo')
|
|
310
|
+
await waitFor(() => expect(onEnd).toHaveBeenCalled())
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'pointer' })
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('calls the updated onResult prop after a re-render during an active session', async () => {
|
|
317
|
+
const onResultV1 = vi.fn()
|
|
318
|
+
const onResultV2 = vi.fn()
|
|
319
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
320
|
+
const { getByTestId, rerender } = render(getInstance({ __rsInstance: recognition, onResult: onResultV1 }))
|
|
321
|
+
|
|
322
|
+
await act(async () => {
|
|
323
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
324
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'default' }))
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
await act(async () => {
|
|
328
|
+
rerender(getInstance({ __rsInstance: recognition, onResult: onResultV2 }))
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
await act(async () => {
|
|
332
|
+
recognition.instance.say('Foo')
|
|
333
|
+
await waitFor(() => expect(onResultV2).toHaveBeenCalledWith('Foo', expect.anything()))
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
expect(onResultV1).not.toHaveBeenCalled()
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('calls the updated onSpeechStart prop after a re-render during an active session', async () => {
|
|
340
|
+
const onSpeechStartV1 = vi.fn()
|
|
341
|
+
const onSpeechStartV2 = vi.fn()
|
|
342
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
343
|
+
const { getByTestId, rerender } = render(getInstance({ __rsInstance: recognition, onSpeechStart: onSpeechStartV1 }))
|
|
344
|
+
|
|
345
|
+
await act(async () => {
|
|
346
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
347
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'default' }))
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
await act(async () => {
|
|
351
|
+
rerender(getInstance({ __rsInstance: recognition, onSpeechStart: onSpeechStartV2 }))
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
await act(async () => {
|
|
355
|
+
recognition.instance.say('Foo')
|
|
356
|
+
await waitFor(() => expect(onSpeechStartV2).toHaveBeenCalled())
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
expect(onSpeechStartV1).not.toHaveBeenCalled()
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('calls the updated onSpeechEnd prop after a re-render during an active session', async () => {
|
|
363
|
+
const onSpeechEndV1 = vi.fn()
|
|
364
|
+
const onSpeechEndV2 = vi.fn()
|
|
365
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
366
|
+
const { getByTestId, rerender } = render(getInstance({ __rsInstance: recognition, onSpeechEnd: onSpeechEndV1 }))
|
|
367
|
+
|
|
368
|
+
await act(async () => {
|
|
369
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
370
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'default' }))
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
await act(async () => {
|
|
374
|
+
rerender(getInstance({ __rsInstance: recognition, onSpeechEnd: onSpeechEndV2 }))
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
await act(async () => {
|
|
378
|
+
recognition.instance.say('Foo')
|
|
379
|
+
await waitFor(() => expect(onSpeechEndV2).toHaveBeenCalled())
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
expect(onSpeechEndV1).not.toHaveBeenCalled()
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('calls the updated onNoMatch prop after a re-render during an active session', async () => {
|
|
386
|
+
const onNoMatchV1 = vi.fn()
|
|
387
|
+
const onNoMatchV2 = vi.fn()
|
|
388
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
389
|
+
const { getByTestId, rerender } = render(getInstance({ __rsInstance: recognition, onNoMatch: onNoMatchV1 }))
|
|
390
|
+
|
|
391
|
+
await act(async () => {
|
|
392
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
393
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'default' }))
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
await act(async () => {
|
|
397
|
+
rerender(getInstance({ __rsInstance: recognition, onNoMatch: onNoMatchV2 }))
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
await act(async () => {
|
|
401
|
+
recognition.instance.say(null)
|
|
402
|
+
await waitFor(() => expect(onNoMatchV2).toHaveBeenCalled())
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
expect(onNoMatchV1).not.toHaveBeenCalled()
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('calls the updated onError prop after a re-render during an active session', async () => {
|
|
409
|
+
const onErrorV1 = vi.fn()
|
|
410
|
+
const onErrorV2 = vi.fn()
|
|
411
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
412
|
+
const { getByTestId, rerender } = render(getInstance({ __rsInstance: recognition, onError: onErrorV1 }))
|
|
413
|
+
|
|
414
|
+
await act(async () => {
|
|
415
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
416
|
+
await waitFor(() => expect(getByTestId('__vocal-root__')).toHaveStyle({ cursor: 'default' }))
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
await act(async () => {
|
|
420
|
+
rerender(getInstance({ __rsInstance: recognition, onError: onErrorV2 }))
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
await act(async () => {
|
|
424
|
+
recognition.instance.error(new Error('mic failure'))
|
|
425
|
+
await waitFor(() => expect(onErrorV2).toHaveBeenCalled())
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
expect(onErrorV1).not.toHaveBeenCalled()
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('returns the most confident alternative when multiple alternatives are provided', async () => {
|
|
432
|
+
const onResult = vi.fn()
|
|
433
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
434
|
+
const { getByTestId } = render(getInstance({ __rsInstance: recognition, onResult }))
|
|
435
|
+
|
|
436
|
+
await act(async () => {
|
|
437
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
438
|
+
recognition.instance.say([[
|
|
439
|
+
{ transcript: 'bar', confidence: 0.4 },
|
|
440
|
+
{ transcript: 'foo', confidence: 0.9 },
|
|
441
|
+
{ transcript: 'baz', confidence: 0.1 },
|
|
442
|
+
]])
|
|
443
|
+
await waitFor(() => expect(onResult).toHaveBeenCalledWith('foo', expect.anything()))
|
|
444
|
+
})
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('joins all segments when multiple result segments are provided', async () => {
|
|
448
|
+
const onResult = vi.fn()
|
|
449
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
450
|
+
const { getByTestId } = render(getInstance({ __rsInstance: recognition, onResult }))
|
|
451
|
+
|
|
452
|
+
await act(async () => {
|
|
453
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
454
|
+
recognition.instance.say([
|
|
455
|
+
[{ transcript: 'hello ', confidence: 0.9 }],
|
|
456
|
+
[{ transcript: 'world', confidence: 0.8 }],
|
|
457
|
+
])
|
|
458
|
+
await waitFor(() => expect(onResult).toHaveBeenCalledWith('hello world', expect.anything()))
|
|
459
|
+
})
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('picks highest-confidence alternative per segment when multi-segment with multi-alternative', async () => {
|
|
463
|
+
const onResult = vi.fn()
|
|
464
|
+
const recognition = new SpeechRecognitionWrapper()
|
|
465
|
+
const { getByTestId } = render(getInstance({ __rsInstance: recognition, onResult }))
|
|
466
|
+
|
|
467
|
+
await act(async () => {
|
|
468
|
+
fireEvent.click(getByTestId('__vocal-root__'))
|
|
469
|
+
recognition.instance.say([
|
|
470
|
+
[{ transcript: 'good ', confidence: 0.8 }, { transcript: 'bad ', confidence: 0.2 }],
|
|
471
|
+
[{ transcript: 'day', confidence: 0.95 }, { transcript: 'dey', confidence: 0.3 }],
|
|
472
|
+
])
|
|
473
|
+
await waitFor(() => expect(onResult).toHaveBeenCalledWith('good day', expect.anything()))
|
|
474
|
+
})
|
|
475
|
+
})
|
|
270
476
|
})
|
package/vitest.setup.js
CHANGED
|
@@ -54,18 +54,21 @@ global.SpeechRecognition = vi.fn(function () {
|
|
|
54
54
|
abort: vi.fn(function () {
|
|
55
55
|
handlers.end?.()
|
|
56
56
|
}),
|
|
57
|
-
say: vi.fn(function (
|
|
57
|
+
say: vi.fn(function (input) {
|
|
58
58
|
handlers.speechstart?.()
|
|
59
59
|
|
|
60
60
|
const resultEvent = new Event('result')
|
|
61
61
|
resultEvent.resultIndex = 0
|
|
62
|
-
resultEvent.results = [[{ transcript:
|
|
62
|
+
resultEvent.results = Array.isArray(input) ? input : input ? [[{ transcript: input }]] : []
|
|
63
63
|
handlers.speechend?.()
|
|
64
|
-
if (
|
|
64
|
+
if (input) {
|
|
65
65
|
handlers.result?.(resultEvent)
|
|
66
66
|
} else {
|
|
67
67
|
handlers.nomatch?.()
|
|
68
68
|
}
|
|
69
69
|
}),
|
|
70
|
+
error: vi.fn(function (err) {
|
|
71
|
+
handlers.error?.(err)
|
|
72
|
+
}),
|
|
70
73
|
}
|
|
71
74
|
})
|