@intellectif/lk-react 23.1.0 → 23.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +9 -5
- package/dist/ActivityErrorBoundary-B379hvtL.cjs +39 -0
- package/dist/ActivityErrorBoundary-B379hvtL.cjs.map +1 -0
- package/dist/ActivityErrorBoundary-Dw6SbWv3.js +34 -0
- package/dist/ActivityErrorBoundary-Dw6SbWv3.js.map +1 -0
- package/dist/ActivityMedia-CQQTRNl7.cjs +641 -0
- package/dist/ActivityMedia-CQQTRNl7.cjs.map +1 -0
- package/dist/ActivityMedia-vDiP-Psa.js +636 -0
- package/dist/ActivityMedia-vDiP-Psa.js.map +1 -0
- package/dist/ActivityPreview-1hhTkYuj.js +240 -0
- package/dist/ActivityPreview-1hhTkYuj.js.map +1 -0
- package/dist/ActivityPreview-BKr34exz.cjs +245 -0
- package/dist/ActivityPreview-BKr34exz.cjs.map +1 -0
- package/dist/ActivitySequence-CkX87pfB.js +839 -0
- package/dist/ActivitySequence-CkX87pfB.js.map +1 -0
- package/dist/ActivitySequence-CmoSyDnp.cjs +844 -0
- package/dist/ActivitySequence-CmoSyDnp.cjs.map +1 -0
- package/dist/Dictation-BlxRp-3k.js +926 -0
- package/dist/Dictation-BlxRp-3k.js.map +1 -0
- package/dist/Dictation-jECzL88E.cjs +931 -0
- package/dist/Dictation-jECzL88E.cjs.map +1 -0
- package/dist/FeedbackRegion-BNNMtTY-.cjs +108 -0
- package/dist/FeedbackRegion-BNNMtTY-.cjs.map +1 -0
- package/dist/FeedbackRegion-CyPSp8bA.js +85 -0
- package/dist/FeedbackRegion-CyPSp8bA.js.map +1 -0
- package/dist/FillInTheBlanks-B6xzqYXx.js +474 -0
- package/dist/FillInTheBlanks-B6xzqYXx.js.map +1 -0
- package/dist/FillInTheBlanks-BpKhdHg2.cjs +479 -0
- package/dist/FillInTheBlanks-BpKhdHg2.cjs.map +1 -0
- package/dist/GapSelect-DUSiaEQH.js +441 -0
- package/dist/GapSelect-DUSiaEQH.js.map +1 -0
- package/dist/GapSelect-payTq_Zn.cjs +446 -0
- package/dist/GapSelect-payTq_Zn.cjs.map +1 -0
- package/dist/InteractiveVideo-BguGv-HA.cjs +2955 -0
- package/dist/InteractiveVideo-BguGv-HA.cjs.map +1 -0
- package/dist/InteractiveVideo-Cg2jbU3j.js +2944 -0
- package/dist/InteractiveVideo-Cg2jbU3j.js.map +1 -0
- package/dist/LkAiProvider-DAPpA3PQ.d.cts +84 -0
- package/dist/LkAiProvider-DAPpA3PQ.d.cts.map +1 -0
- package/dist/LkAiProvider-QdEcIhoo.d.ts +84 -0
- package/dist/LkAiProvider-QdEcIhoo.d.ts.map +1 -0
- package/dist/LkIntlProvider-D_FdYXjQ.js +375 -0
- package/dist/LkIntlProvider-D_FdYXjQ.js.map +1 -0
- package/dist/LkIntlProvider-oi57lYB0.cjs +410 -0
- package/dist/LkIntlProvider-oi57lYB0.cjs.map +1 -0
- package/dist/MultipleChoice-COn5MtKY.cjs +433 -0
- package/dist/MultipleChoice-COn5MtKY.cjs.map +1 -0
- package/dist/MultipleChoice-Dra0_KYD.js +428 -0
- package/dist/MultipleChoice-Dra0_KYD.js.map +1 -0
- package/dist/PronunciationFeedback-DlMPGjkR.cjs +831 -0
- package/dist/PronunciationFeedback-DlMPGjkR.cjs.map +1 -0
- package/dist/PronunciationFeedback-Dxd6XPkO.js +772 -0
- package/dist/PronunciationFeedback-Dxd6XPkO.js.map +1 -0
- package/dist/ReadAloud-C8RhaUqV.js +1092 -0
- package/dist/{chunk-DC4CQCK5.js.map → ReadAloud-C8RhaUqV.js.map} +1 -1
- package/dist/ReadAloud-akDehitT.cjs +1103 -0
- package/dist/ReadAloud-akDehitT.cjs.map +1 -0
- package/dist/StimulusPanel-BgzByfkb.js +74 -0
- package/dist/StimulusPanel-BgzByfkb.js.map +1 -0
- package/dist/StimulusPanel-b66gaN3i.cjs +79 -0
- package/dist/StimulusPanel-b66gaN3i.cjs.map +1 -0
- package/dist/ThemeProvider-B6DyGqzh.d.cts +62 -0
- package/dist/ThemeProvider-B6DyGqzh.d.cts.map +1 -0
- package/dist/ThemeProvider-BCrxBaY-.d.ts +62 -0
- package/dist/ThemeProvider-BCrxBaY-.d.ts.map +1 -0
- package/dist/ThemeProvider-BRN1tj4n.cjs +187 -0
- package/dist/ThemeProvider-BRN1tj4n.cjs.map +1 -0
- package/dist/ThemeProvider-C7K9iMLX.js +158 -0
- package/dist/ThemeProvider-C7K9iMLX.js.map +1 -0
- package/dist/WrittenResponse-Bj3eFmGl.cjs +491 -0
- package/dist/WrittenResponse-Bj3eFmGl.cjs.map +1 -0
- package/dist/WrittenResponse-Cl8G6nO_.js +486 -0
- package/dist/WrittenResponse-Cl8G6nO_.js.map +1 -0
- package/dist/ai/LkAiProvider.cjs +34 -14
- package/dist/ai/LkAiProvider.cjs.map +1 -1
- package/dist/ai/LkAiProvider.d.cts +2 -83
- package/dist/ai/LkAiProvider.d.ts +2 -83
- package/dist/ai/LkAiProvider.js +33 -2
- package/dist/ai/LkAiProvider.js.map +1 -1
- package/dist/ai/useAiHelp.cjs +7 -25
- package/dist/ai/useAiHelp.d.cts +175 -177
- package/dist/ai/useAiHelp.d.cts.map +1 -0
- package/dist/ai/useAiHelp.d.ts +175 -177
- package/dist/ai/useAiHelp.d.ts.map +1 -0
- package/dist/ai/useAiHelp.js +3 -4
- package/dist/components/ActivityPreview.cjs +3 -29
- package/dist/components/ActivityPreview.d.cts +2 -81
- package/dist/components/ActivityPreview.d.ts +2 -81
- package/dist/components/ActivityPreview.js +2 -20
- package/dist/components/ActivitySequence.cjs +3 -30
- package/dist/components/ActivitySequence.d.cts +2 -432
- package/dist/components/ActivitySequence.d.ts +2 -432
- package/dist/components/ActivitySequence.js +2 -21
- package/dist/components/Dictation.cjs +4 -21
- package/dist/components/Dictation.d.cts +2 -19
- package/dist/components/Dictation.d.ts +2 -19
- package/dist/components/Dictation.js +3 -12
- package/dist/components/FillInTheBlanks.cjs +4 -20
- package/dist/components/FillInTheBlanks.d.cts +2 -30
- package/dist/components/FillInTheBlanks.d.ts +2 -30
- package/dist/components/FillInTheBlanks.js +3 -11
- package/dist/components/GapSelect.cjs +4 -20
- package/dist/components/GapSelect.d.cts +2 -27
- package/dist/components/GapSelect.d.ts +2 -27
- package/dist/components/GapSelect.js +3 -11
- package/dist/components/InteractiveVideo.cjs +5 -32
- package/dist/components/InteractiveVideo.d.cts +3 -291
- package/dist/components/InteractiveVideo.d.ts +3 -291
- package/dist/components/InteractiveVideo.js +3 -19
- package/dist/components/MultipleChoice.cjs +4 -20
- package/dist/components/MultipleChoice.d.cts +2 -26
- package/dist/components/MultipleChoice.d.ts +2 -26
- package/dist/components/MultipleChoice.js +3 -11
- package/dist/components/PronunciationFeedback.cjs +4 -17
- package/dist/components/PronunciationFeedback.d.cts +2 -90
- package/dist/components/PronunciationFeedback.d.ts +2 -90
- package/dist/components/PronunciationFeedback.js +3 -8
- package/dist/components/ReadAloud.cjs +4 -22
- package/dist/components/ReadAloud.d.cts +7 -11
- package/dist/components/ReadAloud.d.cts.map +1 -0
- package/dist/components/ReadAloud.d.ts +7 -11
- package/dist/components/ReadAloud.d.ts.map +1 -0
- package/dist/components/ReadAloud.js +3 -13
- package/dist/components/StimulusPanel.cjs +3 -14
- package/dist/components/StimulusPanel.d.cts +2 -51
- package/dist/components/StimulusPanel.d.ts +2 -51
- package/dist/components/StimulusPanel.js +2 -5
- package/dist/components/WrittenResponse.cjs +4 -19
- package/dist/components/WrittenResponse.d.cts +3 -18
- package/dist/components/WrittenResponse.d.ts +3 -18
- package/dist/components/WrittenResponse.js +3 -10
- package/dist/direction-Dq0_HtA5.js +183 -0
- package/dist/direction-Dq0_HtA5.js.map +1 -0
- package/dist/direction-K1pj0HCA.cjs +200 -0
- package/dist/direction-K1pj0HCA.cjs.map +1 -0
- package/dist/hooks/useActivityState.cjs +28 -10
- package/dist/hooks/useActivityState.cjs.map +1 -1
- package/dist/hooks/useActivityState.d.cts +25 -24
- package/dist/hooks/useActivityState.d.cts.map +1 -0
- package/dist/hooks/useActivityState.d.ts +25 -24
- package/dist/hooks/useActivityState.d.ts.map +1 -0
- package/dist/hooks/useActivityState.js +28 -2
- package/dist/hooks/useActivityState.js.map +1 -1
- package/dist/hooks/useSpeechRecorder.cjs +5 -16
- package/dist/hooks/useSpeechRecorder.d.cts +2 -127
- package/dist/hooks/useSpeechRecorder.d.ts +2 -127
- package/dist/hooks/useSpeechRecorder.js +3 -3
- package/dist/hooks/useXAPI.cjs +126 -10
- package/dist/hooks/useXAPI.cjs.map +1 -1
- package/dist/hooks/useXAPI.d.cts +14 -14
- package/dist/hooks/useXAPI.d.cts.map +1 -0
- package/dist/hooks/useXAPI.d.ts +14 -14
- package/dist/hooks/useXAPI.d.ts.map +1 -0
- package/dist/hooks/useXAPI.js +126 -2
- package/dist/hooks/useXAPI.js.map +1 -1
- package/dist/i18n/LkIntlProvider.cjs +9 -32
- package/dist/i18n/LkIntlProvider.d.cts +34 -38
- package/dist/i18n/LkIntlProvider.d.cts.map +1 -0
- package/dist/i18n/LkIntlProvider.d.ts +34 -38
- package/dist/i18n/LkIntlProvider.d.ts.map +1 -0
- package/dist/i18n/LkIntlProvider.js +3 -3
- package/dist/index-6P4rJHrJ.d.cts +432 -0
- package/dist/index-6P4rJHrJ.d.cts.map +1 -0
- package/dist/index-90CX504k.d.ts +90 -0
- package/dist/index-90CX504k.d.ts.map +1 -0
- package/dist/index-B_SgnjsC.d.cts +291 -0
- package/dist/index-B_SgnjsC.d.cts.map +1 -0
- package/dist/index-BavVW5z5.d.cts +25 -0
- package/dist/index-BavVW5z5.d.cts.map +1 -0
- package/dist/index-BhHv6Fiu.d.cts +28 -0
- package/dist/index-BhHv6Fiu.d.cts.map +1 -0
- package/dist/index-Bsu8VciA.d.ts +432 -0
- package/dist/index-Bsu8VciA.d.ts.map +1 -0
- package/dist/index-C1KGpD1k.d.ts +49 -0
- package/dist/index-C1KGpD1k.d.ts.map +1 -0
- package/dist/index-CKTpYTCC.d.cts +49 -0
- package/dist/index-CKTpYTCC.d.cts.map +1 -0
- package/dist/index-CO_SRFJj.d.ts +16 -0
- package/dist/index-CO_SRFJj.d.ts.map +1 -0
- package/dist/index-CSsuUsP7.d.cts +124 -0
- package/dist/index-CSsuUsP7.d.cts.map +1 -0
- package/dist/index-CunUyeuN.d.cts +78 -0
- package/dist/index-CunUyeuN.d.cts.map +1 -0
- package/dist/index-CzfRzks6.d.cts +90 -0
- package/dist/index-CzfRzks6.d.cts.map +1 -0
- package/dist/index-D14xtpBS.d.cts +24 -0
- package/dist/index-D14xtpBS.d.cts.map +1 -0
- package/dist/index-DHRPAPZf.d.ts +291 -0
- package/dist/index-DHRPAPZf.d.ts.map +1 -0
- package/dist/index-DLV6cK26.d.cts +16 -0
- package/dist/index-DLV6cK26.d.cts.map +1 -0
- package/dist/index-DbCCQ9cP.d.ts +124 -0
- package/dist/index-DbCCQ9cP.d.ts.map +1 -0
- package/dist/index-DeV5B2m5.d.ts +24 -0
- package/dist/index-DeV5B2m5.d.ts.map +1 -0
- package/dist/index-TZCJVSge.d.ts +28 -0
- package/dist/index-TZCJVSge.d.ts.map +1 -0
- package/dist/index-ZVvLHsd3.d.ts +25 -0
- package/dist/index-ZVvLHsd3.d.ts.map +1 -0
- package/dist/index-eQwBgpSw.d.ts +78 -0
- package/dist/index-eQwBgpSw.d.ts.map +1 -0
- package/dist/index.cjs +118 -163
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -24
- package/dist/index.d.ts +20 -24
- package/dist/index.js +84 -29
- package/dist/index.js.map +1 -1
- package/dist/theme/ThemeProvider.cjs +8 -28
- package/dist/theme/ThemeProvider.d.cts +2 -59
- package/dist/theme/ThemeProvider.d.ts +2 -59
- package/dist/theme/ThemeProvider.js +3 -3
- package/dist/tries-CbbtZkdG.cjs +363 -0
- package/dist/tries-CbbtZkdG.cjs.map +1 -0
- package/dist/tries-DTHSrLCm.js +310 -0
- package/dist/tries-DTHSrLCm.js.map +1 -0
- package/dist/types-BOQd93x3.d.cts +994 -0
- package/dist/types-BOQd93x3.d.cts.map +1 -0
- package/dist/types-HIrgF_mS.d.ts +994 -0
- package/dist/types-HIrgF_mS.d.ts.map +1 -0
- package/dist/useAiHelp-B-ccDL5Q.js +906 -0
- package/dist/useAiHelp-B-ccDL5Q.js.map +1 -0
- package/dist/useAiHelp-h9yOLFqt.cjs +995 -0
- package/dist/useAiHelp-h9yOLFqt.cjs.map +1 -0
- package/dist/useSpeechRecorder--O_f-xvt.cjs +720 -0
- package/dist/useSpeechRecorder--O_f-xvt.cjs.map +1 -0
- package/dist/useSpeechRecorder-DtAYvl02.d.cts +154 -0
- package/dist/useSpeechRecorder-DtAYvl02.d.cts.map +1 -0
- package/dist/useSpeechRecorder-DtAYvl02.d.ts +154 -0
- package/dist/useSpeechRecorder-DtAYvl02.d.ts.map +1 -0
- package/dist/useSpeechRecorder-zIi9Dlw8.js +709 -0
- package/dist/useSpeechRecorder-zIi9Dlw8.js.map +1 -0
- package/package.json +10 -11
- package/dist/WrittenResponse-B7A5gHMj.d.ts +0 -115
- package/dist/WrittenResponse-BpL9p2Hn.d.cts +0 -115
- package/dist/ai/useAiHelp.cjs.map +0 -1
- package/dist/ai/useAiHelp.js.map +0 -1
- package/dist/chunk-2PNSVBLQ.cjs +0 -54
- package/dist/chunk-2PNSVBLQ.cjs.map +0 -1
- package/dist/chunk-2TW6WLBC.js +0 -80
- package/dist/chunk-2TW6WLBC.js.map +0 -1
- package/dist/chunk-4ZZWZSWI.js +0 -61
- package/dist/chunk-4ZZWZSWI.js.map +0 -1
- package/dist/chunk-6N43WDVG.js +0 -29
- package/dist/chunk-6N43WDVG.js.map +0 -1
- package/dist/chunk-7ALBSPYR.cjs +0 -436
- package/dist/chunk-7ALBSPYR.cjs.map +0 -1
- package/dist/chunk-7NFY3M3W.js +0 -234
- package/dist/chunk-7NFY3M3W.js.map +0 -1
- package/dist/chunk-7SB2MJA4.js +0 -29
- package/dist/chunk-7SB2MJA4.js.map +0 -1
- package/dist/chunk-ABJJ3LII.js +0 -434
- package/dist/chunk-ABJJ3LII.js.map +0 -1
- package/dist/chunk-BIGGXHAW.cjs +0 -342
- package/dist/chunk-BIGGXHAW.cjs.map +0 -1
- package/dist/chunk-C7QZE5BI.js +0 -430
- package/dist/chunk-C7QZE5BI.js.map +0 -1
- package/dist/chunk-CWTUWNV3.cjs +0 -2937
- package/dist/chunk-CWTUWNV3.cjs.map +0 -1
- package/dist/chunk-DC4CQCK5.js +0 -915
- package/dist/chunk-DS6HN3YR.cjs +0 -496
- package/dist/chunk-DS6HN3YR.cjs.map +0 -1
- package/dist/chunk-FDQR4GAZ.js +0 -240
- package/dist/chunk-FDQR4GAZ.js.map +0 -1
- package/dist/chunk-FKGMSOJF.js +0 -394
- package/dist/chunk-FKGMSOJF.js.map +0 -1
- package/dist/chunk-G6PXJ2SA.js +0 -823
- package/dist/chunk-G6PXJ2SA.js.map +0 -1
- package/dist/chunk-HNLRVRQR.js +0 -335
- package/dist/chunk-HNLRVRQR.js.map +0 -1
- package/dist/chunk-J3C72RPK.cjs +0 -31
- package/dist/chunk-J3C72RPK.cjs.map +0 -1
- package/dist/chunk-K63VPFOV.cjs +0 -134
- package/dist/chunk-K63VPFOV.cjs.map +0 -1
- package/dist/chunk-KFLIDTDU.cjs +0 -825
- package/dist/chunk-KFLIDTDU.cjs.map +0 -1
- package/dist/chunk-KHKH5LCT.cjs +0 -250
- package/dist/chunk-KHKH5LCT.cjs.map +0 -1
- package/dist/chunk-LBEKRN6Y.cjs +0 -664
- package/dist/chunk-LBEKRN6Y.cjs.map +0 -1
- package/dist/chunk-LPUWXIPR.cjs +0 -578
- package/dist/chunk-LPUWXIPR.cjs.map +0 -1
- package/dist/chunk-MCO52PV5.js +0 -520
- package/dist/chunk-MCO52PV5.js.map +0 -1
- package/dist/chunk-MKXEM2C5.cjs +0 -396
- package/dist/chunk-MKXEM2C5.cjs.map +0 -1
- package/dist/chunk-MXNC5UNO.js +0 -563
- package/dist/chunk-MXNC5UNO.js.map +0 -1
- package/dist/chunk-NAK55BYU.cjs +0 -859
- package/dist/chunk-NAK55BYU.cjs.map +0 -1
- package/dist/chunk-NDBMKGQ5.cjs +0 -599
- package/dist/chunk-NDBMKGQ5.cjs.map +0 -1
- package/dist/chunk-OD65OZIW.cjs +0 -159
- package/dist/chunk-OD65OZIW.cjs.map +0 -1
- package/dist/chunk-ORT3KLGI.js +0 -50
- package/dist/chunk-ORT3KLGI.js.map +0 -1
- package/dist/chunk-PNNUPUH4.cjs +0 -82
- package/dist/chunk-PNNUPUH4.cjs.map +0 -1
- package/dist/chunk-PWHECW6N.js +0 -2934
- package/dist/chunk-PWHECW6N.js.map +0 -1
- package/dist/chunk-RBS5A2DF.js +0 -857
- package/dist/chunk-RBS5A2DF.js.map +0 -1
- package/dist/chunk-RPWUDXG5.cjs +0 -63
- package/dist/chunk-RPWUDXG5.cjs.map +0 -1
- package/dist/chunk-TCDJY3H6.js +0 -494
- package/dist/chunk-TCDJY3H6.js.map +0 -1
- package/dist/chunk-TP5UMRAU.cjs +0 -918
- package/dist/chunk-TP5UMRAU.cjs.map +0 -1
- package/dist/chunk-TS7J6DTY.js +0 -597
- package/dist/chunk-TS7J6DTY.js.map +0 -1
- package/dist/chunk-TUVENMXB.cjs +0 -20
- package/dist/chunk-TUVENMXB.cjs.map +0 -1
- package/dist/chunk-UDCQTOVU.cjs +0 -523
- package/dist/chunk-UDCQTOVU.cjs.map +0 -1
- package/dist/chunk-UHKZXOXD.js +0 -128
- package/dist/chunk-UHKZXOXD.js.map +0 -1
- package/dist/chunk-V6BA44DS.cjs +0 -236
- package/dist/chunk-V6BA44DS.cjs.map +0 -1
- package/dist/chunk-XO5R73Y4.js +0 -653
- package/dist/chunk-XO5R73Y4.js.map +0 -1
- package/dist/chunk-XOR4MPEN.cjs +0 -31
- package/dist/chunk-XOR4MPEN.cjs.map +0 -1
- package/dist/chunk-YORQQXGO.js +0 -155
- package/dist/chunk-YORQQXGO.js.map +0 -1
- package/dist/chunk-YY4PFR3Z.cjs +0 -432
- package/dist/chunk-YY4PFR3Z.cjs.map +0 -1
- package/dist/chunk-ZPI6Q42R.js +0 -17
- package/dist/chunk-ZPI6Q42R.js.map +0 -1
- package/dist/components/ActivityPreview.cjs.map +0 -1
- package/dist/components/ActivityPreview.js.map +0 -1
- package/dist/components/ActivitySequence.cjs.map +0 -1
- package/dist/components/ActivitySequence.js.map +0 -1
- package/dist/components/Dictation.cjs.map +0 -1
- package/dist/components/Dictation.js.map +0 -1
- package/dist/components/FillInTheBlanks.cjs.map +0 -1
- package/dist/components/FillInTheBlanks.js.map +0 -1
- package/dist/components/GapSelect.cjs.map +0 -1
- package/dist/components/GapSelect.js.map +0 -1
- package/dist/components/InteractiveVideo.cjs.map +0 -1
- package/dist/components/InteractiveVideo.js.map +0 -1
- package/dist/components/MultipleChoice.cjs.map +0 -1
- package/dist/components/MultipleChoice.js.map +0 -1
- package/dist/components/PronunciationFeedback.cjs.map +0 -1
- package/dist/components/PronunciationFeedback.js.map +0 -1
- package/dist/components/ReadAloud.cjs.map +0 -1
- package/dist/components/ReadAloud.js.map +0 -1
- package/dist/components/StimulusPanel.cjs.map +0 -1
- package/dist/components/StimulusPanel.js.map +0 -1
- package/dist/components/WrittenResponse.cjs.map +0 -1
- package/dist/components/WrittenResponse.js.map +0 -1
- package/dist/hooks/useSpeechRecorder.cjs.map +0 -1
- package/dist/hooks/useSpeechRecorder.js.map +0 -1
- package/dist/i18n/LkIntlProvider.cjs.map +0 -1
- package/dist/i18n/LkIntlProvider.js.map +0 -1
- package/dist/machine-CuwKqWXC.d.cts +0 -26
- package/dist/machine-CuwKqWXC.d.ts +0 -26
- package/dist/theme/ThemeProvider.cjs.map +0 -1
- package/dist/theme/ThemeProvider.js.map +0 -1
- package/dist/types-C4IsUQsK.d.ts +0 -989
- package/dist/types-CJgZWtl4.d.cts +0 -989
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
//#region src/hooks/speech-recorder/machine.ts
|
|
4
|
+
/** The state a recorder mounts in, on a server as on a client. */
|
|
5
|
+
const IDLE_RECORDER = {
|
|
6
|
+
status: "idle",
|
|
7
|
+
error: null,
|
|
8
|
+
level: 0,
|
|
9
|
+
elapsedMs: 0,
|
|
10
|
+
take: null,
|
|
11
|
+
takesUsed: 0
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Applies one event. Returns the state it was given, unchanged and identical,
|
|
15
|
+
* when the event moves nothing — so a block arriving after teardown costs no
|
|
16
|
+
* render.
|
|
17
|
+
*/
|
|
18
|
+
function reduceRecorder(state, event) {
|
|
19
|
+
switch (event.kind) {
|
|
20
|
+
case "requested": return {
|
|
21
|
+
...state,
|
|
22
|
+
status: "requesting-permission",
|
|
23
|
+
error: null,
|
|
24
|
+
level: 0,
|
|
25
|
+
elapsedMs: 0,
|
|
26
|
+
take: null
|
|
27
|
+
};
|
|
28
|
+
case "started": return state.status === "requesting-permission" ? {
|
|
29
|
+
...state,
|
|
30
|
+
status: "recording"
|
|
31
|
+
} : state;
|
|
32
|
+
case "progress": return state.status === "recording" ? {
|
|
33
|
+
...state,
|
|
34
|
+
level: event.level,
|
|
35
|
+
elapsedMs: event.elapsedMs
|
|
36
|
+
} : state;
|
|
37
|
+
case "captured": return {
|
|
38
|
+
...state,
|
|
39
|
+
status: "recorded",
|
|
40
|
+
error: null,
|
|
41
|
+
level: 0,
|
|
42
|
+
elapsedMs: event.take.durationMs,
|
|
43
|
+
take: event.take,
|
|
44
|
+
takesUsed: state.takesUsed + 1
|
|
45
|
+
};
|
|
46
|
+
case "failed": return {
|
|
47
|
+
...state,
|
|
48
|
+
status: "error",
|
|
49
|
+
error: event.error,
|
|
50
|
+
level: 0,
|
|
51
|
+
take: null
|
|
52
|
+
};
|
|
53
|
+
case "discarded": return {
|
|
54
|
+
...state,
|
|
55
|
+
status: "idle",
|
|
56
|
+
error: null,
|
|
57
|
+
level: 0,
|
|
58
|
+
elapsedMs: 0,
|
|
59
|
+
take: null
|
|
60
|
+
};
|
|
61
|
+
case "reset": return state === IDLE_RECORDER ? state : IDLE_RECORDER;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/hooks/speech-recorder/resample.ts
|
|
66
|
+
/**
|
|
67
|
+
* Turning the blocks a capture node delivers into one mono 16 kHz signal.
|
|
68
|
+
*
|
|
69
|
+
* 16 kHz mono is the rate every mainstream assessor accepts and the rate
|
|
70
|
+
* `inspectWav` measures exactly, while a microphone is opened at whatever rate
|
|
71
|
+
* the device runs at — 44.1 or 48 kHz on most, already 16 kHz on some. The
|
|
72
|
+
* three steps are separated and pure so the arithmetic that decides what a
|
|
73
|
+
* server will measure can be tested without a browser.
|
|
74
|
+
*/
|
|
75
|
+
/** The capture rate every mainstream assessor accepts. */
|
|
76
|
+
const TARGET_SAMPLE_RATE = 16e3;
|
|
77
|
+
/**
|
|
78
|
+
* Where the anti-alias filter stops passing, as a fraction of the target rate.
|
|
79
|
+
*
|
|
80
|
+
* Below 0.5 on purpose: a filter cutting at exactly the target's Nyquist is
|
|
81
|
+
* only 6 dB down there and its transition band folds straight back into the
|
|
82
|
+
* signal. Cutting at 7.2 kHz leaves the transition band room to finish before
|
|
83
|
+
* 8 kHz, and speech carries next to nothing in the 800 Hz that costs.
|
|
84
|
+
*/
|
|
85
|
+
const CUTOFF_FRACTION = .45;
|
|
86
|
+
/**
|
|
87
|
+
* Half the kernel width, counted in target-rate sample periods. Sixteen
|
|
88
|
+
* periods of taps is enough Hann-windowed sinc to put a tone an octave above
|
|
89
|
+
* the cutoff far below anything a meter or an assessor would notice, and short
|
|
90
|
+
* enough that filtering a whole take costs a fraction of a second.
|
|
91
|
+
*/
|
|
92
|
+
const KERNEL_HALF_PERIODS = 8;
|
|
93
|
+
/**
|
|
94
|
+
* Averages the channels of one capture block into mono.
|
|
95
|
+
*
|
|
96
|
+
* Always allocates, never returns what it was given: a `ScriptProcessorNode`
|
|
97
|
+
* hands out the same buffer on every callback and overwrites it in place, so a
|
|
98
|
+
* mono capture that passed the input straight through would find every block
|
|
99
|
+
* it had kept replaced by the last one.
|
|
100
|
+
*
|
|
101
|
+
* Channels of unequal length are read to the shortest. Nothing should produce
|
|
102
|
+
* them, and the alternative to a bound is reading past the end of one and
|
|
103
|
+
* poisoning the whole take with NaN.
|
|
104
|
+
*/
|
|
105
|
+
function downmix(channels) {
|
|
106
|
+
if (channels.length === 0) return /* @__PURE__ */ new Float32Array(0);
|
|
107
|
+
let frames = channels[0].length;
|
|
108
|
+
for (const channel of channels) if (channel.length < frames) frames = channel.length;
|
|
109
|
+
const mono = new Float32Array(frames);
|
|
110
|
+
for (const channel of channels) for (let frame = 0; frame < frames; frame += 1) mono[frame] += channel[frame];
|
|
111
|
+
for (let frame = 0; frame < frames; frame += 1) mono[frame] /= channels.length;
|
|
112
|
+
return mono;
|
|
113
|
+
}
|
|
114
|
+
/** Concatenates captured blocks into the single signal a take is encoded from. */
|
|
115
|
+
function joinBlocks(blocks, frames) {
|
|
116
|
+
const joined = new Float32Array(frames);
|
|
117
|
+
let at = 0;
|
|
118
|
+
for (const block of blocks) {
|
|
119
|
+
joined.set(block, at);
|
|
120
|
+
at += block.length;
|
|
121
|
+
}
|
|
122
|
+
return joined;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* A windowed-sinc low-pass, applied at the input rate before any samples are
|
|
126
|
+
* dropped.
|
|
127
|
+
*
|
|
128
|
+
* Without it, resampling is aliasing: every component above the target's
|
|
129
|
+
* Nyquist folds back down into the speech band as a tone that was never
|
|
130
|
+
* spoken, and an assessor scores what it hears. The window is Hann and the
|
|
131
|
+
* kernel is normalised to unit gain, so a signal already inside the band comes
|
|
132
|
+
* through at its own amplitude.
|
|
133
|
+
*
|
|
134
|
+
* Edges are extended by repeating the first and last sample rather than by
|
|
135
|
+
* zero-padding, which would put a step — and so a click — at each end of every
|
|
136
|
+
* take.
|
|
137
|
+
*/
|
|
138
|
+
function lowPass(samples, inputRate) {
|
|
139
|
+
const cutoff = TARGET_SAMPLE_RATE * CUTOFF_FRACTION / inputRate;
|
|
140
|
+
const half = Math.max(1, Math.round(KERNEL_HALF_PERIODS * inputRate / TARGET_SAMPLE_RATE));
|
|
141
|
+
const taps = new Float32Array(half * 2 + 1);
|
|
142
|
+
let gain = 0;
|
|
143
|
+
for (let tap = 0; tap < taps.length; tap += 1) {
|
|
144
|
+
const at = tap - half;
|
|
145
|
+
const angle = 2 * Math.PI * cutoff * at;
|
|
146
|
+
const sinc = at === 0 ? 2 * cutoff : Math.sin(angle) / (Math.PI * at);
|
|
147
|
+
const window = .5 - .5 * Math.cos(2 * Math.PI * tap / (taps.length - 1));
|
|
148
|
+
taps[tap] = sinc * window;
|
|
149
|
+
gain += taps[tap];
|
|
150
|
+
}
|
|
151
|
+
for (let tap = 0; tap < taps.length; tap += 1) taps[tap] /= gain;
|
|
152
|
+
const last = samples.length - 1;
|
|
153
|
+
const filtered = new Float32Array(samples.length);
|
|
154
|
+
for (let index = 0; index < samples.length; index += 1) {
|
|
155
|
+
let total = 0;
|
|
156
|
+
for (let tap = 0; tap < taps.length; tap += 1) {
|
|
157
|
+
const at = index + tap - half;
|
|
158
|
+
total += taps[tap] * samples[at < 0 ? 0 : at > last ? last : at];
|
|
159
|
+
}
|
|
160
|
+
filtered[index] = total;
|
|
161
|
+
}
|
|
162
|
+
return filtered;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Resamples mono samples from `inputRate` to {@link TARGET_SAMPLE_RATE}.
|
|
166
|
+
*
|
|
167
|
+
* Every path returns an array the caller owns, the equal-rate one included, so
|
|
168
|
+
* no caller has to know whether it was handed its own buffer back.
|
|
169
|
+
*
|
|
170
|
+
* The filter runs only when the rate is coming down. Interpolating upwards
|
|
171
|
+
* invents no frequencies above the input's own Nyquist, so there is nothing
|
|
172
|
+
* for an anti-alias filter to remove and applying one would only dull a
|
|
173
|
+
* recording a 16 kHz device already captured cleanly.
|
|
174
|
+
*/
|
|
175
|
+
function resampleToTarget(samples, inputRate) {
|
|
176
|
+
if (samples.length === 0) return /* @__PURE__ */ new Float32Array(0);
|
|
177
|
+
if (inputRate === 16e3) return samples.slice();
|
|
178
|
+
const source = inputRate > 16e3 ? lowPass(samples, inputRate) : samples;
|
|
179
|
+
const length = Math.round(samples.length * TARGET_SAMPLE_RATE / inputRate);
|
|
180
|
+
const resampled = new Float32Array(length);
|
|
181
|
+
const step = inputRate / TARGET_SAMPLE_RATE;
|
|
182
|
+
const last = source.length - 1;
|
|
183
|
+
for (let index = 0; index < length; index += 1) {
|
|
184
|
+
const at = index * step;
|
|
185
|
+
const left = Math.min(Math.floor(at), last);
|
|
186
|
+
const right = Math.min(left + 1, last);
|
|
187
|
+
const fraction = at - left;
|
|
188
|
+
resampled[index] = source[left] * (1 - fraction) + source[right] * fraction;
|
|
189
|
+
}
|
|
190
|
+
return resampled;
|
|
191
|
+
}
|
|
192
|
+
//#endregion
|
|
193
|
+
//#region src/hooks/speech-recorder/wav.ts
|
|
194
|
+
/**
|
|
195
|
+
* 16-bit PCM WAV, and the two level measurements taken over the same samples.
|
|
196
|
+
*
|
|
197
|
+
* This is the one format the SDK's `inspectWav` reads, so these bytes are the
|
|
198
|
+
* bytes a server measures a take with — anything else would need a decoder on
|
|
199
|
+
* the application's side before a grade could exist. Nothing here touches Web
|
|
200
|
+
* Audio, which is why the encoder can be unit tested against `inspectWav`
|
|
201
|
+
* itself rather than against a browser.
|
|
202
|
+
*/
|
|
203
|
+
/** The media type of what {@link encodeWav16} returns. */
|
|
204
|
+
const WAV_MIME_TYPE = "audio/wav";
|
|
205
|
+
/** RIFF + a 16-byte `fmt ` + `data`, every field fixed-size. */
|
|
206
|
+
const HEADER_BYTES = 44;
|
|
207
|
+
/** Writes four ASCII characters, which is all a RIFF tag ever is. */
|
|
208
|
+
function writeTag(view, offset, tag) {
|
|
209
|
+
for (let index = 0; index < tag.length; index += 1) view.setUint8(offset + index, tag.charCodeAt(index));
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Encodes mono samples as a 16-bit PCM WAV.
|
|
213
|
+
*
|
|
214
|
+
* The two scale factors are not a typo. Signed 16-bit runs from -32768 to
|
|
215
|
+
* 32767, so a single factor cannot reach full scale in both directions: 0x7fff
|
|
216
|
+
* throughout leaves a full-scale negative peak reading -0.99997, and 0x8000
|
|
217
|
+
* throughout overflows into the positive at +1. Each sign gets the factor that
|
|
218
|
+
* lands exactly on its own limit.
|
|
219
|
+
*
|
|
220
|
+
* The buffer is named in the return type because a `Blob` refuses a view that
|
|
221
|
+
* might be backed by a `SharedArrayBuffer`, and these bytes go straight into
|
|
222
|
+
* one.
|
|
223
|
+
*/
|
|
224
|
+
function encodeWav16(samples, sampleRate) {
|
|
225
|
+
const dataBytes = samples.length * 2;
|
|
226
|
+
const bytes = new Uint8Array(HEADER_BYTES + dataBytes);
|
|
227
|
+
const view = new DataView(bytes.buffer);
|
|
228
|
+
writeTag(view, 0, "RIFF");
|
|
229
|
+
view.setUint32(4, 36 + dataBytes, true);
|
|
230
|
+
writeTag(view, 8, "WAVE");
|
|
231
|
+
writeTag(view, 12, "fmt ");
|
|
232
|
+
view.setUint32(16, 16, true);
|
|
233
|
+
view.setUint16(20, 1, true);
|
|
234
|
+
view.setUint16(22, 1, true);
|
|
235
|
+
view.setUint32(24, sampleRate, true);
|
|
236
|
+
view.setUint32(28, sampleRate * 2, true);
|
|
237
|
+
view.setUint16(32, 2, true);
|
|
238
|
+
view.setUint16(34, 16, true);
|
|
239
|
+
writeTag(view, 36, "data");
|
|
240
|
+
view.setUint32(40, dataBytes, true);
|
|
241
|
+
for (let index = 0; index < samples.length; index += 1) {
|
|
242
|
+
const sample = samples[index];
|
|
243
|
+
const clamped = sample < -1 ? -1 : sample > 1 ? 1 : sample;
|
|
244
|
+
view.setInt16(HEADER_BYTES + index * 2, Math.round(clamped * (clamped < 0 ? 32768 : 32767)), true);
|
|
245
|
+
}
|
|
246
|
+
return bytes;
|
|
247
|
+
}
|
|
248
|
+
/** The loudest sample, 0..1 — what a take reports as its `peakLevel`. */
|
|
249
|
+
function peakOf(samples) {
|
|
250
|
+
let peak = 0;
|
|
251
|
+
for (let index = 0; index < samples.length; index += 1) {
|
|
252
|
+
const magnitude = Math.abs(samples[index]);
|
|
253
|
+
if (magnitude > peak) peak = magnitude;
|
|
254
|
+
}
|
|
255
|
+
return peak > 1 ? 1 : peak;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Root mean square, 0..1 — what the recorder reports as its `level`.
|
|
259
|
+
*
|
|
260
|
+
* RMS rather than the peak, because a meter driven by peaks sits near full
|
|
261
|
+
* scale on any speech at all and tells a learner nothing about whether they
|
|
262
|
+
* are being heard. It is also the measure `inspectWav` decides voiced time
|
|
263
|
+
* with, so what the meter shows and what the server later counts as speech are
|
|
264
|
+
* the same quantity.
|
|
265
|
+
*/
|
|
266
|
+
function rmsOf(samples) {
|
|
267
|
+
if (samples.length === 0) return 0;
|
|
268
|
+
let total = 0;
|
|
269
|
+
for (let index = 0; index < samples.length; index += 1) total += samples[index] * samples[index];
|
|
270
|
+
const rms = Math.sqrt(total / samples.length);
|
|
271
|
+
return rms > 1 ? 1 : rms;
|
|
272
|
+
}
|
|
273
|
+
//#endregion
|
|
274
|
+
//#region src/hooks/speech-recorder/worklet.ts
|
|
275
|
+
/**
|
|
276
|
+
* The audio worklet processor, and the module URL it is loaded through.
|
|
277
|
+
*
|
|
278
|
+
* The processor must run inside `AudioWorkletGlobalScope`, which can only be
|
|
279
|
+
* reached by URL. Source text turned into a Blob URL is the only form of that
|
|
280
|
+
* which needs nothing of the consuming application: `import.meta` is not
|
|
281
|
+
* available in every target this package is bundled for — the CommonJS build
|
|
282
|
+
* among them — and a separate asset file would have to be copied into the
|
|
283
|
+
* public directory of every application that installs the SDK.
|
|
284
|
+
*
|
|
285
|
+
* An application whose Content-Security-Policy refuses that URL self-hosts the
|
|
286
|
+
* same source and passes its URL as `workletUrl` instead. The source is public
|
|
287
|
+
* for exactly that reason — see {@link CAPTURE_PROCESSOR_SOURCE}.
|
|
288
|
+
*/
|
|
289
|
+
/** The name the processor registers itself under, and is constructed by. */
|
|
290
|
+
const CAPTURE_PROCESSOR = "lk-speech-capture";
|
|
291
|
+
/**
|
|
292
|
+
* The audio worklet module `useSpeechRecorder` captures through, as source
|
|
293
|
+
* text — exported so an application with a strict Content-Security-Policy can
|
|
294
|
+
* serve it itself.
|
|
295
|
+
*
|
|
296
|
+
* By default the hook loads this text from a `blob:` URL. A worklet module is
|
|
297
|
+
* loaded as a script, so it is `script-src` that must allow `blob:` —
|
|
298
|
+
* `worker-src` does not govern it. Where that is refused, write this string to
|
|
299
|
+
* a file your policy's `script-src` does allow, at build time and from the
|
|
300
|
+
* installed package so it moves with the version you run, and pass that file's
|
|
301
|
+
* URL as `workletUrl`:
|
|
302
|
+
*
|
|
303
|
+
* ```ts
|
|
304
|
+
* import { writeFileSync } from 'node:fs';
|
|
305
|
+
* import { CAPTURE_PROCESSOR_SOURCE } from '@intellectif/lk-react/hooks/useSpeechRecorder';
|
|
306
|
+
*
|
|
307
|
+
* writeFileSync('public/lk-speech-capture.js', CAPTURE_PROCESSOR_SOURCE);
|
|
308
|
+
* ```
|
|
309
|
+
*
|
|
310
|
+
* **The contract** a module at `workletUrl` must meet, which this source does:
|
|
311
|
+
* register an `AudioWorkletProcessor` under the name `lk-speech-capture`, and
|
|
312
|
+
* from its `process(inputs)` post the first input's channels to its `port` as
|
|
313
|
+
* an array with one `Float32Array` per channel, **copied** — the render
|
|
314
|
+
* quantum's buffers belong to the audio thread and are overwritten on the next
|
|
315
|
+
* quantum, so a block posted without copying arrives as whatever the
|
|
316
|
+
* microphone heard some milliseconds later. Return `true`, which keeps the
|
|
317
|
+
* processor alive while its source is silent. Anything else changes what is
|
|
318
|
+
* recorded, so serve this text unchanged rather than a copy of your own.
|
|
319
|
+
*/
|
|
320
|
+
const CAPTURE_PROCESSOR_SOURCE = `class LkSpeechCapture extends AudioWorkletProcessor {
|
|
321
|
+
process(inputs) {
|
|
322
|
+
const input = inputs[0];
|
|
323
|
+
if (input === undefined || input.length === 0) {
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
const block = [];
|
|
327
|
+
for (let channel = 0; channel < input.length; channel += 1) {
|
|
328
|
+
block.push(input[channel].slice());
|
|
329
|
+
}
|
|
330
|
+
this.port.postMessage(block);
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
registerProcessor(${JSON.stringify(CAPTURE_PROCESSOR)}, LkSpeechCapture);
|
|
335
|
+
`;
|
|
336
|
+
/**
|
|
337
|
+
* Wraps {@link CAPTURE_PROCESSOR_SOURCE} in an object URL.
|
|
338
|
+
*
|
|
339
|
+
* Returns `null` where object URLs are unavailable — a server render, a test
|
|
340
|
+
* environment, a hardened embedder — rather than throwing, because the caller
|
|
341
|
+
* has a working fallback for exactly that case and a missing URL factory is
|
|
342
|
+
* not a failed recording.
|
|
343
|
+
*
|
|
344
|
+
* Called from `start()` and never at module scope: `URL.createObjectURL` is a
|
|
345
|
+
* browser global, and reading one while a module is evaluated is what breaks a
|
|
346
|
+
* server render.
|
|
347
|
+
*/
|
|
348
|
+
function createProcessorUrl() {
|
|
349
|
+
const urls = globalThis.URL;
|
|
350
|
+
if (urls === void 0) return null;
|
|
351
|
+
const create = urls.createObjectURL;
|
|
352
|
+
const revoke = urls.revokeObjectURL;
|
|
353
|
+
if (create === void 0 || revoke === void 0) return null;
|
|
354
|
+
const url = create.call(urls, new Blob([CAPTURE_PROCESSOR_SOURCE], { type: "text/javascript" }));
|
|
355
|
+
return {
|
|
356
|
+
url,
|
|
357
|
+
release: () => {
|
|
358
|
+
revoke.call(urls, url);
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region src/hooks/useSpeechRecorder.ts
|
|
364
|
+
/**
|
|
365
|
+
* A `ScriptProcessorNode`'s buffer, in frames.
|
|
366
|
+
*
|
|
367
|
+
* This fallback runs its callback on the main thread, so the buffer has to
|
|
368
|
+
* hold whatever a render blocks it for; 4096 frames is about 85 ms at 48 kHz,
|
|
369
|
+
* and a smaller one drops samples out of the middle of a take whenever the
|
|
370
|
+
* page is busy. The worklet path, which runs on the audio thread, needs none
|
|
371
|
+
* of this.
|
|
372
|
+
*/
|
|
373
|
+
const PROCESSOR_BUFFER_FRAMES = 4096;
|
|
374
|
+
/** How often the meter and the elapsed time may move. */
|
|
375
|
+
const LEVEL_UPDATES_PER_SECOND = 10;
|
|
376
|
+
function stopTracks(stream) {
|
|
377
|
+
for (const track of stream.getTracks()) track.stop();
|
|
378
|
+
}
|
|
379
|
+
function closeQuietly(context) {
|
|
380
|
+
context.close().catch(() => {});
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Which refusal `getUserMedia` made.
|
|
384
|
+
*
|
|
385
|
+
* Read from `name`, not from the message: messages are localised and are
|
|
386
|
+
* rewritten between browser versions, while the names are specified. Anything
|
|
387
|
+
* unrecognised is `failed` rather than a guess — telling a learner their
|
|
388
|
+
* permission was denied when their device was busy sends them to the wrong
|
|
389
|
+
* setting.
|
|
390
|
+
*/
|
|
391
|
+
function microphoneError(error) {
|
|
392
|
+
switch (error?.name) {
|
|
393
|
+
case "NotAllowedError":
|
|
394
|
+
case "SecurityError": return "permission-denied";
|
|
395
|
+
case "NotFoundError": return "no-device";
|
|
396
|
+
case "NotSupportedError": return "unsupported";
|
|
397
|
+
default: return "failed";
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The worklet capture node, or `null` when this engine cannot build one.
|
|
402
|
+
*
|
|
403
|
+
* `null` rather than a throw for every one of its reasons — no
|
|
404
|
+
* `AudioWorkletNode`, no worklet thread, an object URL the page's
|
|
405
|
+
* Content-Security-Policy refuses, a module that fails to load — because none
|
|
406
|
+
* of them is a failed recording: the caller has a working fallback for all of
|
|
407
|
+
* them, and only the absence of both is an unsupported browser.
|
|
408
|
+
*/
|
|
409
|
+
async function createWorkletNode(context, workletUrl, onBlock) {
|
|
410
|
+
const AudioWorkletNodeConstructor = globalThis.AudioWorkletNode;
|
|
411
|
+
const worklet = context.audioWorklet;
|
|
412
|
+
if (AudioWorkletNodeConstructor === void 0 || worklet === void 0) return null;
|
|
413
|
+
const minted = workletUrl === void 0 ? createProcessorUrl() : null;
|
|
414
|
+
const url = workletUrl ?? minted?.url;
|
|
415
|
+
if (url === void 0) return null;
|
|
416
|
+
try {
|
|
417
|
+
await worklet.addModule(url);
|
|
418
|
+
const node = new AudioWorkletNodeConstructor(context, CAPTURE_PROCESSOR);
|
|
419
|
+
node.port.onmessage = (event) => {
|
|
420
|
+
onBlock(event.data);
|
|
421
|
+
};
|
|
422
|
+
return {
|
|
423
|
+
node,
|
|
424
|
+
detach: () => {
|
|
425
|
+
node.port.onmessage = null;
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
} catch {
|
|
429
|
+
return null;
|
|
430
|
+
} finally {
|
|
431
|
+
minted?.release();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/** The `ScriptProcessorNode` fallback, or `null` on an engine without one. */
|
|
435
|
+
function createProcessorNode(context, onBlock) {
|
|
436
|
+
const create = context.createScriptProcessor;
|
|
437
|
+
if (create === void 0) return null;
|
|
438
|
+
const node = create.call(context, PROCESSOR_BUFFER_FRAMES, 1, 1);
|
|
439
|
+
node.onaudioprocess = (event) => {
|
|
440
|
+
const buffer = event.inputBuffer;
|
|
441
|
+
const channels = [];
|
|
442
|
+
for (let channel = 0; channel < buffer.numberOfChannels; channel += 1) channels.push(buffer.getChannelData(channel));
|
|
443
|
+
onBlock(channels);
|
|
444
|
+
};
|
|
445
|
+
return {
|
|
446
|
+
node,
|
|
447
|
+
detach: () => {
|
|
448
|
+
node.onaudioprocess = null;
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Records a take of speech as mono 16-bit PCM WAV at 16 kHz.
|
|
454
|
+
*
|
|
455
|
+
* That is the one format the SDK measures directly: `inspectWav` reads it
|
|
456
|
+
* without a decoding library, and it is what every mainstream pronunciation
|
|
457
|
+
* assessor accepts. A take is captured at whatever rate the device runs at,
|
|
458
|
+
* downmixed to mono, low-pass filtered and resampled, then encoded — so what a
|
|
459
|
+
* server measures is what this hook produced, not a claim about it.
|
|
460
|
+
*
|
|
461
|
+
* **Server rendering.** `status`, `error` and `canRecord` are derived from the
|
|
462
|
+
* take count alone and never from a browser global, so the markup a server
|
|
463
|
+
* produces is the markup the browser's first render produces. Support is
|
|
464
|
+
* discovered inside {@link SpeechRecorder.start} and reported as
|
|
465
|
+
* `error: 'unsupported'`; the obvious `!!navigator.mediaDevices` guard at
|
|
466
|
+
* render time would make a server render and its hydration disagree.
|
|
467
|
+
*
|
|
468
|
+
* **Identity.** `start`, `stop`, `discard` and `reset` keep one identity for the
|
|
469
|
+
* life of the hook, so they are safe in a dependency array. The returned object keeps
|
|
470
|
+
* one identity for as long as none of its values changes, so a render caused
|
|
471
|
+
* by something else does not invalidate an effect that depends on it.
|
|
472
|
+
*
|
|
473
|
+
* `start()` does nothing when no take remains, so `canRecord` is presentation:
|
|
474
|
+
* mark the control `aria-disabled` rather than `disabled`, which would take it
|
|
475
|
+
* out of the tab order and leave a learner nothing to read.
|
|
476
|
+
*
|
|
477
|
+
* ```tsx
|
|
478
|
+
* const recorder = useSpeechRecorder({ maxDurationMs: 20_000, maxTakes: 3 });
|
|
479
|
+
* <button
|
|
480
|
+
* type="button"
|
|
481
|
+
* aria-disabled={!recorder.canRecord}
|
|
482
|
+
* onClick={() => {
|
|
483
|
+
* void recorder.start();
|
|
484
|
+
* }}
|
|
485
|
+
* >
|
|
486
|
+
* Record
|
|
487
|
+
* </button>
|
|
488
|
+
* ```
|
|
489
|
+
*/
|
|
490
|
+
function useSpeechRecorder(options) {
|
|
491
|
+
const optionsRef = useRef(options);
|
|
492
|
+
optionsRef.current = options;
|
|
493
|
+
const [state, setState] = useState(IDLE_RECORDER);
|
|
494
|
+
const stateRef = useRef(IDLE_RECORDER);
|
|
495
|
+
const sessionRef = useRef(null);
|
|
496
|
+
const startingRef = useRef(false);
|
|
497
|
+
const attemptRef = useRef(0);
|
|
498
|
+
const dispatch = useCallback((event) => {
|
|
499
|
+
const next = reduceRecorder(stateRef.current, event);
|
|
500
|
+
if (next === stateRef.current) return;
|
|
501
|
+
stateRef.current = next;
|
|
502
|
+
setState(next);
|
|
503
|
+
}, []);
|
|
504
|
+
const endSession = useCallback(() => {
|
|
505
|
+
const session = sessionRef.current;
|
|
506
|
+
sessionRef.current = null;
|
|
507
|
+
session?.release();
|
|
508
|
+
}, []);
|
|
509
|
+
const finish = useCallback(() => {
|
|
510
|
+
const session = sessionRef.current;
|
|
511
|
+
if (session === null) return;
|
|
512
|
+
sessionRef.current = null;
|
|
513
|
+
session.release();
|
|
514
|
+
const { minDurationMs } = optionsRef.current;
|
|
515
|
+
const samples = resampleToTarget(joinBlocks(session.blocks, session.frames), session.rate);
|
|
516
|
+
const durationMs = samples.length / TARGET_SAMPLE_RATE * 1e3;
|
|
517
|
+
if (minDurationMs !== void 0 && durationMs < minDurationMs) {
|
|
518
|
+
dispatch({
|
|
519
|
+
kind: "failed",
|
|
520
|
+
error: "too-short"
|
|
521
|
+
});
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
dispatch({
|
|
525
|
+
kind: "captured",
|
|
526
|
+
take: {
|
|
527
|
+
blob: new Blob([encodeWav16(samples, TARGET_SAMPLE_RATE)], { type: WAV_MIME_TYPE }),
|
|
528
|
+
mimeType: WAV_MIME_TYPE,
|
|
529
|
+
durationMs,
|
|
530
|
+
peakLevel: peakOf(samples)
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
}, [dispatch]);
|
|
534
|
+
const handleBlock = useCallback((channels) => {
|
|
535
|
+
const session = sessionRef.current;
|
|
536
|
+
if (session === null) return;
|
|
537
|
+
let block = downmix(channels);
|
|
538
|
+
const room = session.maxFrames - session.frames;
|
|
539
|
+
if (block.length > room) block = block.subarray(0, room);
|
|
540
|
+
session.blocks.push(block);
|
|
541
|
+
session.frames += block.length;
|
|
542
|
+
const level = rmsOf(block);
|
|
543
|
+
if (level > session.peak) session.peak = level;
|
|
544
|
+
if (session.frames >= session.maxFrames) {
|
|
545
|
+
finish();
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (session.frames - session.reportedAt < session.rate / LEVEL_UPDATES_PER_SECOND) return;
|
|
549
|
+
session.reportedAt = session.frames;
|
|
550
|
+
const peak = session.peak;
|
|
551
|
+
session.peak = 0;
|
|
552
|
+
dispatch({
|
|
553
|
+
kind: "progress",
|
|
554
|
+
level: peak,
|
|
555
|
+
elapsedMs: session.frames / session.rate * 1e3
|
|
556
|
+
});
|
|
557
|
+
}, [dispatch, finish]);
|
|
558
|
+
const start = useCallback(async () => {
|
|
559
|
+
const { maxDurationMs, maxTakes, workletUrl } = optionsRef.current;
|
|
560
|
+
if (startingRef.current || sessionRef.current !== null) return;
|
|
561
|
+
if (maxTakes !== void 0 && stateRef.current.takesUsed >= maxTakes) return;
|
|
562
|
+
const attempt = attemptRef.current;
|
|
563
|
+
startingRef.current = true;
|
|
564
|
+
dispatch({ kind: "requested" });
|
|
565
|
+
let release = () => {};
|
|
566
|
+
try {
|
|
567
|
+
const view = globalThis;
|
|
568
|
+
const devices = view.navigator?.mediaDevices;
|
|
569
|
+
const getUserMedia = devices?.getUserMedia;
|
|
570
|
+
const AudioContextConstructor = view.AudioContext;
|
|
571
|
+
if (devices === void 0 || getUserMedia === void 0 || AudioContextConstructor === void 0) {
|
|
572
|
+
dispatch({
|
|
573
|
+
kind: "failed",
|
|
574
|
+
error: "unsupported"
|
|
575
|
+
});
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
let stream;
|
|
579
|
+
try {
|
|
580
|
+
stream = await getUserMedia.call(devices, { audio: true });
|
|
581
|
+
} catch (error) {
|
|
582
|
+
dispatch({
|
|
583
|
+
kind: "failed",
|
|
584
|
+
error: microphoneError(error)
|
|
585
|
+
});
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
release = () => {
|
|
589
|
+
stopTracks(stream);
|
|
590
|
+
};
|
|
591
|
+
if (attempt !== attemptRef.current) {
|
|
592
|
+
release();
|
|
593
|
+
dispatch({ kind: "discarded" });
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
let context;
|
|
597
|
+
try {
|
|
598
|
+
context = new AudioContextConstructor();
|
|
599
|
+
} catch {
|
|
600
|
+
release();
|
|
601
|
+
dispatch({
|
|
602
|
+
kind: "failed",
|
|
603
|
+
error: "unsupported"
|
|
604
|
+
});
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const open = context;
|
|
608
|
+
release = () => {
|
|
609
|
+
stopTracks(stream);
|
|
610
|
+
closeQuietly(open);
|
|
611
|
+
};
|
|
612
|
+
const attached = await createWorkletNode(open, workletUrl, handleBlock) ?? createProcessorNode(open, handleBlock);
|
|
613
|
+
if (attached === null) {
|
|
614
|
+
release();
|
|
615
|
+
dispatch({
|
|
616
|
+
kind: "failed",
|
|
617
|
+
error: "unsupported"
|
|
618
|
+
});
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const source = open.createMediaStreamSource(stream);
|
|
622
|
+
const sink = open.createGain();
|
|
623
|
+
sink.gain.value = 0;
|
|
624
|
+
source.connect(attached.node);
|
|
625
|
+
attached.node.connect(sink);
|
|
626
|
+
sink.connect(open.destination);
|
|
627
|
+
release = () => {
|
|
628
|
+
attached.detach();
|
|
629
|
+
attached.node.disconnect();
|
|
630
|
+
source.disconnect();
|
|
631
|
+
sink.disconnect();
|
|
632
|
+
stopTracks(stream);
|
|
633
|
+
closeQuietly(open);
|
|
634
|
+
};
|
|
635
|
+
if (attempt !== attemptRef.current) {
|
|
636
|
+
release();
|
|
637
|
+
dispatch({ kind: "discarded" });
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
sessionRef.current = {
|
|
641
|
+
blocks: [],
|
|
642
|
+
frames: 0,
|
|
643
|
+
rate: open.sampleRate,
|
|
644
|
+
maxFrames: Math.max(1, Math.floor(maxDurationMs / 1e3 * open.sampleRate)),
|
|
645
|
+
peak: 0,
|
|
646
|
+
reportedAt: 0,
|
|
647
|
+
release
|
|
648
|
+
};
|
|
649
|
+
dispatch({ kind: "started" });
|
|
650
|
+
} catch {
|
|
651
|
+
release();
|
|
652
|
+
sessionRef.current = null;
|
|
653
|
+
dispatch({
|
|
654
|
+
kind: "failed",
|
|
655
|
+
error: "failed"
|
|
656
|
+
});
|
|
657
|
+
} finally {
|
|
658
|
+
startingRef.current = false;
|
|
659
|
+
}
|
|
660
|
+
}, [dispatch, handleBlock]);
|
|
661
|
+
const stop = useCallback(() => {
|
|
662
|
+
if (sessionRef.current === null) {
|
|
663
|
+
attemptRef.current += 1;
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
finish();
|
|
667
|
+
}, [finish]);
|
|
668
|
+
const discard = useCallback(() => {
|
|
669
|
+
attemptRef.current += 1;
|
|
670
|
+
endSession();
|
|
671
|
+
dispatch({ kind: "discarded" });
|
|
672
|
+
}, [dispatch, endSession]);
|
|
673
|
+
const reset = useCallback(() => {
|
|
674
|
+
attemptRef.current += 1;
|
|
675
|
+
endSession();
|
|
676
|
+
dispatch({ kind: "reset" });
|
|
677
|
+
}, [dispatch, endSession]);
|
|
678
|
+
useEffect(() => {
|
|
679
|
+
return () => {
|
|
680
|
+
attemptRef.current += 1;
|
|
681
|
+
endSession();
|
|
682
|
+
};
|
|
683
|
+
}, [endSession]);
|
|
684
|
+
const { maxTakes } = options;
|
|
685
|
+
return useMemo(() => ({
|
|
686
|
+
status: state.status,
|
|
687
|
+
error: state.error,
|
|
688
|
+
level: state.level,
|
|
689
|
+
elapsedMs: state.elapsedMs,
|
|
690
|
+
take: state.take,
|
|
691
|
+
takesUsed: state.takesUsed,
|
|
692
|
+
canRecord: maxTakes === void 0 || state.takesUsed < maxTakes,
|
|
693
|
+
start,
|
|
694
|
+
stop,
|
|
695
|
+
discard,
|
|
696
|
+
reset
|
|
697
|
+
}), [
|
|
698
|
+
state,
|
|
699
|
+
maxTakes,
|
|
700
|
+
start,
|
|
701
|
+
stop,
|
|
702
|
+
discard,
|
|
703
|
+
reset
|
|
704
|
+
]);
|
|
705
|
+
}
|
|
706
|
+
//#endregion
|
|
707
|
+
export { CAPTURE_PROCESSOR_SOURCE as n, useSpeechRecorder as t };
|
|
708
|
+
|
|
709
|
+
//# sourceMappingURL=useSpeechRecorder-zIi9Dlw8.js.map
|