@nualang/nualang-ui-components 0.1.1420 → 0.1.1421
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/README.md +31 -0
- package/dist/Chat/BottomBar/BottomBar.js +23 -10
- package/dist/Chat/Chat.js +2 -0
- package/dist/Exercises/Bot/Bot.js +1 -0
- package/dist/Exercises/ListenAndSpeak/ListenAndSpeak.js +18 -8
- package/dist/Exercises/Pronouncer/Pronouncer.js +18 -8
- package/dist/Exercises/Roleplay/Games/ActItOutGameListeningAndSpeaking/ActItOutGame.js +18 -8
- package/dist/Exercises/Roleplay/Games/ActItOutGameSpeaking/ActItOutGame.js +18 -9
- package/dist/hooks/useRecognition.js +208 -37
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -35,6 +35,37 @@ If webapp is caching previous versions (Add a character to the end of the .tgz,
|
|
|
35
35
|
pnpm add ../ui-components/nualang-ui-components-0.1.XXXa.tgz
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
## Local Development with `npm link`
|
|
39
|
+
Links this repo directly into `app`, so changes here show up instantly with no
|
|
40
|
+
repacking or reinstalling. Requires both repos cloned as sibling folders
|
|
41
|
+
(`ui-components` and `app` side by side, same parent directory).
|
|
42
|
+
|
|
43
|
+
1. **Here in `ui-components`**, start the watcher (it cleans `dist/` before its first
|
|
44
|
+
compile, so it's always safe to run - no separate build step needed):
|
|
45
|
+
```bash
|
|
46
|
+
pnpm run watch # leave this running
|
|
47
|
+
```
|
|
48
|
+
2. **In `app`**, link it in and start the dev server:
|
|
49
|
+
```bash
|
|
50
|
+
npm run link:ui-components
|
|
51
|
+
npm start
|
|
52
|
+
```
|
|
53
|
+
3. Edit a component here under `src/` and save - it recompiles automatically, `app`'s
|
|
54
|
+
dev server rebuilds, and a browser reload shows your change.
|
|
55
|
+
4. **In `app`** when you're done, switch back to the published version:
|
|
56
|
+
```bash
|
|
57
|
+
npm run unlink:ui-components
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**If you delete or rename a file while the watcher is already running**, restart
|
|
61
|
+
`pnpm run watch`. Babel's `--watch` mode only ever adds/updates compiled output - it
|
|
62
|
+
never removes a stale file from `dist/` for a source file that's gone, which can leave
|
|
63
|
+
`app` resolving a module that no longer exists in `src/`. Restarting re-runs the clean
|
|
64
|
+
step and fixes it.
|
|
65
|
+
|
|
66
|
+
Heads up: an `npm install` in `app` will quietly replace the link with the published
|
|
67
|
+
version - just re-run `npm run link:ui-components` to relink.
|
|
68
|
+
|
|
38
69
|
## Publish to npm
|
|
39
70
|
Normall, you should just update the version number in package.json and commit
|
|
40
71
|
your updates. When you updated are merged to main, gitlab CI/CD will add a
|
|
@@ -9,6 +9,7 @@ import Tooltip from "@mui/material/Tooltip";
|
|
|
9
9
|
import Divider from "@mui/material/Divider";
|
|
10
10
|
import Paper from "@mui/material/Paper";
|
|
11
11
|
import Typography from "@mui/material/Typography";
|
|
12
|
+
import CircularProgress from "@mui/material/CircularProgress";
|
|
12
13
|
import MicIcon from "@mui/icons-material/Mic";
|
|
13
14
|
import MicOffIcon from "@mui/icons-material/MicOff";
|
|
14
15
|
import SendIcon from "@mui/icons-material/Send";
|
|
@@ -81,6 +82,7 @@ export default function BottomBar({
|
|
|
81
82
|
handleKeyPress,
|
|
82
83
|
handleUpdate,
|
|
83
84
|
isListening,
|
|
85
|
+
isMicBusy = false,
|
|
84
86
|
handleAttachFile,
|
|
85
87
|
handleRecord,
|
|
86
88
|
handleAdd,
|
|
@@ -131,6 +133,11 @@ export default function BottomBar({
|
|
|
131
133
|
const textInput = useRef(null);
|
|
132
134
|
const activeRef = inputRef ? inputRef : textInput;
|
|
133
135
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
|
|
136
|
+
|
|
137
|
+
// isMicBusy spans the async mic start/stop window (permission prompt,
|
|
138
|
+
// audio worklet setup, socket teardown) - without this the button just
|
|
139
|
+
// goes inert with no explanation.
|
|
140
|
+
const micButtonLabel = isMicBusy ? t("loading") : isListening ? t("click_to_stop") : t("click_to_speak");
|
|
134
141
|
const TextInput = /*#__PURE__*/_jsx("div", {
|
|
135
142
|
className: classes.input,
|
|
136
143
|
id: "keyboardInput",
|
|
@@ -236,7 +243,6 @@ export default function BottomBar({
|
|
|
236
243
|
children: isSmallScreen ? /*#__PURE__*/_jsx(_Fragment, {
|
|
237
244
|
children: /*#__PURE__*/_jsx(IconButton, {
|
|
238
245
|
sx: {
|
|
239
|
-
color: isListening ? "red" : "inherit",
|
|
240
246
|
animation: showMicReminder ? "reminder 1.6s ease-in-out infinite" : "none",
|
|
241
247
|
"@keyframes reminder": {
|
|
242
248
|
"0%": {
|
|
@@ -253,24 +259,27 @@ export default function BottomBar({
|
|
|
253
259
|
onClick: onClickRecord,
|
|
254
260
|
edge: "start",
|
|
255
261
|
id: "voiceInput",
|
|
262
|
+
color: isListening ? "error" : "inherit",
|
|
256
263
|
disabled: disableInput,
|
|
264
|
+
"aria-busy": isMicBusy,
|
|
265
|
+
"aria-label": micButtonLabel,
|
|
257
266
|
size: "large",
|
|
258
267
|
ref: buttonRef,
|
|
259
|
-
children:
|
|
268
|
+
children: isMicBusy ? /*#__PURE__*/_jsx(CircularProgress, {
|
|
269
|
+
size: 20,
|
|
270
|
+
color: "inherit"
|
|
271
|
+
}) : isListening ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(MicIcon, {})
|
|
260
272
|
})
|
|
261
273
|
}) : /*#__PURE__*/_jsx(_Fragment, {
|
|
262
274
|
children: /*#__PURE__*/_jsx(Button, {
|
|
263
275
|
sx: {
|
|
264
|
-
color: "white",
|
|
265
|
-
backgroundColor: isListening ? "#d32f2f" : "#2d7b30",
|
|
266
276
|
padding: 1,
|
|
267
277
|
paddingLeft: 2,
|
|
268
278
|
paddingRight: 2,
|
|
269
279
|
borderRadius: 3,
|
|
270
|
-
border: "1.5px solid
|
|
280
|
+
border: "1.5px solid transparent",
|
|
271
281
|
"&:hover": {
|
|
272
|
-
|
|
273
|
-
border: "1.5px solid white"
|
|
282
|
+
border: "1.5px solid black"
|
|
274
283
|
},
|
|
275
284
|
animation: showMicReminder ? "reminder 1.6s ease-in-out infinite" : "none",
|
|
276
285
|
"@keyframes reminder": {
|
|
@@ -286,18 +295,22 @@ export default function BottomBar({
|
|
|
286
295
|
}
|
|
287
296
|
},
|
|
288
297
|
onClick: onClickRecord,
|
|
289
|
-
startIcon:
|
|
298
|
+
startIcon: isMicBusy ? /*#__PURE__*/_jsx(CircularProgress, {
|
|
299
|
+
size: 20,
|
|
300
|
+
color: "inherit"
|
|
301
|
+
}) : isListening ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(MicIcon, {}),
|
|
290
302
|
color: isListening ? "error" : "success",
|
|
291
303
|
disabled: disableInput,
|
|
304
|
+
"aria-busy": isMicBusy,
|
|
292
305
|
variant: "contained",
|
|
293
306
|
id: "voiceInput",
|
|
294
307
|
ref: buttonRef,
|
|
295
|
-
children:
|
|
308
|
+
children: micButtonLabel
|
|
296
309
|
})
|
|
297
310
|
})
|
|
298
311
|
}), /*#__PURE__*/_jsx(Typography, {
|
|
299
312
|
sx: {
|
|
300
|
-
marginLeft: 1.5,
|
|
313
|
+
marginLeft: isSmallScreen ? 0.75 : 1.5,
|
|
301
314
|
marginRight: 1,
|
|
302
315
|
my: "auto",
|
|
303
316
|
fontWeight: "bold"
|
package/dist/Chat/Chat.js
CHANGED
|
@@ -23,6 +23,7 @@ function Chat({
|
|
|
23
23
|
handleBack,
|
|
24
24
|
handleClose,
|
|
25
25
|
handleRecord,
|
|
26
|
+
isMicBusy = false,
|
|
26
27
|
handleAdd,
|
|
27
28
|
handleSend,
|
|
28
29
|
handleUpdate,
|
|
@@ -178,6 +179,7 @@ function Chat({
|
|
|
178
179
|
focusInput: focusInput,
|
|
179
180
|
handleInputChange: handleInputChange,
|
|
180
181
|
isListening: isListening,
|
|
182
|
+
isMicBusy: isMicBusy,
|
|
181
183
|
handleRecord: handleRecord,
|
|
182
184
|
handleSend: handleSend,
|
|
183
185
|
handleAdd: handleAdd,
|
|
@@ -606,6 +606,7 @@ function Bot({
|
|
|
606
606
|
t: t,
|
|
607
607
|
authenticated: authenticated,
|
|
608
608
|
disableInput: !authenticated || isSpeaking || isRecognizingDisabled ? true : false,
|
|
609
|
+
isMicBusy: isRecognizingDisabled,
|
|
609
610
|
messages: botMessages,
|
|
610
611
|
placeholder: !recognizing ? placeholder : "",
|
|
611
612
|
translationEnabled: isTranslationEnabled,
|
|
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react";
|
|
|
2
2
|
import { Joyride, STATUS } from "react-joyride";
|
|
3
3
|
import { makeStyles } from "tss-react/mui";
|
|
4
4
|
import { useTheme } from "@mui/material/styles";
|
|
5
|
-
import { Grid, Slide, IconButton, Button, Toolbar, AppBar, Dialog, Typography, Box, Tooltip } from "@mui/material";
|
|
5
|
+
import { Grid, Slide, IconButton, Button, Toolbar, AppBar, Dialog, Typography, Box, Tooltip, CircularProgress } from "@mui/material";
|
|
6
6
|
import { styled } from "@mui/system";
|
|
7
7
|
import LightbulbIcon from "@mui/icons-material/Lightbulb";
|
|
8
8
|
import Replay from "@mui/icons-material/Replay";
|
|
@@ -227,9 +227,12 @@ function ListenAndSpeak({
|
|
|
227
227
|
});
|
|
228
228
|
}
|
|
229
229
|
if (recognizing && interimTranscript === "" && finalTranscript === "") {
|
|
230
|
-
// Set a timer to stop listening if no audio is detected after 10 seconds
|
|
230
|
+
// Set a timer to stop listening if no audio is detected after 10 seconds.
|
|
231
|
+
// Without the snackbar here the mic just switches off with no
|
|
232
|
+
// explanation, indistinguishable from the student stopping it themselves.
|
|
231
233
|
timer = setTimeout(() => {
|
|
232
234
|
stopListening();
|
|
235
|
+
openSnackbar?.("We didn't hear anything, please try again.", "warning");
|
|
233
236
|
}, 10000);
|
|
234
237
|
}
|
|
235
238
|
return () => clearTimeout(timer);
|
|
@@ -455,6 +458,12 @@ function ListenAndSpeak({
|
|
|
455
458
|
}
|
|
456
459
|
};
|
|
457
460
|
const isListeningIndicatorTextVisible = recognizing && interimTranscript === "" && finalTranscript === "" ? true : false;
|
|
461
|
+
|
|
462
|
+
// isRecognizingDisabled spans the async mic start/stop window (permission
|
|
463
|
+
// prompt, audio worklet setup, socket teardown) - without a distinct label
|
|
464
|
+
// and icon here the button just goes inert with no explanation.
|
|
465
|
+
const isMicBusy = isRecognizingDisabled;
|
|
466
|
+
const micButtonLabel = isMicBusy ? t("loading") : recognizing ? t("click_to_stop") : t("click_to_speak");
|
|
458
467
|
const navigate = useNavigate();
|
|
459
468
|
const goBackToAssignment = () => {
|
|
460
469
|
navigate(`/classrooms/${classroomId}#Assignments`);
|
|
@@ -696,32 +705,33 @@ function ListenAndSpeak({
|
|
|
696
705
|
[theme.breakpoints.down("md")]: {
|
|
697
706
|
height: "50px",
|
|
698
707
|
width: "200px",
|
|
699
|
-
color: "white",
|
|
700
708
|
padding: 2,
|
|
701
709
|
fontSize: "16px",
|
|
702
710
|
borderRadius: 3
|
|
703
711
|
},
|
|
704
712
|
height: "70px",
|
|
705
713
|
width: "250px",
|
|
706
|
-
color: "white",
|
|
707
|
-
backgroundColor: recognizing ? "#d32f2f" : "#2d7b30",
|
|
708
714
|
padding: 1,
|
|
709
715
|
fontSize: "20px",
|
|
710
716
|
borderRadius: 5,
|
|
711
717
|
border: "1.5px solid transparent",
|
|
712
718
|
"&:hover": {
|
|
713
|
-
backgroundColor: recognizing ? "#d32f2f" : "#2d7b30",
|
|
714
719
|
border: "1.5px solid black"
|
|
715
720
|
}
|
|
716
721
|
}),
|
|
717
722
|
onClick: () => handleMic(currentPhrase ? currentPhrase.phrase : ""),
|
|
718
|
-
startIcon:
|
|
723
|
+
startIcon: isMicBusy ? /*#__PURE__*/_jsx(CircularProgress, {
|
|
724
|
+
size: 20,
|
|
725
|
+
color: "inherit"
|
|
726
|
+
}) : recognizing ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(Mic, {}),
|
|
719
727
|
color: recognizing ? "error" : "success",
|
|
720
728
|
disabled: isSpeaking || !isSocketConnected || isRecognizingDisabled,
|
|
729
|
+
"aria-busy": isMicBusy,
|
|
730
|
+
"aria-label": micButtonLabel,
|
|
721
731
|
id: "voiceInput",
|
|
722
732
|
variant: "contained",
|
|
723
733
|
ref: btnRef,
|
|
724
|
-
children:
|
|
734
|
+
children: micButtonLabel
|
|
725
735
|
})
|
|
726
736
|
}), /*#__PURE__*/_jsx(Grid, {
|
|
727
737
|
children: /*#__PURE__*/_jsx(Typography, {
|
|
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react";
|
|
|
2
2
|
import { Joyride, STATUS } from "react-joyride";
|
|
3
3
|
import { makeStyles } from "tss-react/mui";
|
|
4
4
|
import { useTheme } from "@mui/material/styles";
|
|
5
|
-
import { Grid, Slide, IconButton, Button, Toolbar, AppBar, Dialog, Typography, Box, Tooltip } from "@mui/material";
|
|
5
|
+
import { Grid, Slide, IconButton, Button, Toolbar, AppBar, Dialog, Typography, Box, Tooltip, CircularProgress } from "@mui/material";
|
|
6
6
|
import { styled } from "@mui/system";
|
|
7
7
|
import LightbulbIcon from "@mui/icons-material/Lightbulb";
|
|
8
8
|
import Replay from "@mui/icons-material/Replay";
|
|
@@ -228,9 +228,12 @@ function Pronouncer({
|
|
|
228
228
|
});
|
|
229
229
|
}
|
|
230
230
|
if (recognizing && interimTranscript === "" && finalTranscript === "") {
|
|
231
|
-
// Set a timer to stop listening if no audio is detected after 10 seconds
|
|
231
|
+
// Set a timer to stop listening if no audio is detected after 10 seconds.
|
|
232
|
+
// Without the snackbar here the mic just switches off with no
|
|
233
|
+
// explanation, indistinguishable from the student stopping it themselves.
|
|
232
234
|
timer = setTimeout(() => {
|
|
233
235
|
stopListening();
|
|
236
|
+
openSnackbar?.("We didn't hear anything, please try again.", "warning");
|
|
234
237
|
}, 10000);
|
|
235
238
|
}
|
|
236
239
|
return () => clearTimeout(timer);
|
|
@@ -467,6 +470,12 @@ function Pronouncer({
|
|
|
467
470
|
}
|
|
468
471
|
};
|
|
469
472
|
const isListeningIndicatorTextVisible = recognizing && interimTranscript === "" && finalTranscript === "" ? true : false;
|
|
473
|
+
|
|
474
|
+
// isRecognizingDisabled spans the async mic start/stop window (permission
|
|
475
|
+
// prompt, audio worklet setup, socket teardown) - without a distinct label
|
|
476
|
+
// and icon here the button just goes inert with no explanation.
|
|
477
|
+
const isMicBusy = isRecognizingDisabled;
|
|
478
|
+
const micButtonLabel = isMicBusy ? t("loading") : recognizing ? t("click_to_stop") : t("click_to_speak");
|
|
470
479
|
const navigate = useNavigate();
|
|
471
480
|
const goBackToAssignment = () => {
|
|
472
481
|
navigate(`/classrooms/${classroomId}#Assignments`);
|
|
@@ -751,32 +760,33 @@ function Pronouncer({
|
|
|
751
760
|
[theme.breakpoints.down("md")]: {
|
|
752
761
|
height: "50px",
|
|
753
762
|
width: "200px",
|
|
754
|
-
color: "white",
|
|
755
763
|
padding: 2,
|
|
756
764
|
fontSize: "16px",
|
|
757
765
|
borderRadius: 3
|
|
758
766
|
},
|
|
759
767
|
height: "70px",
|
|
760
768
|
width: "250px",
|
|
761
|
-
color: "white",
|
|
762
|
-
backgroundColor: recognizing ? "#d32f2f" : "#2d7b30",
|
|
763
769
|
padding: 1,
|
|
764
770
|
fontSize: "20px",
|
|
765
771
|
borderRadius: 5,
|
|
766
772
|
border: "1.5px solid transparent",
|
|
767
773
|
"&:hover": {
|
|
768
|
-
backgroundColor: recognizing ? "#d32f2f" : "#2d7b30",
|
|
769
774
|
border: "1.5px solid black"
|
|
770
775
|
}
|
|
771
776
|
}),
|
|
772
777
|
onClick: () => handleMic(currentPhrase ? currentPhrase.phrase : ""),
|
|
773
|
-
startIcon:
|
|
778
|
+
startIcon: isMicBusy ? /*#__PURE__*/_jsx(CircularProgress, {
|
|
779
|
+
size: 20,
|
|
780
|
+
color: "inherit"
|
|
781
|
+
}) : recognizing ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(Mic, {}),
|
|
774
782
|
color: recognizing ? "error" : "success",
|
|
775
783
|
disabled: isSpeaking || !isSocketConnected || isRecognizingDisabled,
|
|
784
|
+
"aria-busy": isMicBusy,
|
|
785
|
+
"aria-label": micButtonLabel,
|
|
776
786
|
id: "voiceInput",
|
|
777
787
|
variant: "contained",
|
|
778
788
|
ref: btnRef,
|
|
779
|
-
children:
|
|
789
|
+
children: micButtonLabel
|
|
780
790
|
})
|
|
781
791
|
}), /*#__PURE__*/_jsx(Grid, {
|
|
782
792
|
children: /*#__PURE__*/_jsx(Typography, {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useRef } from "react";
|
|
2
|
-
import { Box, Button, Typography, Grid, Avatar, Dialog } from "@mui/material";
|
|
2
|
+
import { Box, Button, Typography, Grid, Avatar, Dialog, CircularProgress } from "@mui/material";
|
|
3
3
|
import { makeStyles } from "tss-react/mui";
|
|
4
4
|
import Person from "@mui/icons-material/Person";
|
|
5
5
|
import Mic from "@mui/icons-material/Mic";
|
|
@@ -101,6 +101,12 @@ function AnswerOptions({
|
|
|
101
101
|
buttonRef.current.focus();
|
|
102
102
|
}
|
|
103
103
|
}, [isSpeaking, isSocketConnected]);
|
|
104
|
+
|
|
105
|
+
// isRecognizingDisabled spans the async mic start/stop window (permission
|
|
106
|
+
// prompt, audio worklet setup, socket teardown) - without a distinct label
|
|
107
|
+
// and icon here the button just goes inert with no explanation.
|
|
108
|
+
const isMicBusy = isRecognizingDisabled;
|
|
109
|
+
const micButtonLabel = isMicBusy ? t("loading") : recognizing ? t("click_to_stop") : t("click_to_speak");
|
|
104
110
|
return /*#__PURE__*/_jsxs(Box, {
|
|
105
111
|
sx: theme => ({
|
|
106
112
|
display: "flex",
|
|
@@ -176,27 +182,28 @@ function AnswerOptions({
|
|
|
176
182
|
children: [/*#__PURE__*/_jsx(Button, {
|
|
177
183
|
sx: theme => ({
|
|
178
184
|
[theme.breakpoints.down("md")]: {
|
|
179
|
-
color: "white",
|
|
180
185
|
borderRadius: 3
|
|
181
186
|
},
|
|
182
|
-
color: "white",
|
|
183
|
-
backgroundColor: recognizing ? "#d32f2f" : "#2d7b30",
|
|
184
187
|
mb: theme.spacing(2),
|
|
185
188
|
border: "1.5px solid transparent",
|
|
186
189
|
"&:hover": {
|
|
187
|
-
backgroundColor: recognizing ? "#d32f2f" : "#2d7b30",
|
|
188
190
|
border: "1.5px solid black"
|
|
189
191
|
}
|
|
190
192
|
}),
|
|
191
193
|
onClick: () => handleMic(text),
|
|
192
|
-
startIcon:
|
|
194
|
+
startIcon: isMicBusy ? /*#__PURE__*/_jsx(CircularProgress, {
|
|
195
|
+
size: 20,
|
|
196
|
+
color: "inherit"
|
|
197
|
+
}) : recognizing ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(Mic, {}),
|
|
193
198
|
color: recognizing ? "error" : "success",
|
|
194
199
|
disabled: isSpeaking || !isSocketConnected || isRecognizingDisabled,
|
|
195
200
|
id: "voiceInput",
|
|
196
201
|
variant: "contained",
|
|
197
202
|
size: "large",
|
|
198
203
|
ref: buttonRef,
|
|
199
|
-
|
|
204
|
+
"aria-busy": isMicBusy,
|
|
205
|
+
"aria-label": micButtonLabel,
|
|
206
|
+
children: micButtonLabel
|
|
200
207
|
}), /*#__PURE__*/_jsx(Typography, {
|
|
201
208
|
role: "status",
|
|
202
209
|
variant: "h6",
|
|
@@ -434,9 +441,12 @@ function RoleplayGame({
|
|
|
434
441
|
useEffect(() => {
|
|
435
442
|
let timer;
|
|
436
443
|
if (recognizing && interimTranscript === "" && finalTranscript === "") {
|
|
437
|
-
// Set a timer to stop listening if no audio is detected after 10 seconds
|
|
444
|
+
// Set a timer to stop listening if no audio is detected after 10 seconds.
|
|
445
|
+
// Without the snackbar here the mic just switches off with no
|
|
446
|
+
// explanation, indistinguishable from the student stopping it themselves.
|
|
438
447
|
timer = setTimeout(() => {
|
|
439
448
|
stopListening();
|
|
449
|
+
openSnackbar?.("We didn't hear anything, please try again.", "warning");
|
|
440
450
|
}, 10000);
|
|
441
451
|
}
|
|
442
452
|
return () => clearTimeout(timer);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useRef } from "react";
|
|
2
|
-
import { Box, Button, Typography, Grid, Avatar, Dialog } from "@mui/material";
|
|
2
|
+
import { Box, Button, Typography, Grid, Avatar, Dialog, CircularProgress } from "@mui/material";
|
|
3
3
|
import { makeStyles } from "tss-react/mui";
|
|
4
4
|
import Person from "@mui/icons-material/Person";
|
|
5
5
|
import RecordVoiceOverIcon from "@mui/icons-material/RecordVoiceOver";
|
|
@@ -92,6 +92,12 @@ function AnswerOptions({
|
|
|
92
92
|
buttonRef.current.focus();
|
|
93
93
|
}
|
|
94
94
|
}, [isSpeaking, isSocketConnected]);
|
|
95
|
+
|
|
96
|
+
// isRecognizingDisabled spans the async mic start/stop window (permission
|
|
97
|
+
// prompt, audio worklet setup, socket teardown) - without a distinct label
|
|
98
|
+
// and icon here the button just goes inert with no explanation.
|
|
99
|
+
const isMicBusy = isRecognizingDisabled;
|
|
100
|
+
const micButtonLabel = isMicBusy ? t("loading") : recognizing ? t("click_to_stop") : t("click_to_speak");
|
|
95
101
|
return /*#__PURE__*/_jsxs(Box, {
|
|
96
102
|
sx: theme => ({
|
|
97
103
|
display: "flex",
|
|
@@ -167,28 +173,28 @@ function AnswerOptions({
|
|
|
167
173
|
children: [/*#__PURE__*/_jsx(Button, {
|
|
168
174
|
sx: theme => ({
|
|
169
175
|
[theme.breakpoints.down("md")]: {
|
|
170
|
-
color: "white",
|
|
171
176
|
borderRadius: 3
|
|
172
177
|
},
|
|
173
|
-
color: "white",
|
|
174
|
-
backgroundColor: recognizing ? "#d32f2f" : "#2d7b30",
|
|
175
178
|
mb: theme.spacing(2),
|
|
176
179
|
border: "1.5px solid transparent",
|
|
177
180
|
"&:hover": {
|
|
178
|
-
backgroundColor: recognizing ? "#d32f2f" : "#2d7b30",
|
|
179
181
|
border: "1.5px solid black"
|
|
180
182
|
}
|
|
181
183
|
}),
|
|
182
184
|
onClick: () => handleMic(text),
|
|
183
|
-
startIcon:
|
|
185
|
+
startIcon: isMicBusy ? /*#__PURE__*/_jsx(CircularProgress, {
|
|
186
|
+
size: 20,
|
|
187
|
+
color: "inherit"
|
|
188
|
+
}) : recognizing ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(Mic, {}),
|
|
184
189
|
color: recognizing ? "error" : "success",
|
|
185
190
|
disabled: isSpeaking || !isSocketConnected || isRecognizingDisabled,
|
|
186
191
|
id: "voiceInput",
|
|
187
192
|
variant: "contained",
|
|
188
193
|
size: "large",
|
|
189
194
|
ref: buttonRef,
|
|
190
|
-
"aria-
|
|
191
|
-
|
|
195
|
+
"aria-busy": isMicBusy,
|
|
196
|
+
"aria-label": ariaLiveText || micButtonLabel,
|
|
197
|
+
children: micButtonLabel
|
|
192
198
|
}), /*#__PURE__*/_jsx(Typography, {
|
|
193
199
|
role: "status",
|
|
194
200
|
variant: "h6",
|
|
@@ -427,9 +433,12 @@ function RoleplayGame({
|
|
|
427
433
|
useEffect(() => {
|
|
428
434
|
let timer;
|
|
429
435
|
if (recognizing && interimTranscript === "" && finalTranscript === "") {
|
|
430
|
-
// Set a timer to stop listening if no audio is detected after 10 seconds
|
|
436
|
+
// Set a timer to stop listening if no audio is detected after 10 seconds.
|
|
437
|
+
// Without the snackbar here the mic just switches off with no
|
|
438
|
+
// explanation, indistinguishable from the student stopping it themselves.
|
|
431
439
|
timer = setTimeout(() => {
|
|
432
440
|
stopListening();
|
|
441
|
+
openSnackbar?.("We didn't hear anything, please try again.", "warning");
|
|
433
442
|
}, 10000);
|
|
434
443
|
}
|
|
435
444
|
return () => clearTimeout(timer);
|
|
@@ -9,7 +9,7 @@ const ENDPOINT = import.meta.env.REACT_APP_GCLOUD_SPEECH || "wss://speech-rec-se
|
|
|
9
9
|
const socket = io(ENDPOINT, {
|
|
10
10
|
withCredentials: true,
|
|
11
11
|
autoConnect: false,
|
|
12
|
-
transports: ["websocket"] // use WebSocket
|
|
12
|
+
transports: ["websocket", "polling"] // use WebSocket, fallback to polling if necessary
|
|
13
13
|
});
|
|
14
14
|
socket.on("connect_error", error => {
|
|
15
15
|
// revert to classic upgrade
|
|
@@ -54,6 +54,24 @@ export default function useRecognition(props = {}) {
|
|
|
54
54
|
const stopAudioRecordingTimeoutIdRef = useRef();
|
|
55
55
|
const streamRef = useRef();
|
|
56
56
|
const serverErrorRef = useRef();
|
|
57
|
+
const hasAlertedConnectionErrorRef = useRef(false);
|
|
58
|
+
// Guards against a second click landing during the async start-up window,
|
|
59
|
+
// before recognizing/isRecognizingDisabled state has re-rendered the button.
|
|
60
|
+
const isStartingRef = useRef(false);
|
|
61
|
+
|
|
62
|
+
// Lets us query, e.g., how often students end up on the polling fallback
|
|
63
|
+
// (a proxy for "couldn't connect via websocket") or hit connect errors.
|
|
64
|
+
function trackConnectionDiagnostics(eventName, extra = {}) {
|
|
65
|
+
if (typeof trackRecommendedEvent !== "function") {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
trackRecommendedEvent(eventName, {
|
|
69
|
+
A1_browser: `${getBrowserInfo(navigator.userAgent)}`,
|
|
70
|
+
A2_machine_info: `${getMachineInfo(navigator.userAgent)}`,
|
|
71
|
+
A3_exercise: gameId ? `${exerciseName}-${gameId}` : `${exerciseName}`,
|
|
72
|
+
...extra
|
|
73
|
+
});
|
|
74
|
+
}
|
|
57
75
|
async function loadModule() {
|
|
58
76
|
try {
|
|
59
77
|
await audioContextRef.current.audioWorklet.addModule(`/worklets/recording-processor-v2.js`);
|
|
@@ -72,7 +90,9 @@ export default function useRecognition(props = {}) {
|
|
|
72
90
|
});
|
|
73
91
|
await loadModule();
|
|
74
92
|
}
|
|
75
|
-
|
|
93
|
+
// Must be awaited - an unawaited rejection here would leave the
|
|
94
|
+
// context suspended with no error surfaced, silently killing capture.
|
|
95
|
+
await audioContextRef.current.resume();
|
|
76
96
|
} catch (e) {
|
|
77
97
|
console.error(`Sorry, there was an issue setting up the audio context`, e);
|
|
78
98
|
throw e;
|
|
@@ -80,13 +100,71 @@ export default function useRecognition(props = {}) {
|
|
|
80
100
|
}
|
|
81
101
|
useEffect(() => {
|
|
82
102
|
function onConnect() {
|
|
103
|
+
hasAlertedConnectionErrorRef.current = false;
|
|
83
104
|
setIsSocketConnected(true);
|
|
105
|
+
// socket.io.engine.transport.name is "polling" here only when the
|
|
106
|
+
// websocket attempt failed and it fell back - the clearest signal we
|
|
107
|
+
// have that a student couldn't connect via websocket.
|
|
108
|
+
trackConnectionDiagnostics("speech_recognition_socket_connected", {
|
|
109
|
+
A4_transport: socket.io.engine.transport.name
|
|
110
|
+
});
|
|
84
111
|
}
|
|
85
112
|
function onDisconnect() {
|
|
86
113
|
setIsSocketConnected(false);
|
|
114
|
+
// The server-side recognize stream/session is gone with the connection
|
|
115
|
+
// (no state recovery), so leaving recognizing:true would keep streaming
|
|
116
|
+
// audio into a void with no feedback until the 10s no-transcript timer.
|
|
117
|
+
setRecognitionState(prevState => {
|
|
118
|
+
if (stopAudioRecordingTimeoutIdRef.current) {
|
|
119
|
+
clearTimeout(stopAudioRecordingTimeoutIdRef.current);
|
|
120
|
+
stopAudioRecordingTimeoutIdRef.current = null;
|
|
121
|
+
}
|
|
122
|
+
// A start() in flight (isStartingRef/isRecognizingDisabled set, but
|
|
123
|
+
// recognizing not yet true) needs the same unwind as an active
|
|
124
|
+
// recording - otherwise it's neither idle nor recovered, and the
|
|
125
|
+
// getUserMedia continuation below would still flip recognizing:true
|
|
126
|
+
// once it resolves against a dead socket.
|
|
127
|
+
const isStarting = isStartingRef.current || prevState.isRecognizingDisabled;
|
|
128
|
+
if (!prevState.recognizing && !isStarting) {
|
|
129
|
+
// Truly idle - nothing to unwind, but the mic button is about to go
|
|
130
|
+
// disabled - without this the student just finds a dead button
|
|
131
|
+
// later with no explanation. Guarded so it only fires once per
|
|
132
|
+
// disconnected streak.
|
|
133
|
+
if (!hasAlertedConnectionErrorRef.current) {
|
|
134
|
+
hasAlertedConnectionErrorRef.current = true;
|
|
135
|
+
recognitionAlert("Connection to the speech recognition service was lost. The microphone will be unavailable until it reconnects.");
|
|
136
|
+
}
|
|
137
|
+
return prevState;
|
|
138
|
+
}
|
|
139
|
+
isStartingRef.current = false;
|
|
140
|
+
closeAll(true);
|
|
141
|
+
if (!hasAlertedConnectionErrorRef.current) {
|
|
142
|
+
hasAlertedConnectionErrorRef.current = true;
|
|
143
|
+
recognitionAlert(prevState.recognizing ? "Connection to the speech recognition service was lost while recording. Please try again." : "Connection to the speech recognition service was lost while starting the microphone. Please try again.");
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
...prevState,
|
|
147
|
+
recognizing: false,
|
|
148
|
+
isRecognizingDisabled: false
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function onConnectError(error) {
|
|
153
|
+
console.error(error);
|
|
154
|
+
trackConnectionDiagnostics("speech_recognition_connect_error", {
|
|
155
|
+
A4_error_message: `${error?.message}`
|
|
156
|
+
});
|
|
157
|
+
// Without this, a blocked/failed connection (e.g. a school network
|
|
158
|
+
// firewall blocking the websocket) leaves the mic button silently
|
|
159
|
+
// disabled with no feedback to the student.
|
|
160
|
+
if (!hasAlertedConnectionErrorRef.current) {
|
|
161
|
+
hasAlertedConnectionErrorRef.current = true;
|
|
162
|
+
recognitionAlert("Could not connect to the speech recognition service. Please check your network connection (some school Wi-Fi/firewalls block this) and refresh the page.");
|
|
163
|
+
}
|
|
87
164
|
}
|
|
88
165
|
socket.on("connect", onConnect);
|
|
89
166
|
socket.on("disconnect", onDisconnect);
|
|
167
|
+
socket.on("connect_error", onConnectError);
|
|
90
168
|
|
|
91
169
|
// no-op if the socket is already connected
|
|
92
170
|
socket.connect();
|
|
@@ -94,14 +172,19 @@ export default function useRecognition(props = {}) {
|
|
|
94
172
|
socket.disconnect();
|
|
95
173
|
socket.off("connect", onConnect);
|
|
96
174
|
socket.off("disconnect", onDisconnect);
|
|
97
|
-
|
|
175
|
+
socket.off("connect_error", onConnectError);
|
|
176
|
+
closeAll(false, true);
|
|
98
177
|
};
|
|
99
178
|
}, []);
|
|
100
179
|
|
|
101
180
|
/**
|
|
102
181
|
* Stops recording and closes everything down. Runs on error or on stop.
|
|
182
|
+
* By default the AudioContext is only suspended, not closed, so the next
|
|
183
|
+
* start() can reuse it instead of re-fetching/re-registering the audio
|
|
184
|
+
* worklet module (a source of noticeable start-up lag). Pass fullClose
|
|
185
|
+
* when the hook itself is unmounting and the context should be released.
|
|
103
186
|
*/
|
|
104
|
-
function closeAll(keepSocketAlive) {
|
|
187
|
+
function closeAll(keepSocketAlive, fullClose = false) {
|
|
105
188
|
// Clear the listeners (prevents issue if opening and closing repeatedly)
|
|
106
189
|
if (keepSocketAlive) {
|
|
107
190
|
socket.off("speechData");
|
|
@@ -114,6 +197,7 @@ export default function useRecognition(props = {}) {
|
|
|
114
197
|
track.stop();
|
|
115
198
|
});
|
|
116
199
|
}
|
|
200
|
+
streamRef.current = null;
|
|
117
201
|
if (processorRef.current) {
|
|
118
202
|
if (audioInputRef.current) {
|
|
119
203
|
try {
|
|
@@ -122,19 +206,30 @@ export default function useRecognition(props = {}) {
|
|
|
122
206
|
logger.warn(`Attempt to disconnect input failed: ${error}`);
|
|
123
207
|
}
|
|
124
208
|
}
|
|
125
|
-
|
|
209
|
+
try {
|
|
210
|
+
processorRef.current.disconnect(audioContextRef.current?.destination);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
logger.warn(`Attempt to disconnect processor failed: ${error}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
audioInputRef.current = null;
|
|
216
|
+
processorRef.current = null;
|
|
217
|
+
if (!audioContextRef.current) {
|
|
218
|
+
return;
|
|
126
219
|
}
|
|
127
|
-
if (
|
|
220
|
+
if (fullClose) {
|
|
128
221
|
audioContextRef.current.close().then(function () {
|
|
129
|
-
audioInputRef.current = null;
|
|
130
|
-
processorRef.current = null;
|
|
131
222
|
audioContextRef.current = null;
|
|
132
223
|
});
|
|
224
|
+
} else {
|
|
225
|
+
audioContextRef.current.suspend().catch(error => {
|
|
226
|
+
logger.warn(`Failed to suspend audio context: ${error}`);
|
|
227
|
+
});
|
|
133
228
|
}
|
|
134
229
|
}
|
|
135
|
-
function recognitionAlert(message) {
|
|
230
|
+
function recognitionAlert(message, severity = "error") {
|
|
136
231
|
if (openSnackbar) {
|
|
137
|
-
openSnackbar(message,
|
|
232
|
+
openSnackbar(message, severity);
|
|
138
233
|
} else {
|
|
139
234
|
alert(message);
|
|
140
235
|
}
|
|
@@ -148,7 +243,15 @@ export default function useRecognition(props = {}) {
|
|
|
148
243
|
const initRecording = async (gcloudParams, onData, onError) => {
|
|
149
244
|
try {
|
|
150
245
|
await setupAudioContext();
|
|
151
|
-
const handleSuccess = function (stream) {
|
|
246
|
+
const handleSuccess = async function (stream) {
|
|
247
|
+
streamRef.current = stream;
|
|
248
|
+
if (!socket.connected) {
|
|
249
|
+
// Dropped while getUserMedia was resolving - don't buffer a stale
|
|
250
|
+
// startGoogleCloudStream for a session the server already tore
|
|
251
|
+
// down. onDisconnect owns the alert/state reset.
|
|
252
|
+
closeAll(true);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
152
255
|
const gcloudConfig = {
|
|
153
256
|
config: {
|
|
154
257
|
encoding: "LINEAR16",
|
|
@@ -167,12 +270,28 @@ export default function useRecognition(props = {}) {
|
|
|
167
270
|
};
|
|
168
271
|
socket.emit("startGoogleCloudStream", gcloudConfig, version); // init socket Google Speech Connection
|
|
169
272
|
|
|
170
|
-
streamRef.current = stream;
|
|
171
273
|
audioInputRef.current = audioContextRef.current.createMediaStreamSource(stream);
|
|
172
274
|
serverErrorRef.current = null;
|
|
173
275
|
processorRef.current = new AudioWorkletNode(audioContextRef.current, "recording-processor-v2");
|
|
174
276
|
processorRef.current.connect(audioContextRef.current.destination);
|
|
175
|
-
|
|
277
|
+
try {
|
|
278
|
+
// Awaited so a rejection reaches the getUserMedia .catch() below
|
|
279
|
+
// instead of vanishing as an unhandled rejection.
|
|
280
|
+
await audioContextRef.current.resume();
|
|
281
|
+
} catch (resumeError) {
|
|
282
|
+
// stream/audioInput/processor are already live at this point -
|
|
283
|
+
// without this the mic track and worklet node would leak past the
|
|
284
|
+
// failure that the outer .catch() alerts and resets state for.
|
|
285
|
+
closeAll(true);
|
|
286
|
+
throw resumeError;
|
|
287
|
+
}
|
|
288
|
+
if (!socket.connected) {
|
|
289
|
+
// Dropped mid-resume; onDisconnect has already (or is about to)
|
|
290
|
+
// close these same resources, so stop instead of wiring nodes onto
|
|
291
|
+
// a torn-down session.
|
|
292
|
+
closeAll(true);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
176
295
|
audioInputRef.current.connect(processorRef.current);
|
|
177
296
|
processorRef.current.port.onmessage = event => {
|
|
178
297
|
const audioData = event.data;
|
|
@@ -188,50 +307,86 @@ export default function useRecognition(props = {}) {
|
|
|
188
307
|
socket.emit("binaryData", result, version);
|
|
189
308
|
};
|
|
190
309
|
if (window.MediaRecorder) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
310
|
+
// This only powers the "listen back to your attempt" playback
|
|
311
|
+
// feature, not recognition itself. A codec/construction failure
|
|
312
|
+
// here (seen in some embedded/older WebKit webviews) must not
|
|
313
|
+
// abort the recording flow that already started above.
|
|
314
|
+
try {
|
|
315
|
+
mediaRecorderRef.current = new MediaRecorder(stream);
|
|
316
|
+
mediaRecorderRef.current.ondataavailable = e => {
|
|
317
|
+
const audioChunks = [];
|
|
318
|
+
audioChunks.push(e.data);
|
|
319
|
+
const audioBlob = new Blob(audioChunks, {
|
|
320
|
+
type: mediaRecorderRef.current.mimeType
|
|
321
|
+
});
|
|
322
|
+
const audioUrl = URL.createObjectURL(audioBlob);
|
|
323
|
+
setRecognitionState(prevState => ({
|
|
324
|
+
...prevState,
|
|
325
|
+
attemptAudioURL: audioUrl,
|
|
326
|
+
audioBlob: audioBlob
|
|
327
|
+
}));
|
|
328
|
+
};
|
|
329
|
+
mediaRecorderRef.current.start();
|
|
330
|
+
} catch (mediaRecorderError) {
|
|
331
|
+
console.error("Failed to start MediaRecorder for attempt playback", mediaRecorderError);
|
|
332
|
+
mediaRecorderRef.current = null;
|
|
333
|
+
}
|
|
206
334
|
}
|
|
207
|
-
if (
|
|
335
|
+
if (
|
|
336
|
+
// Excludes bare "Safari", which also matches every desktop
|
|
337
|
+
// Chrome/Edge/Firefox UA string and was adding this delay for
|
|
338
|
+
// almost all browsers, not just mobile ones.
|
|
339
|
+
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
|
|
208
340
|
// wait half second to indicate recording, this helps prevent speech being lost at the start
|
|
209
341
|
return new Promise(resolve => setTimeout(resolve, 500));
|
|
210
342
|
}
|
|
211
343
|
};
|
|
212
344
|
if (navigator && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
|
213
345
|
navigator.mediaDevices.getUserMedia(constraints).then(handleSuccess).then(() => {
|
|
346
|
+
isStartingRef.current = false;
|
|
347
|
+
// socket.connected is checked directly (not the isSocketConnected
|
|
348
|
+
// state, which may not have re-rendered yet) so a disconnect that
|
|
349
|
+
// landed mid-setup can't be raced into recognizing:true - onDisconnect
|
|
350
|
+
// already unwound this startup, so just release what handleSuccess
|
|
351
|
+
// just created.
|
|
352
|
+
if (!socket.connected) {
|
|
353
|
+
closeAll(true);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
214
356
|
if (!serverErrorRef.current) {
|
|
215
357
|
setRecognitionState(prevState => ({
|
|
216
358
|
...prevState,
|
|
217
|
-
recognizing: true
|
|
359
|
+
recognizing: true,
|
|
360
|
+
isRecognizingDisabled: false
|
|
218
361
|
}));
|
|
219
362
|
}
|
|
220
363
|
}).catch(err => {
|
|
221
364
|
/* handle the error */
|
|
222
365
|
console.error(err);
|
|
223
|
-
|
|
224
|
-
|
|
366
|
+
// Match on err.name (the DOMException standard) rather than the
|
|
367
|
+
// message text, which varies by browser/locale and previously
|
|
368
|
+
// meant most getUserMedia failures (e.g. a Permissions-Policy
|
|
369
|
+
// block when embedded in an iframe/webview such as an LMS app)
|
|
370
|
+
// were logged but never surfaced to the student.
|
|
371
|
+
const isEmbedded = typeof window !== "undefined" && window.self !== window.top;
|
|
372
|
+
if (["NotAllowedError", "PermissionDeniedError", "SecurityError"].includes(err?.name)) {
|
|
373
|
+
recognitionAlert(isEmbedded ? "Microphone access was blocked. If you're inside another app, make sure it allows microphone access." : "Permission denied, Please allow microphone access and refresh the page.");
|
|
374
|
+
} else {
|
|
375
|
+
recognitionAlert("Could not access the microphone. Please refresh the page and try again.");
|
|
225
376
|
}
|
|
377
|
+
isStartingRef.current = false;
|
|
226
378
|
setRecognitionState(prevState => ({
|
|
227
379
|
...prevState,
|
|
228
|
-
recognizing: false
|
|
380
|
+
recognizing: false,
|
|
381
|
+
isRecognizingDisabled: false
|
|
229
382
|
}));
|
|
230
383
|
});
|
|
231
384
|
} else {
|
|
385
|
+
isStartingRef.current = false;
|
|
232
386
|
setRecognitionState(prevState => ({
|
|
233
387
|
...prevState,
|
|
234
|
-
recognizing: false
|
|
388
|
+
recognizing: false,
|
|
389
|
+
isRecognizingDisabled: false
|
|
235
390
|
}));
|
|
236
391
|
recognitionAlert("Speech recognition not supported, please try another browser.");
|
|
237
392
|
}
|
|
@@ -250,6 +405,12 @@ export default function useRecognition(props = {}) {
|
|
|
250
405
|
}
|
|
251
406
|
});
|
|
252
407
|
} catch (error) {
|
|
408
|
+
isStartingRef.current = false;
|
|
409
|
+
setRecognitionState(prevState => ({
|
|
410
|
+
...prevState,
|
|
411
|
+
recognizing: false,
|
|
412
|
+
isRecognizingDisabled: false
|
|
413
|
+
}));
|
|
253
414
|
if (onError) {
|
|
254
415
|
onError(error);
|
|
255
416
|
}
|
|
@@ -280,7 +441,7 @@ export default function useRecognition(props = {}) {
|
|
|
280
441
|
};
|
|
281
442
|
trackRecommendedEvent("speech_recognition_no_transcript", eventBody);
|
|
282
443
|
};
|
|
283
|
-
const stopRecording = (keepSocketAlive, triggerEvent) => {
|
|
444
|
+
const stopRecording = (keepSocketAlive, triggerEvent, fullClose = false) => {
|
|
284
445
|
// indicate end stream to trigger interim transcript to return as final transcript
|
|
285
446
|
if (socket) {
|
|
286
447
|
indicateEndStream();
|
|
@@ -289,7 +450,7 @@ export default function useRecognition(props = {}) {
|
|
|
289
450
|
mediaRecorderRef.current.stop();
|
|
290
451
|
}
|
|
291
452
|
setTimeout(() => {
|
|
292
|
-
closeAll(keepSocketAlive);
|
|
453
|
+
closeAll(keepSocketAlive, fullClose);
|
|
293
454
|
setRecognitionState(prevState => ({
|
|
294
455
|
...prevState,
|
|
295
456
|
isRecognizingDisabled: false
|
|
@@ -305,7 +466,7 @@ export default function useRecognition(props = {}) {
|
|
|
305
466
|
};
|
|
306
467
|
useEffect(() => {
|
|
307
468
|
return () => {
|
|
308
|
-
stopRecording(false);
|
|
469
|
+
stopRecording(false, false, true);
|
|
309
470
|
};
|
|
310
471
|
}, []);
|
|
311
472
|
const startRecognition = (lang, speechContexts, model) => {
|
|
@@ -408,6 +569,16 @@ export default function useRecognition(props = {}) {
|
|
|
408
569
|
stop();
|
|
409
570
|
};
|
|
410
571
|
const start = (lang, speechContexts, model) => {
|
|
572
|
+
// Ignore a repeat click while a start is already in flight or once
|
|
573
|
+
// recognizing - a ref check because it must be synchronous, unlike state.
|
|
574
|
+
if (isStartingRef.current || recognitionState.recognizing) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
isStartingRef.current = true;
|
|
578
|
+
setRecognitionState(prevState => ({
|
|
579
|
+
...prevState,
|
|
580
|
+
isRecognizingDisabled: true
|
|
581
|
+
}));
|
|
411
582
|
stopAudioRecordingTimeoutIdRef.current = setTimeout(() => {
|
|
412
583
|
stop();
|
|
413
584
|
}, audioRecordingLimit);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nualang/nualang-ui-components",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1421",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"files": [
|
|
@@ -177,6 +177,7 @@
|
|
|
177
177
|
"build-storybook": "storybook build",
|
|
178
178
|
"clean": "rimraf dist",
|
|
179
179
|
"compile": "pnpm run clean && cross-env NODE_ENV=production babel src/components --out-dir dist --copy-files --no-copy-ignored",
|
|
180
|
+
"watch": "pnpm run clean && cross-env NODE_ENV=production babel src/components --out-dir dist --copy-files --no-copy-ignored --watch",
|
|
180
181
|
"version": "pnpm run compile && git add -A",
|
|
181
182
|
"postversion": "pnpm publish && git push && git push --tags",
|
|
182
183
|
"localpack": "pnpm run compile && pnpm pack",
|