@magmamath/students-features 1.7.9 → 1.7.10-rc.1

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.
Files changed (154) hide show
  1. package/dist/commonjs/features/voice/constants.js +39 -3
  2. package/dist/commonjs/features/voice/constants.js.map +1 -1
  3. package/dist/commonjs/features/voice/helpers.js +67 -1
  4. package/dist/commonjs/features/voice/helpers.js.map +1 -1
  5. package/dist/commonjs/features/voice/index.js +77 -0
  6. package/dist/commonjs/features/voice/index.js.map +1 -1
  7. package/dist/commonjs/features/voice/playing/components/VoiceTranscriptContent.js +17 -9
  8. package/dist/commonjs/features/voice/playing/components/VoiceTranscriptContent.js.map +1 -1
  9. package/dist/commonjs/features/voice/playing/model/TranscriptionsDownloader.model.js +4 -27
  10. package/dist/commonjs/features/voice/playing/model/TranscriptionsDownloader.model.js.map +1 -1
  11. package/dist/commonjs/features/voice/recording/components/VoiceRecordWithTranscript.js +49 -0
  12. package/dist/commonjs/features/voice/recording/components/VoiceRecordWithTranscript.js.map +1 -0
  13. package/dist/commonjs/features/voice/recording/components/VoiceTranscriptPanel.js +245 -0
  14. package/dist/commonjs/features/voice/recording/components/VoiceTranscriptPanel.js.map +1 -0
  15. package/dist/commonjs/features/voice/recording/components/VoiceWaveform.js +93 -0
  16. package/dist/commonjs/features/voice/recording/components/VoiceWaveform.js.map +1 -0
  17. package/dist/commonjs/features/voice/recording/hooks/useTranscriptPanelAnimation.js +42 -0
  18. package/dist/commonjs/features/voice/recording/hooks/useTranscriptPanelAnimation.js.map +1 -0
  19. package/dist/commonjs/features/voice/recording/hooks/useVoiceRecorder.js +17 -2
  20. package/dist/commonjs/features/voice/recording/hooks/useVoiceRecorder.js.map +1 -1
  21. package/dist/commonjs/features/voice/recording/hooks/useVoiceWaveform.js +43 -0
  22. package/dist/commonjs/features/voice/recording/hooks/useVoiceWaveform.js.map +1 -0
  23. package/dist/commonjs/features/voice/recording/model/Recorder.model.js +3 -0
  24. package/dist/commonjs/features/voice/recording/model/Recorder.model.js.map +1 -1
  25. package/dist/commonjs/features/voice/recording/model/VoiceRecordWithTranscript.model.js +83 -0
  26. package/dist/commonjs/features/voice/recording/model/VoiceRecordWithTranscript.model.js.map +1 -0
  27. package/dist/commonjs/features/voice/recording/model/VoiceTranscriptPanel.model.js +65 -0
  28. package/dist/commonjs/features/voice/recording/model/VoiceTranscriptPanel.model.js.map +1 -0
  29. package/dist/commonjs/features/voice/transcript.helpers.js +36 -0
  30. package/dist/commonjs/features/voice/transcript.helpers.js.map +1 -0
  31. package/dist/commonjs/features/voice/types.js +11 -7
  32. package/dist/commonjs/features/voice/types.js.map +1 -1
  33. package/dist/module/features/voice/constants.js +38 -0
  34. package/dist/module/features/voice/constants.js.map +1 -1
  35. package/dist/module/features/voice/helpers.js +65 -1
  36. package/dist/module/features/voice/helpers.js.map +1 -1
  37. package/dist/module/features/voice/index.js +7 -0
  38. package/dist/module/features/voice/index.js.map +1 -1
  39. package/dist/module/features/voice/playing/components/VoiceTranscriptContent.js +15 -9
  40. package/dist/module/features/voice/playing/components/VoiceTranscriptContent.js.map +1 -1
  41. package/dist/module/features/voice/playing/model/TranscriptionsDownloader.model.js +4 -27
  42. package/dist/module/features/voice/playing/model/TranscriptionsDownloader.model.js.map +1 -1
  43. package/dist/module/features/voice/recording/components/VoiceRecordWithTranscript.js +43 -0
  44. package/dist/module/features/voice/recording/components/VoiceRecordWithTranscript.js.map +1 -0
  45. package/dist/module/features/voice/recording/components/VoiceTranscriptPanel.js +238 -0
  46. package/dist/module/features/voice/recording/components/VoiceTranscriptPanel.js.map +1 -0
  47. package/dist/module/features/voice/recording/components/VoiceWaveform.js +86 -0
  48. package/dist/module/features/voice/recording/components/VoiceWaveform.js.map +1 -0
  49. package/dist/module/features/voice/recording/hooks/useTranscriptPanelAnimation.js +37 -0
  50. package/dist/module/features/voice/recording/hooks/useTranscriptPanelAnimation.js.map +1 -0
  51. package/dist/module/features/voice/recording/hooks/useVoiceRecorder.js +20 -3
  52. package/dist/module/features/voice/recording/hooks/useVoiceRecorder.js.map +1 -1
  53. package/dist/module/features/voice/recording/hooks/useVoiceWaveform.js +38 -0
  54. package/dist/module/features/voice/recording/hooks/useVoiceWaveform.js.map +1 -0
  55. package/dist/module/features/voice/recording/model/Recorder.model.js +3 -0
  56. package/dist/module/features/voice/recording/model/Recorder.model.js.map +1 -1
  57. package/dist/module/features/voice/recording/model/VoiceRecordWithTranscript.model.js +78 -0
  58. package/dist/module/features/voice/recording/model/VoiceRecordWithTranscript.model.js.map +1 -0
  59. package/dist/module/features/voice/recording/model/VoiceTranscriptPanel.model.js +60 -0
  60. package/dist/module/features/voice/recording/model/VoiceTranscriptPanel.model.js.map +1 -0
  61. package/dist/module/features/voice/transcript.helpers.js +31 -0
  62. package/dist/module/features/voice/transcript.helpers.js.map +1 -0
  63. package/dist/module/features/voice/types.js +4 -6
  64. package/dist/module/features/voice/types.js.map +1 -1
  65. package/dist/typescript/commonjs/features/voice/__tests__/VoiceRecordWithTranscript.model.test.d.ts +2 -0
  66. package/dist/typescript/commonjs/features/voice/__tests__/VoiceRecordWithTranscript.model.test.d.ts.map +1 -0
  67. package/dist/typescript/commonjs/features/voice/__tests__/VoiceTranscriptPanel.model.test.d.ts +2 -0
  68. package/dist/typescript/commonjs/features/voice/__tests__/VoiceTranscriptPanel.model.test.d.ts.map +1 -0
  69. package/dist/typescript/commonjs/features/voice/constants.d.ts +32 -0
  70. package/dist/typescript/commonjs/features/voice/constants.d.ts.map +1 -1
  71. package/dist/typescript/commonjs/features/voice/helpers.d.ts +3 -0
  72. package/dist/typescript/commonjs/features/voice/helpers.d.ts.map +1 -1
  73. package/dist/typescript/commonjs/features/voice/index.d.ts +7 -0
  74. package/dist/typescript/commonjs/features/voice/index.d.ts.map +1 -1
  75. package/dist/typescript/commonjs/features/voice/playing/components/VoiceTranscriptContent.d.ts +2 -1
  76. package/dist/typescript/commonjs/features/voice/playing/components/VoiceTranscriptContent.d.ts.map +1 -1
  77. package/dist/typescript/commonjs/features/voice/playing/model/TranscriptionsDownloader.model.d.ts +2 -7
  78. package/dist/typescript/commonjs/features/voice/playing/model/TranscriptionsDownloader.model.d.ts.map +1 -1
  79. package/dist/typescript/commonjs/features/voice/recording/components/VoiceRecordWithTranscript.d.ts +13 -0
  80. package/dist/typescript/commonjs/features/voice/recording/components/VoiceRecordWithTranscript.d.ts.map +1 -0
  81. package/dist/typescript/commonjs/features/voice/recording/components/VoiceTranscriptPanel.d.ts +11 -0
  82. package/dist/typescript/commonjs/features/voice/recording/components/VoiceTranscriptPanel.d.ts.map +1 -0
  83. package/dist/typescript/commonjs/features/voice/recording/components/VoiceWaveform.d.ts +8 -0
  84. package/dist/typescript/commonjs/features/voice/recording/components/VoiceWaveform.d.ts.map +1 -0
  85. package/dist/typescript/commonjs/features/voice/recording/hooks/useTranscriptPanelAnimation.d.ts +18 -0
  86. package/dist/typescript/commonjs/features/voice/recording/hooks/useTranscriptPanelAnimation.d.ts.map +1 -0
  87. package/dist/typescript/commonjs/features/voice/recording/hooks/useVoiceRecorder.d.ts.map +1 -1
  88. package/dist/typescript/commonjs/features/voice/recording/hooks/useVoiceWaveform.d.ts +7 -0
  89. package/dist/typescript/commonjs/features/voice/recording/hooks/useVoiceWaveform.d.ts.map +1 -0
  90. package/dist/typescript/commonjs/features/voice/recording/model/Recorder.model.d.ts +2 -0
  91. package/dist/typescript/commonjs/features/voice/recording/model/Recorder.model.d.ts.map +1 -1
  92. package/dist/typescript/commonjs/features/voice/recording/model/VoiceRecordWithTranscript.model.d.ts +16 -0
  93. package/dist/typescript/commonjs/features/voice/recording/model/VoiceRecordWithTranscript.model.d.ts.map +1 -0
  94. package/dist/typescript/commonjs/features/voice/recording/model/VoiceTranscriptPanel.model.d.ts +26 -0
  95. package/dist/typescript/commonjs/features/voice/recording/model/VoiceTranscriptPanel.model.d.ts.map +1 -0
  96. package/dist/typescript/commonjs/features/voice/transcript.helpers.d.ts +8 -0
  97. package/dist/typescript/commonjs/features/voice/transcript.helpers.d.ts.map +1 -0
  98. package/dist/typescript/commonjs/features/voice/types.d.ts +2 -5
  99. package/dist/typescript/commonjs/features/voice/types.d.ts.map +1 -1
  100. package/dist/typescript/module/features/voice/__tests__/VoiceRecordWithTranscript.model.test.d.ts +2 -0
  101. package/dist/typescript/module/features/voice/__tests__/VoiceRecordWithTranscript.model.test.d.ts.map +1 -0
  102. package/dist/typescript/module/features/voice/__tests__/VoiceTranscriptPanel.model.test.d.ts +2 -0
  103. package/dist/typescript/module/features/voice/__tests__/VoiceTranscriptPanel.model.test.d.ts.map +1 -0
  104. package/dist/typescript/module/features/voice/constants.d.ts +32 -0
  105. package/dist/typescript/module/features/voice/constants.d.ts.map +1 -1
  106. package/dist/typescript/module/features/voice/helpers.d.ts +3 -0
  107. package/dist/typescript/module/features/voice/helpers.d.ts.map +1 -1
  108. package/dist/typescript/module/features/voice/index.d.ts +7 -0
  109. package/dist/typescript/module/features/voice/index.d.ts.map +1 -1
  110. package/dist/typescript/module/features/voice/playing/components/VoiceTranscriptContent.d.ts +2 -1
  111. package/dist/typescript/module/features/voice/playing/components/VoiceTranscriptContent.d.ts.map +1 -1
  112. package/dist/typescript/module/features/voice/playing/model/TranscriptionsDownloader.model.d.ts +2 -7
  113. package/dist/typescript/module/features/voice/playing/model/TranscriptionsDownloader.model.d.ts.map +1 -1
  114. package/dist/typescript/module/features/voice/recording/components/VoiceRecordWithTranscript.d.ts +13 -0
  115. package/dist/typescript/module/features/voice/recording/components/VoiceRecordWithTranscript.d.ts.map +1 -0
  116. package/dist/typescript/module/features/voice/recording/components/VoiceTranscriptPanel.d.ts +11 -0
  117. package/dist/typescript/module/features/voice/recording/components/VoiceTranscriptPanel.d.ts.map +1 -0
  118. package/dist/typescript/module/features/voice/recording/components/VoiceWaveform.d.ts +8 -0
  119. package/dist/typescript/module/features/voice/recording/components/VoiceWaveform.d.ts.map +1 -0
  120. package/dist/typescript/module/features/voice/recording/hooks/useTranscriptPanelAnimation.d.ts +18 -0
  121. package/dist/typescript/module/features/voice/recording/hooks/useTranscriptPanelAnimation.d.ts.map +1 -0
  122. package/dist/typescript/module/features/voice/recording/hooks/useVoiceRecorder.d.ts.map +1 -1
  123. package/dist/typescript/module/features/voice/recording/hooks/useVoiceWaveform.d.ts +7 -0
  124. package/dist/typescript/module/features/voice/recording/hooks/useVoiceWaveform.d.ts.map +1 -0
  125. package/dist/typescript/module/features/voice/recording/model/Recorder.model.d.ts +2 -0
  126. package/dist/typescript/module/features/voice/recording/model/Recorder.model.d.ts.map +1 -1
  127. package/dist/typescript/module/features/voice/recording/model/VoiceRecordWithTranscript.model.d.ts +16 -0
  128. package/dist/typescript/module/features/voice/recording/model/VoiceRecordWithTranscript.model.d.ts.map +1 -0
  129. package/dist/typescript/module/features/voice/recording/model/VoiceTranscriptPanel.model.d.ts +26 -0
  130. package/dist/typescript/module/features/voice/recording/model/VoiceTranscriptPanel.model.d.ts.map +1 -0
  131. package/dist/typescript/module/features/voice/transcript.helpers.d.ts +8 -0
  132. package/dist/typescript/module/features/voice/transcript.helpers.d.ts.map +1 -0
  133. package/dist/typescript/module/features/voice/types.d.ts +2 -5
  134. package/dist/typescript/module/features/voice/types.d.ts.map +1 -1
  135. package/package.json +1 -1
  136. package/src/features/voice/__tests__/VoiceRecordWithTranscript.model.test.ts +145 -0
  137. package/src/features/voice/__tests__/VoiceTranscriptPanel.model.test.ts +135 -0
  138. package/src/features/voice/constants.ts +40 -0
  139. package/src/features/voice/helpers.ts +85 -1
  140. package/src/features/voice/index.ts +7 -0
  141. package/src/features/voice/playing/components/VoiceTranscriptContent.tsx +16 -8
  142. package/src/features/voice/playing/model/TranscriptionsDownloader.model.ts +8 -30
  143. package/src/features/voice/recording/components/VoiceRecordWithTranscript.tsx +52 -0
  144. package/src/features/voice/recording/components/VoiceTranscriptPanel.tsx +285 -0
  145. package/src/features/voice/recording/components/VoiceWaveform.tsx +102 -0
  146. package/src/features/voice/recording/hooks/useTranscriptPanelAnimation.ts +49 -0
  147. package/src/features/voice/recording/hooks/useVoiceRecorder.ts +26 -3
  148. package/src/features/voice/recording/hooks/useVoiceWaveform.ts +46 -0
  149. package/src/features/voice/recording/model/Recorder.model.ts +3 -0
  150. package/src/features/voice/recording/model/VoiceRecordWithTranscript.model.ts +81 -0
  151. package/src/features/voice/recording/model/VoiceTranscriptPanel.model.ts +76 -0
  152. package/src/features/voice/transcript.helpers.ts +37 -0
  153. package/src/features/voice/types.ts +5 -6
  154. package/src/i18n/.generated/schema.json +6 -5
@@ -0,0 +1,49 @@
1
+ import { LayoutChangeEvent } from 'react-native'
2
+
3
+ import {
4
+ useAnimatedStyle,
5
+ useSharedValue,
6
+ withTiming,
7
+ } from 'react-native-reanimated'
8
+
9
+ import { VOICE_TRANSCRIPT_PANEL } from '../../constants'
10
+
11
+ const TIMING = { duration: 170 } as const
12
+
13
+ // Width and height are animated as explicit numbers instead of reanimated
14
+ // layout transitions: on web those run as FLIP animations that scale the
15
+ // subtree, visibly stretching the text while the panel resizes.
16
+ export const useTranscriptPanelAnimation = (isExpanded: boolean) => {
17
+ const contentHeight = useSharedValue(0)
18
+
19
+ const onContentLayout = (event: LayoutChangeEvent) => {
20
+ const height = event.nativeEvent.layout.height
21
+ if (height <= 0) return
22
+ contentHeight.value =
23
+ contentHeight.value === 0 ? height : withTiming(height, TIMING)
24
+ }
25
+
26
+ const containerAnimatedStyle = useAnimatedStyle(
27
+ () => ({
28
+ width: withTiming(
29
+ isExpanded ? VOICE_TRANSCRIPT_PANEL.WIDTH_DONE : VOICE_TRANSCRIPT_PANEL.WIDTH,
30
+ TIMING,
31
+ ),
32
+ }),
33
+ [isExpanded],
34
+ )
35
+
36
+ const bodyAnimatedStyle = useAnimatedStyle(
37
+ () => (contentHeight.value === 0 ? {} : { height: contentHeight.value }),
38
+ [contentHeight],
39
+ )
40
+
41
+ const caretAnimatedStyle = useAnimatedStyle(
42
+ () => ({
43
+ transform: [{ rotate: withTiming(isExpanded ? '180deg' : '0deg', TIMING) }],
44
+ }),
45
+ [isExpanded],
46
+ )
47
+
48
+ return { onContentLayout, containerAnimatedStyle, bodyAnimatedStyle, caretAnimatedStyle }
49
+ }
@@ -3,11 +3,34 @@ import { VoiceRecordModel } from '../model/VoiceRecord.model'
3
3
  import { useEffect } from 'react'
4
4
  import { IS_WEB } from '@magmamath/react-native-ui'
5
5
  import { VOICE_RECORDER_MAX_DURATION_MS, VoiceRecorderState } from '../../constants'
6
- import { addAudioInputsListenerWeb, getAvailableInputs } from '../../helpers'
6
+ import {
7
+ addAudioInputsListenerWeb,
8
+ createWebMeteringMonitor,
9
+ getAvailableInputs,
10
+ } from '../../helpers'
11
+ import { METERING_UPDATE_INTERVAL_MS } from '../../constants'
12
+
13
+ // Metering powers the live recording waveform (recorderState.metering).
14
+ // Only native respects it — on web the level is measured by
15
+ // createWebMeteringMonitor instead.
16
+ const RECORDING_OPTIONS = {
17
+ ...RecordingPresets.LOW_QUALITY,
18
+ isMeteringEnabled: true,
19
+ }
7
20
 
8
21
  export const useVoiceRecorder = (model: VoiceRecordModel) => {
9
- const recorder = useAudioRecorder(RecordingPresets.LOW_QUALITY, model.recordingStatusUpdate)
10
- const recorderState = useAudioRecorderState(recorder)
22
+ const recorder = useAudioRecorder(RECORDING_OPTIONS, model.recordingStatusUpdate)
23
+ const recorderState = useAudioRecorderState(recorder, METERING_UPDATE_INTERVAL_MS)
24
+
25
+ useEffect(() => {
26
+ if (IS_WEB) return
27
+ model.recorderModel.setMetering(recorderState.metering ?? null)
28
+ }, [recorderState.metering, model.recorderModel])
29
+
30
+ useEffect(() => {
31
+ if (!IS_WEB || !recorderState.isRecording) return
32
+ return createWebMeteringMonitor(recorder, model.recorderModel.setMetering)
33
+ }, [recorderState.isRecording, recorder, model.recorderModel])
11
34
 
12
35
  useEffect(() => {
13
36
  if (
@@ -0,0 +1,46 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+
3
+ import { VOICE_WAVEFORM } from '../../constants'
4
+ import { normalizeMetering } from '../../helpers'
5
+
6
+ type UseVoiceWaveformParams = {
7
+ metering: number | null
8
+ isActive: boolean
9
+ }
10
+
11
+ const createSilentBuffer = () =>
12
+ new Array<number>(VOICE_WAVEFORM.BUFFER_SIZE).fill(VOICE_WAVEFORM.MIN_LEVEL)
13
+
14
+ // Keeps a rolling buffer of normalized levels that scrolls right-to-left while
15
+ // recording. We sample on a fixed interval (rather than on every metering
16
+ // update) so the scroll speed stays constant regardless of the recorder's
17
+ // status cadence. The latest metering value is read from a ref to avoid
18
+ // resubscribing the interval on each change. Levels are low-pass filtered so
19
+ // neighboring bars flow into each other instead of jumping.
20
+ export const useVoiceWaveform = ({ metering, isActive }: UseVoiceWaveformParams) => {
21
+ const meteringRef = useRef(metering)
22
+ meteringRef.current = metering
23
+
24
+ const smoothedLevelRef = useRef(VOICE_WAVEFORM.MIN_LEVEL)
25
+ const [levels, setLevels] = useState<number[]>(createSilentBuffer)
26
+
27
+ useEffect(() => {
28
+ if (!isActive) {
29
+ smoothedLevelRef.current = VOICE_WAVEFORM.MIN_LEVEL
30
+ setLevels(createSilentBuffer())
31
+ return
32
+ }
33
+
34
+ const intervalId = setInterval(() => {
35
+ const target = normalizeMetering(meteringRef.current)
36
+ smoothedLevelRef.current +=
37
+ (target - smoothedLevelRef.current) * VOICE_WAVEFORM.LEVEL_SMOOTHING
38
+ const level = Math.max(VOICE_WAVEFORM.MIN_LEVEL, smoothedLevelRef.current)
39
+ setLevels((prev) => [...prev.slice(1), level])
40
+ }, VOICE_WAVEFORM.SAMPLE_INTERVAL_MS)
41
+
42
+ return () => clearInterval(intervalId)
43
+ }, [isActive])
44
+
45
+ return levels
46
+ }
@@ -18,6 +18,7 @@ export class RecorderModel<T = unknown> {
18
18
  public readonly setVoiceRecordState = createEvent<VoiceRecorderState>()
19
19
  public readonly setAvailableInputs = createEvent<AvailableAudioInputs>()
20
20
  public readonly setLastKnownDurationMs = createEvent<number>()
21
+ public readonly setMetering = createEvent<number | null>()
21
22
 
22
23
  public readonly $voiceRecordState = restore(
23
24
  this.setVoiceRecordState,
@@ -25,6 +26,8 @@ export class RecorderModel<T = unknown> {
25
26
  ).reset(this.reset)
26
27
  public readonly $availableInputs = restore(this.setAvailableInputs, [])
27
28
  public readonly $lastKnownDurationMs = restore(this.setLastKnownDurationMs, 0).reset(this.reset)
29
+ // Live input level in dBFS while recording (powers the waveform)
30
+ public readonly $metering = restore(this.setMetering, null).reset(this.reset)
28
31
 
29
32
  public readonly start = createEffect(async () => {
30
33
  try {
@@ -0,0 +1,81 @@
1
+ import { createEffect, Effect, sample } from 'effector'
2
+
3
+ import { NO_AUDIO_BE_MESSAGE, VoiceRecorderState } from '../../constants'
4
+ import { fetchTranscriptWithRetry, GetAudioFileTranscript } from '../../transcript.helpers'
5
+ import { VoiceTranscriptPanelModel } from './VoiceTranscriptPanel.model'
6
+ import type { VoiceRecordModel } from './VoiceRecord.model'
7
+ import type { VoiceRecordCollectionItem } from '../../types'
8
+
9
+ export type VoiceRecordWithTranscriptModelProps = {
10
+ recordModel: VoiceRecordModel
11
+ getTranscript: GetAudioFileTranscript
12
+ }
13
+
14
+ // Wires the existing recording flow to the transcript panel:
15
+ // recording starts → panel opens with the waveform; the recording is confirmed
16
+ // (upload kicks off) → panel shows the loader; the uploaded file's transcript
17
+ // arrives → panel shows the text. Deleting/discarding the record closes the panel.
18
+ export class VoiceRecordWithTranscriptModel {
19
+ public readonly record: VoiceRecordModel
20
+ public readonly panel = new VoiceTranscriptPanelModel()
21
+
22
+ private readonly resolveTranscriptFx: Effect<VoiceRecordCollectionItem, string>
23
+
24
+ constructor({ recordModel, getTranscript }: VoiceRecordWithTranscriptModelProps) {
25
+ this.record = recordModel
26
+ this.resolveTranscriptFx = createEffect(async (record: VoiceRecordCollectionItem) => {
27
+ const upload = await record.audioUploadPromise
28
+ if (!upload || !upload.id) throw new Error('Audio upload did not return a file id')
29
+
30
+ const { text } = await fetchTranscriptWithRetry(getTranscript, upload.id)
31
+ if (text === NO_AUDIO_BE_MESSAGE) throw new Error('Transcript is not available')
32
+ return text
33
+ })
34
+
35
+ this.setupPanelLifecycle()
36
+ this.setupTranscriptPipeline()
37
+ }
38
+
39
+ private setupPanelLifecycle() {
40
+ sample({
41
+ clock: this.record.recorderModel.$voiceRecordState,
42
+ filter: (state) => state === VoiceRecorderState.RECORDING,
43
+ target: this.panel.open,
44
+ })
45
+ // startAudioUpload fires exactly when a recording was confirmed (the
46
+ // discard path never uploads), so it doubles as the panel's confirm clock.
47
+ sample({ clock: this.record.startAudioUpload, target: this.panel.confirm })
48
+ sample({ clock: [this.record.reset, this.record.stop.done], target: this.panel.discard })
49
+ }
50
+
51
+ private setupTranscriptPipeline() {
52
+ // Identity guard: a fetch result only applies if the record it was fetched
53
+ // for is still the current one — late results from a discarded or
54
+ // re-recorded take are dropped.
55
+ const isFetchedRecordCurrent = (
56
+ currentRecord: VoiceRecordCollectionItem | null,
57
+ { params }: { params: VoiceRecordCollectionItem },
58
+ ) => currentRecord === params
59
+
60
+ sample({
61
+ clock: this.record.$currentRecord.updates,
62
+ source: this.panel.$flags,
63
+ filter: (flags, record) => flags.isProcessing && Boolean(record?.audioUploadPromise),
64
+ fn: (_, record): VoiceRecordCollectionItem => record,
65
+ target: this.resolveTranscriptFx,
66
+ })
67
+ sample({
68
+ clock: this.resolveTranscriptFx.done,
69
+ source: this.record.$currentRecord,
70
+ filter: isFetchedRecordCurrent,
71
+ fn: (_, { result }) => result,
72
+ target: this.panel.complete,
73
+ })
74
+ sample({
75
+ clock: this.resolveTranscriptFx.fail,
76
+ source: this.record.$currentRecord,
77
+ filter: isFetchedRecordCurrent,
78
+ target: this.panel.fail,
79
+ })
80
+ }
81
+ }
@@ -0,0 +1,76 @@
1
+ import { createEvent, createStore, Event, sample } from 'effector'
2
+
3
+ import { VoicePanelPhase } from '../../constants'
4
+ import { DropdownModel } from '../../playing/model/Dropdown.model'
5
+
6
+ type PhaseFlags = {
7
+ isOpen: boolean
8
+ isRecording: boolean
9
+ isProcessing: boolean
10
+ isDone: boolean
11
+ }
12
+
13
+ // Phase machine for the recording-flow panel: open → RECORDING (waveform),
14
+ // confirm → PROCESSING (loader), complete/fail → DONE (transcript text).
15
+ // The transcript shows inside a collapsible dropdown (same UX as the playing
16
+ // side): collapsed to a short preview by default, expandable via the caret.
17
+ // Transcript fetching itself is wired externally
18
+ // (see VoiceRecordWithTranscriptModel).
19
+ export class VoiceTranscriptPanelModel {
20
+ public readonly reset = createEvent()
21
+
22
+ public readonly dropdown = new DropdownModel()
23
+
24
+ public readonly open = createEvent()
25
+ public readonly confirm = createEvent()
26
+ public readonly complete = createEvent<string>()
27
+ public readonly fail = createEvent()
28
+ public readonly discard = createEvent()
29
+
30
+ public readonly $phase = createStore<VoicePanelPhase | null>(null)
31
+ public readonly $transcript = createStore('')
32
+ public readonly $hasError = createStore(false)
33
+
34
+ public readonly $flags = this.$phase.map(
35
+ (phase): PhaseFlags => ({
36
+ isOpen: phase !== null,
37
+ isRecording: phase === VoicePanelPhase.RECORDING,
38
+ isProcessing: phase === VoicePanelPhase.PROCESSING,
39
+ isDone: phase === VoicePanelPhase.DONE,
40
+ }),
41
+ )
42
+
43
+ constructor() {
44
+ this.setupPhaseTransitions()
45
+ }
46
+
47
+ // The event passes through only while the panel is in the given phase —
48
+ // e.g. a transcript that arrives after a discard must not reopen the panel
49
+ private allowInPhase<T>(clock: Event<T>, phase: VoicePanelPhase): Event<T> {
50
+ return sample({
51
+ clock,
52
+ source: this.$phase,
53
+ filter: (currentPhase) => currentPhase === phase,
54
+ fn: (_, payload: T) => payload,
55
+ })
56
+ }
57
+
58
+ private setupPhaseTransitions() {
59
+ const confirmed = this.allowInPhase(this.confirm, VoicePanelPhase.RECORDING)
60
+ const completed = this.allowInPhase(this.complete, VoicePanelPhase.PROCESSING)
61
+ const failed = this.allowInPhase(this.fail, VoicePanelPhase.PROCESSING)
62
+
63
+ this.$phase
64
+ .on(this.open, () => VoicePanelPhase.RECORDING)
65
+ .on(confirmed, () => VoicePanelPhase.PROCESSING)
66
+ .on(completed, () => VoicePanelPhase.DONE)
67
+ .on(failed, () => VoicePanelPhase.DONE)
68
+ .reset(this.reset)
69
+
70
+ this.$transcript.on(completed, (_, transcript) => transcript).reset(this.reset)
71
+ this.$hasError.on(failed, () => true).reset(this.reset)
72
+
73
+ sample({ clock: this.discard, target: this.reset })
74
+ sample({ clock: this.reset, target: this.dropdown.reset })
75
+ }
76
+ }
@@ -0,0 +1,37 @@
1
+ import {
2
+ NO_AUDIO_BE_MESSAGE,
3
+ TRANSCRIPT_MAX_RETRIES,
4
+ TRANSCRIPT_RETRY_INTERVAL_MS,
5
+ TranscriptionStatus,
6
+ } from './constants'
7
+ import type { AudioTranscriptResponse } from './types'
8
+
9
+ export type TranscriptResult = { text: string; language?: string }
10
+ export type GetAudioFileTranscript = (audioFileId: string) => Promise<AudioTranscriptResponse>
11
+
12
+ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
13
+
14
+ // Polls the transcript endpoint until the BE finishes processing. FAILED and
15
+ // exhausted retries both resolve to the NO_AUDIO sentinel — same contract the
16
+ // playing side relies on.
17
+ export const fetchTranscriptWithRetry = async (
18
+ getTranscript: GetAudioFileTranscript,
19
+ audioFileId: string,
20
+ ): Promise<TranscriptResult> => {
21
+ for (let attempt = 0; attempt < TRANSCRIPT_MAX_RETRIES; attempt++) {
22
+ const response = await getTranscript(audioFileId)
23
+
24
+ if (response.status === TranscriptionStatus.COMPLETED) {
25
+ return { text: response.text, language: response.language }
26
+ }
27
+
28
+ if (response.status === TranscriptionStatus.FAILED) {
29
+ return { text: NO_AUDIO_BE_MESSAGE }
30
+ }
31
+
32
+ if (attempt < TRANSCRIPT_MAX_RETRIES - 1) {
33
+ await delay(TRANSCRIPT_RETRY_INTERVAL_MS)
34
+ }
35
+ }
36
+ return { text: NO_AUDIO_BE_MESSAGE }
37
+ }
@@ -1,7 +1,12 @@
1
1
  import { WithAbortSignal } from 'src/types/common.types'
2
+ import { TranscriptionStatus } from './constants'
2
3
  import { getAvailableInputs } from './helpers'
3
4
  import { VoiceRecordModel } from './recording/model/VoiceRecord.model'
4
5
  import { AxiosResponse } from 'axios'
6
+
7
+ // The enum moved to constants.ts (jest-safe import chain for transcript helpers);
8
+ // re-exported here to keep the public surface unchanged.
9
+ export { TranscriptionStatus }
5
10
  import type {
6
11
  GoogleTranslateProps as GoogleTranslatePayload,
7
12
  GoogleTranslateResponse,
@@ -60,12 +65,6 @@ export type VoiceRecorderApi = {
60
65
  deleteAudioFile: (audioFileId: string) => Promise<void>
61
66
  }
62
67
 
63
- export enum TranscriptionStatus {
64
- PENDING = 'pending',
65
- COMPLETED = 'completed',
66
- FAILED = 'failed',
67
- }
68
-
69
68
  export type AudioTranscriptResponse = {
70
69
  text: string
71
70
  language: string
@@ -73,9 +73,9 @@
73
73
  "errorTitle": "Something went wrong",
74
74
  "fast": "Fast!",
75
75
  "incorrect": "INCORRECT — ANSWER: {{answer}}",
76
- "masteryGrowing": "Growing",
77
- "masteryLearning": "Learning",
78
- "masteryMastered": "Mastered",
76
+ "masteryGrowing": "Familiar",
77
+ "masteryLearning": "New",
78
+ "masteryMastered": "Automatic",
79
79
  "masteryStrong": "Strong",
80
80
  "multiplication": "Multiplication",
81
81
  "notStarted": "New",
@@ -98,7 +98,7 @@
98
98
  "teacher": {
99
99
  "fluency": {
100
100
  "allPupils": "All students",
101
- "average": "Average",
101
+ "average": "Progress",
102
102
  "avgTimeTitle": "Avg Time / Student",
103
103
  "factFluency": "Fact Fluency",
104
104
  "factsGridLabel": "{{cols}}×{{rows}} facts",
@@ -108,7 +108,7 @@
108
108
  "noFactsPracticed": "No facts practiced yet",
109
109
  "noStudents": "This class has no students",
110
110
  "noStudentsFound": "No students match your search",
111
- "overallMastery": "Overall mastery",
111
+ "overallMastery": "Automaticity progress",
112
112
  "progressDeveloping": "Developing",
113
113
  "progressFluent": "Fluent",
114
114
  "progressLearning": "Learning",
@@ -140,6 +140,7 @@
140
140
  "noMicrophoneAccess": "No microphone access.",
141
141
  "noMicrophoneAccessBySystem": "Microphone access denied by system settings.",
142
142
  "noMicrophoneFound": "No microphone found.",
143
+ "preparingTranscript": "Preparing transcript",
143
144
  "recordingDeleted": "Recording successfully deleted.",
144
145
  "redo": "Redo",
145
146
  "redoRecording": "Redo recording?",