@magmamath/students-features 1.8.7 → 1.8.8-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 +42 -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 +21 -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 +252 -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 +122 -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 +118 -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 +41 -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 +19 -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 +245 -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 +117 -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 +113 -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 +34 -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 +3 -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 +17 -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 +32 -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 +34 -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 +3 -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 +17 -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 +32 -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 +249 -0
  137. package/src/features/voice/__tests__/VoiceTranscriptPanel.model.test.ts +237 -0
  138. package/src/features/voice/constants.ts +43 -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 +26 -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 +292 -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 +121 -0
  151. package/src/features/voice/recording/model/VoiceTranscriptPanel.model.ts +137 -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 +1 -0
@@ -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,121 @@
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
+ type ResultGuardSource = {
15
+ currentRecord: VoiceRecordCollectionItem | null
16
+ isDismissPending: boolean
17
+ }
18
+
19
+ // Wires the existing recording flow to the transcript panel:
20
+ // recording starts → panel opens with the waveform; the recording is confirmed
21
+ // (upload kicks off) → panel shows the loader; the uploaded file's transcript
22
+ // arrives → panel shows the text. Deleting/discarding the record closes the panel.
23
+ export class VoiceRecordWithTranscriptModel {
24
+ public readonly record: VoiceRecordModel
25
+ public readonly panel = new VoiceTranscriptPanelModel()
26
+
27
+ private readonly resolveTranscriptFx: Effect<VoiceRecordCollectionItem, string>
28
+
29
+ constructor({ recordModel, getTranscript }: VoiceRecordWithTranscriptModelProps) {
30
+ this.record = recordModel
31
+ this.resolveTranscriptFx = createEffect(async (record: VoiceRecordCollectionItem) => {
32
+ // A restored recording already carries its uploaded file id; a fresh take
33
+ // gets it from the in-flight upload promise.
34
+ const fileId = record.id ?? (await record.audioUploadPromise)?.id
35
+ if (!fileId) throw new Error('Audio upload did not return a file id')
36
+
37
+ const { text } = await fetchTranscriptWithRetry(getTranscript, fileId)
38
+ if (text === NO_AUDIO_BE_MESSAGE) throw new Error('Transcript is not available')
39
+ return text
40
+ })
41
+
42
+ this.setupPanelLifecycle()
43
+ this.setupTranscriptPipeline()
44
+ this.setupTranscriptRestore()
45
+ }
46
+
47
+ private setupPanelLifecycle() {
48
+ sample({
49
+ clock: this.record.recorderModel.$voiceRecordState,
50
+ filter: (state) => state === VoiceRecorderState.RECORDING,
51
+ target: this.panel.open,
52
+ })
53
+ // startAudioUpload fires exactly when a recording was confirmed (the
54
+ // discard path never uploads), so it doubles as the panel's confirm clock.
55
+ sample({ clock: this.record.startAudioUpload, target: this.panel.confirm })
56
+ // The record resets as a side effect of a wrong answer (the attempt key
57
+ // rotates to a take with no recording). While the panel is handling its own
58
+ // dismissal — dimming a shown transcript, or waiting for one that is still
59
+ // loading — that incidental reset must not close it early; the panel owns
60
+ // the close.
61
+ sample({
62
+ clock: [this.record.reset, this.record.stop.done],
63
+ source: this.panel.$isHandlingDismissal,
64
+ filter: (isHandlingDismissal) => !isHandlingDismissal,
65
+ target: this.panel.discard,
66
+ })
67
+ }
68
+
69
+ private setupTranscriptPipeline() {
70
+ // A fetch result applies when the record it was fetched for is still the
71
+ // current one — late results from a discarded or re-recorded take are
72
+ // dropped. The exception is a pending dismissal: a wrong answer rotates
73
+ // $currentRecord away from the take it was fetched for, but that take's
74
+ // transcript is exactly the one the panel is waiting to show before it
75
+ // closes, so it must still apply.
76
+ const shouldApplyResult = (
77
+ { currentRecord, isDismissPending }: ResultGuardSource,
78
+ { params }: { params: VoiceRecordCollectionItem },
79
+ ) => isDismissPending || currentRecord === params
80
+
81
+ const resultGuardSource = {
82
+ currentRecord: this.record.$currentRecord,
83
+ isDismissPending: this.panel.$isDismissPending,
84
+ }
85
+
86
+ sample({
87
+ clock: this.record.$currentRecord.updates,
88
+ source: this.panel.$flags,
89
+ filter: (flags, record) =>
90
+ flags.isProcessing && Boolean(record?.audioUploadPromise) && !record?.id,
91
+ fn: (_, record): VoiceRecordCollectionItem => record,
92
+ target: this.resolveTranscriptFx,
93
+ })
94
+ sample({
95
+ clock: this.resolveTranscriptFx.done,
96
+ source: resultGuardSource,
97
+ filter: shouldApplyResult,
98
+ fn: (_, { result }) => result,
99
+ target: this.panel.complete,
100
+ })
101
+ sample({
102
+ clock: this.resolveTranscriptFx.fail,
103
+ source: resultGuardSource,
104
+ filter: shouldApplyResult,
105
+ target: this.panel.fail,
106
+ })
107
+ }
108
+
109
+ // Reopening a problem whose recording was already uploaded: $currentRecord is
110
+ // restored with its file id (no live take in progress), so the live pipeline
111
+ // never runs. Reopen the panel into the loader and refetch — a still-pending
112
+ // transcript keeps polling, a completed one resolves on the first call.
113
+ private setupTranscriptRestore() {
114
+ const restored = sample({
115
+ clock: this.record.$currentRecord.updates,
116
+ filter: (record): record is VoiceRecordCollectionItem => Boolean(record?.id),
117
+ })
118
+ sample({ clock: restored, target: this.panel.restore })
119
+ sample({ clock: restored, target: this.resolveTranscriptFx })
120
+ }
121
+ }
@@ -0,0 +1,137 @@
1
+ import { combine, createEvent, createStore, Event, sample } from 'effector'
2
+ import { delay } from 'patronum'
3
+
4
+ import { VOICE_TRANSCRIPT_PANEL, VoicePanelPhase } from '../../constants'
5
+ import { DropdownModel } from '../../playing/model/Dropdown.model'
6
+
7
+ type PhaseFlags = {
8
+ isOpen: boolean
9
+ isRecording: boolean
10
+ isProcessing: boolean
11
+ isDone: boolean
12
+ }
13
+
14
+ // Phase machine for the recording-flow panel: open → RECORDING (waveform),
15
+ // confirm → PROCESSING (loader), complete/fail → DONE (transcript text).
16
+ // restore → PROCESSING is the reopen path: a saved recording becomes current
17
+ // again (no live take), so the panel jumps straight to the loader and refetches.
18
+ // The transcript shows inside a collapsible dropdown (same UX as the playing
19
+ // side): collapsed to a short preview by default, expandable via the caret.
20
+ // Transcript fetching itself is wired externally
21
+ // (see VoiceRecordWithTranscriptModel).
22
+ export class VoiceTranscriptPanelModel {
23
+ public readonly reset = createEvent()
24
+
25
+ public readonly dropdown = new DropdownModel()
26
+
27
+ public readonly open = createEvent()
28
+ public readonly restore = createEvent()
29
+ public readonly confirm = createEvent()
30
+ public readonly complete = createEvent<string>()
31
+ public readonly fail = createEvent()
32
+ public readonly discard = createEvent()
33
+ // Ask the panel to dismiss itself gracefully — dim the transcript, linger,
34
+ // then close (vs. discard, which closes immediately). Wired to a wrong answer
35
+ // by the consumer; the panel itself stays domain-agnostic and owns the timing.
36
+ public readonly requestDismiss = createEvent()
37
+
38
+ public readonly $phase = createStore<VoicePanelPhase | null>(null)
39
+ public readonly $transcript = createStore('')
40
+ public readonly $hasError = createStore(false)
41
+ public readonly $isDimmed = createStore(false)
42
+ // A dismissal asked for while the transcript is still loading: there is
43
+ // nothing to dim yet, so we remember it and run the dismissal once the
44
+ // transcript resolves into the DONE phase.
45
+ public readonly $isDismissPending = createStore(false)
46
+
47
+ // True while the panel owns its own dismissal — either already dimming a
48
+ // shown transcript, or waiting for an in-flight transcript before it can.
49
+ // The consumer reads this so an incidental record reset can't close the
50
+ // panel out from under a dismissal in progress.
51
+ public readonly $isHandlingDismissal = combine(
52
+ this.$isDimmed,
53
+ this.$isDismissPending,
54
+ (isDimmed, isDismissPending) => isDimmed || isDismissPending,
55
+ )
56
+
57
+ public readonly $flags = this.$phase.map(
58
+ (phase): PhaseFlags => ({
59
+ isOpen: phase !== null,
60
+ isRecording: phase === VoicePanelPhase.RECORDING,
61
+ isProcessing: phase === VoicePanelPhase.PROCESSING,
62
+ isDone: phase === VoicePanelPhase.DONE,
63
+ }),
64
+ )
65
+
66
+ constructor() {
67
+ this.setupPhaseTransitions()
68
+ this.setupDelayedDismissal()
69
+ }
70
+
71
+ // The event passes through only while the panel is in the given phase —
72
+ // e.g. a transcript that arrives after a discard must not reopen the panel
73
+ private allowInPhase<T>(clock: Event<T>, phase: VoicePanelPhase): Event<T> {
74
+ return sample({
75
+ clock,
76
+ source: this.$phase,
77
+ filter: (currentPhase) => currentPhase === phase,
78
+ fn: (_, payload: T) => payload,
79
+ })
80
+ }
81
+
82
+ private setupPhaseTransitions() {
83
+ const confirmed = this.allowInPhase(this.confirm, VoicePanelPhase.RECORDING)
84
+ const completed = this.allowInPhase(this.complete, VoicePanelPhase.PROCESSING)
85
+ const failed = this.allowInPhase(this.fail, VoicePanelPhase.PROCESSING)
86
+
87
+ this.$phase
88
+ .on(this.open, () => VoicePanelPhase.RECORDING)
89
+ .on(this.restore, () => VoicePanelPhase.PROCESSING)
90
+ .on(confirmed, () => VoicePanelPhase.PROCESSING)
91
+ .on(completed, () => VoicePanelPhase.DONE)
92
+ .on(failed, () => VoicePanelPhase.DONE)
93
+ .reset(this.reset)
94
+
95
+ this.$transcript.on(completed, (_, transcript) => transcript).reset([this.reset, this.restore])
96
+ this.$hasError.on(failed, () => true).reset([this.reset, this.restore])
97
+
98
+ sample({ clock: this.discard, target: this.reset })
99
+ sample({ clock: [this.reset, this.restore], target: this.dropdown.reset })
100
+ }
101
+
102
+ private setupDelayedDismissal() {
103
+ const beginDismissal = createEvent()
104
+
105
+ // Transcript already shown → dim and dismiss right away.
106
+ const dismissWhileDone = this.allowInPhase(this.requestDismiss, VoicePanelPhase.DONE)
107
+
108
+ // Transcript still loading → there is nothing to dim, so defer the request.
109
+ const dismissWhileProcessing = this.allowInPhase(this.requestDismiss, VoicePanelPhase.PROCESSING)
110
+ this.$isDismissPending
111
+ .on(dismissWhileProcessing, () => true)
112
+ .reset([this.reset, this.open, this.restore])
113
+
114
+ // A deferred request fires the moment the transcript resolves into DONE.
115
+ const deferredDismiss = sample({
116
+ clock: this.$phase.updates,
117
+ source: this.$isDismissPending,
118
+ filter: (isDismissPending, phase) => isDismissPending && phase === VoicePanelPhase.DONE,
119
+ fn: () => undefined,
120
+ })
121
+
122
+ sample({ clock: [dismissWhileDone, deferredDismiss], target: beginDismissal })
123
+
124
+ this.$isDimmed.on(beginDismissal, () => true).reset([this.reset, this.open, this.restore])
125
+
126
+ const dismissTimeout = delay({
127
+ source: beginDismissal,
128
+ timeout: VOICE_TRANSCRIPT_PANEL.WRONG_ANSWER_DISMISS_DELAY_MS,
129
+ })
130
+ // The phase guard drops the timer if the panel was already discarded or a
131
+ // new recording started during the linger window
132
+ sample({
133
+ clock: this.allowInPhase(dismissTimeout, VoicePanelPhase.DONE),
134
+ target: this.reset,
135
+ })
136
+ }
137
+ }
@@ -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
@@ -165,6 +165,7 @@
165
165
  "voice": {
166
166
  "attempt": "Attempt {{ number }}",
167
167
  "cancel": "Cancel",
168
+ "couldNotUnderstand": "We couldn't understand your voice. Please try again!",
168
169
  "delete": "Delete",
169
170
  "deleteRecording": "Delete recording?",
170
171
  "noMicrophoneAccess": "No microphone access.",