@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 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: isListening ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(MicIcon, {})
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 black",
280
+ border: "1.5px solid transparent",
271
281
  "&:hover": {
272
- backgroundColor: isListening ? "#d32f2f" : "#2d7b30",
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: isListening ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(MicIcon, {}),
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: isListening ? t("click_to_stop") : t("click_to_speak")
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: recognizing ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(Mic, {}),
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: recognizing ? t("click_to_stop") : t("click_to_speak")
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: recognizing ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(Mic, {}),
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: recognizing ? t("click_to_stop") : t("click_to_speak")
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: recognizing ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(Mic, {}),
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
- children: recognizing ? t("click_to_stop") : t("click_to_speak")
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: recognizing ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(Mic, {}),
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-label": ariaLiveText,
191
- children: recognizing ? t("click_to_stop") : t("click_to_speak")
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 only
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
- audioContextRef.current.resume();
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
- closeAll();
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
- processorRef.current.disconnect(audioContextRef.current.destination);
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 (audioContextRef.current) {
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, "error");
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
- audioContextRef.current.resume();
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
- mediaRecorderRef.current = new MediaRecorder(stream);
192
- mediaRecorderRef.current.ondataavailable = e => {
193
- const audioChunks = [];
194
- audioChunks.push(e.data);
195
- const audioBlob = new Blob(audioChunks, {
196
- type: mediaRecorderRef.current.mimeType
197
- });
198
- const audioUrl = URL.createObjectURL(audioBlob);
199
- setRecognitionState(prevState => ({
200
- ...prevState,
201
- attemptAudioURL: audioUrl,
202
- audioBlob: audioBlob
203
- }));
204
- };
205
- mediaRecorderRef.current.start();
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 (/Android|webOS|Safari|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
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
- if (["Permission denied", "The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.", "The request is not allowed by the user agent or the platform in the current context."].includes(err?.message)) {
224
- recognitionAlert("Permission denied, Please allow microphone access and refresh the page.");
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.1420",
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",