@magmamath/students-features 1.7.9 → 1.7.10-rc.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.
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 +22 -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 +253 -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 +100 -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 +115 -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 +20 -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 +246 -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 +95 -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 +110 -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 +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 +31 -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 +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 +31 -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 +228 -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 +27 -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 +293 -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 +104 -0
  151. package/src/features/voice/recording/model/VoiceTranscriptPanel.model.ts +131 -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,228 @@
1
+ import { allSettled, createEffect, createEvent, fork, restore } from 'effector'
2
+
3
+ import { TranscriptionStatus, VoicePanelPhase, VoiceRecorderState } from '../constants'
4
+ import { VoiceRecordWithTranscriptModel } from '../recording/model/VoiceRecordWithTranscript.model'
5
+ import type { VoiceRecordModel } from '../recording/model/VoiceRecord.model'
6
+ import type { AudioTranscriptResponse, VoiceRecordCollectionItem } from '../types'
7
+
8
+ const createRecordModelStub = () => {
9
+ const setVoiceRecordState = createEvent<VoiceRecorderState>()
10
+ const setCurrentRecord = createEvent<VoiceRecordCollectionItem | null>()
11
+ const startAudioUpload = createEvent<string>()
12
+ const reset = createEvent()
13
+ const stop = createEffect(async () => {})
14
+
15
+ const stub = {
16
+ recorderModel: {
17
+ $voiceRecordState: restore(setVoiceRecordState, VoiceRecorderState.IDLE),
18
+ },
19
+ $currentRecord: restore(setCurrentRecord, null),
20
+ startAudioUpload,
21
+ reset,
22
+ stop,
23
+ }
24
+
25
+ return {
26
+ // The integration model only touches the members stubbed above
27
+ recordModel: stub as unknown as VoiceRecordModel,
28
+ setVoiceRecordState,
29
+ setCurrentRecord,
30
+ startAudioUpload,
31
+ reset,
32
+ }
33
+ }
34
+
35
+ const completedTranscript = (text: string): AudioTranscriptResponse => ({
36
+ text,
37
+ language: 'en',
38
+ status: TranscriptionStatus.COMPLETED,
39
+ })
40
+
41
+ const createUploadedRecord = (id: string): VoiceRecordCollectionItem => ({
42
+ durationMs: 5000,
43
+ audioUploadPromise: Promise.resolve({ id, fileName: 'rec', fileType: 'audio/mp4' }),
44
+ })
45
+
46
+ describe('VoiceRecordWithTranscriptModel', () => {
47
+ it('opens the panel when recording starts', async () => {
48
+ const stub = createRecordModelStub()
49
+ const model = new VoiceRecordWithTranscriptModel({
50
+ recordModel: stub.recordModel,
51
+ getTranscript: async () => completedTranscript('text'),
52
+ })
53
+ const scope = fork()
54
+
55
+ await allSettled(stub.setVoiceRecordState, { scope, params: VoiceRecorderState.RECORDING })
56
+
57
+ expect(scope.getState(model.panel.$phase)).toBe(VoicePanelPhase.RECORDING)
58
+ })
59
+
60
+ it('moves the panel to processing when the upload starts', async () => {
61
+ const stub = createRecordModelStub()
62
+ const model = new VoiceRecordWithTranscriptModel({
63
+ recordModel: stub.recordModel,
64
+ getTranscript: async () => completedTranscript('text'),
65
+ })
66
+ const scope = fork()
67
+
68
+ await allSettled(stub.setVoiceRecordState, { scope, params: VoiceRecorderState.RECORDING })
69
+ await allSettled(stub.startAudioUpload, { scope, params: 'file://rec' })
70
+
71
+ expect(scope.getState(model.panel.$phase)).toBe(VoicePanelPhase.PROCESSING)
72
+ })
73
+
74
+ it('shows the transcript once the uploaded record resolves', async () => {
75
+ const stub = createRecordModelStub()
76
+ const getTranscript = jest.fn(async () => completedTranscript('sixty times three'))
77
+ const model = new VoiceRecordWithTranscriptModel({
78
+ recordModel: stub.recordModel,
79
+ getTranscript,
80
+ })
81
+ const scope = fork()
82
+
83
+ await allSettled(stub.setVoiceRecordState, { scope, params: VoiceRecorderState.RECORDING })
84
+ await allSettled(stub.startAudioUpload, { scope, params: 'file://rec' })
85
+ await allSettled(stub.setCurrentRecord, { scope, params: createUploadedRecord('audio-1') })
86
+
87
+ expect(getTranscript).toHaveBeenCalledWith('audio-1')
88
+ expect(scope.getState(model.panel.$phase)).toBe(VoicePanelPhase.DONE)
89
+ expect(scope.getState(model.panel.$transcript)).toBe('sixty times three')
90
+ })
91
+
92
+ it('fails the panel when the transcript is not available', async () => {
93
+ const stub = createRecordModelStub()
94
+ const model = new VoiceRecordWithTranscriptModel({
95
+ recordModel: stub.recordModel,
96
+ getTranscript: async () => ({
97
+ text: '',
98
+ language: '',
99
+ status: TranscriptionStatus.FAILED,
100
+ }),
101
+ })
102
+ const scope = fork()
103
+
104
+ await allSettled(stub.setVoiceRecordState, { scope, params: VoiceRecorderState.RECORDING })
105
+ await allSettled(stub.startAudioUpload, { scope, params: 'file://rec' })
106
+ await allSettled(stub.setCurrentRecord, { scope, params: createUploadedRecord('audio-1') })
107
+
108
+ expect(scope.getState(model.panel.$phase)).toBe(VoicePanelPhase.DONE)
109
+ expect(scope.getState(model.panel.$hasError)).toBe(true)
110
+ })
111
+
112
+ it('discards the panel when the record is deleted', async () => {
113
+ const stub = createRecordModelStub()
114
+ const model = new VoiceRecordWithTranscriptModel({
115
+ recordModel: stub.recordModel,
116
+ getTranscript: async () => completedTranscript('text'),
117
+ })
118
+ const scope = fork()
119
+
120
+ await allSettled(stub.setVoiceRecordState, { scope, params: VoiceRecorderState.RECORDING })
121
+ await allSettled(stub.reset, { scope })
122
+
123
+ expect(scope.getState(model.panel.$phase)).toBeNull()
124
+ })
125
+
126
+ it('keeps the dimmed panel open when the record resets during dismissal', async () => {
127
+ jest.useFakeTimers()
128
+ try {
129
+ const stub = createRecordModelStub()
130
+ const model = new VoiceRecordWithTranscriptModel({
131
+ recordModel: stub.recordModel,
132
+ getTranscript: async () => completedTranscript('text'),
133
+ })
134
+ const scope = fork()
135
+
136
+ // Drive the panel straight to DONE — this test isolates the wrapper's
137
+ // reset-suppression wiring, not the transcript fetch effect
138
+ await allSettled(model.panel.open, { scope })
139
+ await allSettled(model.panel.confirm, { scope })
140
+ await allSettled(model.panel.complete, { scope, params: 'text' })
141
+ // Wrong answer: the panel starts dimming (linger timer running), then the
142
+ // attempt key rotation resets the record — that incidental reset must not
143
+ // close the panel. Both stores update synchronously, so assert before the
144
+ // timer; awaiting allSettled here would block on the pending linger delay.
145
+ void allSettled(model.panel.requestDismiss, { scope })
146
+ void allSettled(stub.reset, { scope })
147
+
148
+ expect(scope.getState(model.panel.$flags).isDone).toBe(true)
149
+ expect(scope.getState(model.panel.$isDimmed)).toBe(true)
150
+
151
+ await jest.runAllTimersAsync()
152
+ } finally {
153
+ jest.clearAllTimers()
154
+ jest.useRealTimers()
155
+ }
156
+ })
157
+
158
+ it('shows an in-flight transcript before dismissing when a wrong answer lands mid-load', async () => {
159
+ jest.useFakeTimers()
160
+ try {
161
+ let resolveTranscript!: (response: AudioTranscriptResponse) => void
162
+ const transcriptPromise = new Promise<AudioTranscriptResponse>((resolve) => {
163
+ resolveTranscript = resolve
164
+ })
165
+ const stub = createRecordModelStub()
166
+ const model = new VoiceRecordWithTranscriptModel({
167
+ recordModel: stub.recordModel,
168
+ getTranscript: () => transcriptPromise,
169
+ })
170
+ const scope = fork()
171
+
172
+ await allSettled(stub.setVoiceRecordState, { scope, params: VoiceRecorderState.RECORDING })
173
+ await allSettled(stub.startAudioUpload, { scope, params: 'file://rec' })
174
+ // The transcript fetch stays in flight (promise unresolved), so the scope
175
+ // never goes idle — awaiting allSettled would hang. These triggers apply
176
+ // their store updates synchronously, so fire them without awaiting.
177
+ void allSettled(stub.setCurrentRecord, { scope, params: createUploadedRecord('audio-1') })
178
+
179
+ expect(scope.getState(model.panel.$phase)).toBe(VoicePanelPhase.PROCESSING)
180
+
181
+ // Wrong answer mid-load: the dismissal is deferred and the attempt key
182
+ // rotation both resets the record and rotates $currentRecord away from
183
+ // the take whose transcript is still loading.
184
+ void allSettled(model.panel.requestDismiss, { scope })
185
+ void allSettled(stub.reset, { scope })
186
+ void allSettled(stub.setCurrentRecord, { scope, params: null })
187
+
188
+ expect(scope.getState(model.panel.$phase)).toBe(VoicePanelPhase.PROCESSING)
189
+ expect(scope.getState(model.panel.$isDismissPending)).toBe(true)
190
+
191
+ // The transcript finally arrives — it must still be shown briefly even
192
+ // though $currentRecord has moved on. Flush the fetch's microtasks
193
+ // without advancing the linger timer.
194
+ resolveTranscript(completedTranscript('mid-load transcript'))
195
+ await jest.advanceTimersByTimeAsync(0)
196
+
197
+ expect(scope.getState(model.panel.$phase)).toBe(VoicePanelPhase.DONE)
198
+ expect(scope.getState(model.panel.$transcript)).toBe('mid-load transcript')
199
+ expect(scope.getState(model.panel.$isDimmed)).toBe(true)
200
+
201
+ await jest.runAllTimersAsync()
202
+ expect(scope.getState(model.panel.$flags).isOpen).toBe(false)
203
+ } finally {
204
+ jest.clearAllTimers()
205
+ jest.useRealTimers()
206
+ }
207
+ })
208
+
209
+ it('drops a transcript fetched for a record that is no longer current', async () => {
210
+ const stub = createRecordModelStub()
211
+ const model = new VoiceRecordWithTranscriptModel({
212
+ recordModel: stub.recordModel,
213
+ getTranscript: async () => completedTranscript('stale text'),
214
+ })
215
+ const scope = fork()
216
+
217
+ await allSettled(stub.setVoiceRecordState, { scope, params: VoiceRecorderState.RECORDING })
218
+ await allSettled(stub.startAudioUpload, { scope, params: 'file://rec' })
219
+
220
+ // The fetched record is replaced before its transcript resolves
221
+ const staleRecord = createUploadedRecord('audio-stale')
222
+ const replacementPromise = allSettled(stub.setCurrentRecord, { scope, params: staleRecord })
223
+ await allSettled(stub.setCurrentRecord, { scope, params: { durationMs: 1 } })
224
+ await replacementPromise
225
+
226
+ expect(scope.getState(model.panel.$transcript)).toBe('')
227
+ })
228
+ })
@@ -0,0 +1,237 @@
1
+ import { allSettled, fork } from 'effector'
2
+
3
+ import { VoicePanelPhase } from '../constants'
4
+ import { VoiceTranscriptPanelModel } from '../recording/model/VoiceTranscriptPanel.model'
5
+
6
+ describe('VoiceTranscriptPanelModel', () => {
7
+ it('opens into the recording phase', async () => {
8
+ const model = new VoiceTranscriptPanelModel()
9
+ const scope = fork()
10
+
11
+ await allSettled(model.open, { scope })
12
+
13
+ expect(scope.getState(model.$phase)).toBe(VoicePanelPhase.RECORDING)
14
+ expect(scope.getState(model.$flags).isRecording).toBe(true)
15
+ })
16
+
17
+ it('confirm moves recording to processing', async () => {
18
+ const model = new VoiceTranscriptPanelModel()
19
+ const scope = fork()
20
+
21
+ await allSettled(model.open, { scope })
22
+ await allSettled(model.confirm, { scope })
23
+
24
+ expect(scope.getState(model.$phase)).toBe(VoicePanelPhase.PROCESSING)
25
+ })
26
+
27
+ it('ignores confirm when the panel is not recording', async () => {
28
+ const model = new VoiceTranscriptPanelModel()
29
+ const scope = fork()
30
+
31
+ await allSettled(model.confirm, { scope })
32
+
33
+ expect(scope.getState(model.$phase)).toBeNull()
34
+ })
35
+
36
+ it('complete lands on done with the transcript text', async () => {
37
+ const transcript = 'three times sixty is one hundred eighty'
38
+ const model = new VoiceTranscriptPanelModel()
39
+ const scope = fork()
40
+
41
+ await allSettled(model.open, { scope })
42
+ await allSettled(model.confirm, { scope })
43
+ await allSettled(model.complete, { scope, params: transcript })
44
+
45
+ expect(scope.getState(model.$phase)).toBe(VoicePanelPhase.DONE)
46
+ expect(scope.getState(model.$transcript)).toBe(transcript)
47
+ expect(scope.getState(model.$hasError)).toBe(false)
48
+ })
49
+
50
+ it('fail lands on done with the error flag', async () => {
51
+ const model = new VoiceTranscriptPanelModel()
52
+ const scope = fork()
53
+
54
+ await allSettled(model.open, { scope })
55
+ await allSettled(model.confirm, { scope })
56
+ await allSettled(model.fail, { scope })
57
+
58
+ expect(scope.getState(model.$phase)).toBe(VoicePanelPhase.DONE)
59
+ expect(scope.getState(model.$hasError)).toBe(true)
60
+ })
61
+
62
+ it('ignores a transcript that arrives after discard', async () => {
63
+ const model = new VoiceTranscriptPanelModel()
64
+ const scope = fork()
65
+
66
+ await allSettled(model.open, { scope })
67
+ await allSettled(model.confirm, { scope })
68
+ await allSettled(model.discard, { scope })
69
+ await allSettled(model.complete, { scope, params: 'late transcript' })
70
+
71
+ expect(scope.getState(model.$phase)).toBeNull()
72
+ expect(scope.getState(model.$transcript)).toBe('')
73
+ })
74
+
75
+ it('discard closes the panel', async () => {
76
+ const model = new VoiceTranscriptPanelModel()
77
+ const scope = fork()
78
+
79
+ await allSettled(model.open, { scope })
80
+ await allSettled(model.discard, { scope })
81
+
82
+ expect(scope.getState(model.$flags).isOpen).toBe(false)
83
+ })
84
+
85
+ it('discard after done clears the transcript', async () => {
86
+ const model = new VoiceTranscriptPanelModel()
87
+ const scope = fork()
88
+
89
+ await allSettled(model.open, { scope })
90
+ await allSettled(model.confirm, { scope })
91
+ await allSettled(model.complete, { scope, params: 'transcript' })
92
+ await allSettled(model.discard, { scope })
93
+
94
+ expect(scope.getState(model.$phase)).toBeNull()
95
+ expect(scope.getState(model.$transcript)).toBe('')
96
+ })
97
+
98
+ it('the transcript arrives collapsed by default', async () => {
99
+ const model = new VoiceTranscriptPanelModel()
100
+ const scope = fork()
101
+
102
+ await allSettled(model.open, { scope })
103
+ await allSettled(model.confirm, { scope })
104
+ await allSettled(model.complete, { scope, params: 'transcript' })
105
+
106
+ expect(scope.getState(model.$phase)).toBe(VoicePanelPhase.DONE)
107
+ expect(scope.getState(model.dropdown.$isExpanded)).toBe(false)
108
+ })
109
+
110
+ it('toggleExpand unfolds the collapsed dropdown', async () => {
111
+ const model = new VoiceTranscriptPanelModel()
112
+ const scope = fork()
113
+
114
+ await allSettled(model.open, { scope })
115
+ await allSettled(model.confirm, { scope })
116
+ await allSettled(model.complete, { scope, params: 'transcript' })
117
+ await allSettled(model.dropdown.toggleExpand, { scope })
118
+
119
+ expect(scope.getState(model.dropdown.$isExpanded)).toBe(true)
120
+ })
121
+
122
+ it('discard resets the dropdown state', async () => {
123
+ const model = new VoiceTranscriptPanelModel()
124
+ const scope = fork()
125
+
126
+ await allSettled(model.open, { scope })
127
+ await allSettled(model.confirm, { scope })
128
+ await allSettled(model.complete, { scope, params: 'transcript' })
129
+ await allSettled(model.dropdown.toggleExpand, { scope })
130
+ await allSettled(model.discard, { scope })
131
+
132
+ expect(scope.getState(model.dropdown.$isExpanded)).toBe(false)
133
+ expect(scope.getState(model.$flags).isOpen).toBe(false)
134
+ })
135
+
136
+ describe('requestDismiss', () => {
137
+ beforeEach(() => jest.useFakeTimers())
138
+ afterEach(() => jest.useRealTimers())
139
+
140
+ const reachDone = async (scope: ReturnType<typeof fork>, model: VoiceTranscriptPanelModel) => {
141
+ await allSettled(model.open, { scope })
142
+ await allSettled(model.confirm, { scope })
143
+ await allSettled(model.complete, { scope, params: 'transcript' })
144
+ }
145
+
146
+ it('dims the transcript immediately but keeps it open', async () => {
147
+ const model = new VoiceTranscriptPanelModel()
148
+ const scope = fork()
149
+
150
+ await reachDone(scope, model)
151
+ // Don't await — that would block on the linger timer; the dim is synchronous
152
+ const settled = allSettled(model.requestDismiss, { scope })
153
+
154
+ expect(scope.getState(model.$isDimmed)).toBe(true)
155
+ expect(scope.getState(model.$flags).isOpen).toBe(true)
156
+
157
+ await jest.runAllTimersAsync()
158
+ await settled
159
+ })
160
+
161
+ it('dismisses the panel after the linger delay', async () => {
162
+ const model = new VoiceTranscriptPanelModel()
163
+ const scope = fork()
164
+
165
+ await reachDone(scope, model)
166
+ const settled = allSettled(model.requestDismiss, { scope })
167
+ await jest.runAllTimersAsync()
168
+ await settled
169
+
170
+ expect(scope.getState(model.$flags).isOpen).toBe(false)
171
+ expect(scope.getState(model.$isDimmed)).toBe(false)
172
+ })
173
+
174
+ it('ignores requestDismiss outside the done phase', async () => {
175
+ const model = new VoiceTranscriptPanelModel()
176
+ const scope = fork()
177
+
178
+ await allSettled(model.open, { scope })
179
+ await allSettled(model.requestDismiss, { scope })
180
+
181
+ expect(scope.getState(model.$isDimmed)).toBe(false)
182
+ expect(scope.getState(model.$phase)).toBe(VoicePanelPhase.RECORDING)
183
+ })
184
+
185
+ it('defers a dismissal requested while the transcript is still loading', async () => {
186
+ const model = new VoiceTranscriptPanelModel()
187
+ const scope = fork()
188
+
189
+ await allSettled(model.open, { scope })
190
+ await allSettled(model.confirm, { scope })
191
+ await allSettled(model.requestDismiss, { scope })
192
+
193
+ // Nothing to dim yet — the panel stays open in processing, but marks the
194
+ // dismissal as pending and protects itself from incidental closes.
195
+ expect(scope.getState(model.$phase)).toBe(VoicePanelPhase.PROCESSING)
196
+ expect(scope.getState(model.$isDimmed)).toBe(false)
197
+ expect(scope.getState(model.$flags).isOpen).toBe(true)
198
+ expect(scope.getState(model.$isDismissPending)).toBe(true)
199
+ expect(scope.getState(model.$isHandlingDismissal)).toBe(true)
200
+ })
201
+
202
+ it('runs the deferred dismissal once the transcript arrives', async () => {
203
+ const model = new VoiceTranscriptPanelModel()
204
+ const scope = fork()
205
+
206
+ await allSettled(model.open, { scope })
207
+ await allSettled(model.confirm, { scope })
208
+ await allSettled(model.requestDismiss, { scope })
209
+
210
+ // The transcript lands after the wrong answer — it shows, then dims.
211
+ const settled = allSettled(model.complete, { scope, params: 'late transcript' })
212
+
213
+ expect(scope.getState(model.$phase)).toBe(VoicePanelPhase.DONE)
214
+ expect(scope.getState(model.$transcript)).toBe('late transcript')
215
+ expect(scope.getState(model.$isDimmed)).toBe(true)
216
+
217
+ await jest.runAllTimersAsync()
218
+ await settled
219
+
220
+ expect(scope.getState(model.$flags).isOpen).toBe(false)
221
+ expect(scope.getState(model.$isDismissPending)).toBe(false)
222
+ })
223
+
224
+ it('clears a pending dismissal when a new recording opens the panel', async () => {
225
+ const model = new VoiceTranscriptPanelModel()
226
+ const scope = fork()
227
+
228
+ await allSettled(model.open, { scope })
229
+ await allSettled(model.confirm, { scope })
230
+ await allSettled(model.requestDismiss, { scope })
231
+ await allSettled(model.open, { scope })
232
+
233
+ expect(scope.getState(model.$isDismissPending)).toBe(false)
234
+ expect(scope.getState(model.$phase)).toBe(VoicePanelPhase.RECORDING)
235
+ })
236
+ })
237
+ })
@@ -20,8 +20,51 @@ export const TRANSCRIPT_RETRY_INTERVAL_MS = 4000
20
20
  export const TRANSCRIPT_MAX_RETRIES = 15
21
21
  export const NO_AUDIO_BE_MESSAGE = 'No audio'
22
22
 
23
+ export enum TranscriptionStatus {
24
+ PENDING = 'pending',
25
+ COMPLETED = 'completed',
26
+ FAILED = 'failed',
27
+ }
28
+
23
29
  //todo: remove native shadows from rn-ui and set from WEB for both platforms
24
30
  export const VOICE_RECORD_SHADOWS = {
25
31
  default: '0 1px 3px 0 rgba(51, 51, 51, 0.10), 0 0 1px 0 rgba(51, 51, 51, 0.40)',
26
32
  advanced: '0 0 1px 0 rgba(51, 51, 51, 0.20), 0 3px 10px 0 rgba(51, 51, 51, 0.16)',
27
33
  }
34
+
35
+ export enum VoicePanelPhase {
36
+ RECORDING = 'recording',
37
+ PROCESSING = 'processing',
38
+ DONE = 'done',
39
+ }
40
+
41
+ export const VOICE_WAVEFORM = {
42
+ BAR_WIDTH: 3,
43
+ BAR_GAP: 3,
44
+ MIN_BAR_HEIGHT: 2,
45
+ HEIGHT: 40,
46
+ AMPLITUDE_PADDING: 12,
47
+ MIN_LEVEL: 0.04,
48
+ MIN_BARS: 16,
49
+ BUFFER_SIZE: 120,
50
+ SAMPLE_INTERVAL_MS: 80,
51
+ FADE_RATIO: 0.22,
52
+ METERING_FLOOR_DB: -60,
53
+ // Low-pass factor for bar levels: 1 = raw values, lower = smoother flow
54
+ LEVEL_SMOOTHING: 0.55,
55
+ } as const
56
+
57
+ // expo-audio polls recorder status every 500ms by default — too coarse for a
58
+ // live waveform. The same cadence drives the web Audio API meter.
59
+ export const METERING_UPDATE_INTERVAL_MS = 100
60
+ export const WEB_METERING_FFT_SIZE = 1024
61
+
62
+ export const VOICE_TRANSCRIPT_PANEL = {
63
+ WIDTH: 240,
64
+ WIDTH_DONE: 360,
65
+ TRANSCRIPT_MAX_HEIGHT: 200,
66
+ SCROLLBAR_WIDTH: 8,
67
+ // After a wrong answer the transcript fades out, lingers, then auto-dismisses
68
+ WRONG_ANSWER_DISMISS_DELAY_MS: 5000,
69
+ DIMMED_OPACITY: 0.4,
70
+ } as const
@@ -8,7 +8,12 @@ import {
8
8
  } from 'expo-audio'
9
9
  import * as FileSystem from 'expo-file-system'
10
10
  import { VoicePlayerApi, VoiceRecorderApi } from './types'
11
- import { VOICE_RECORDER_MAX_DURATION_MS } from './constants'
11
+ import {
12
+ METERING_UPDATE_INTERVAL_MS,
13
+ VOICE_RECORDER_MAX_DURATION_MS,
14
+ VOICE_WAVEFORM,
15
+ WEB_METERING_FFT_SIZE,
16
+ } from './constants'
12
17
  import { PlayerState } from './playing/model'
13
18
  import { AudioStatus } from '../chatbot'
14
19
  import { AxiosResponse } from 'axios'
@@ -20,6 +25,85 @@ export const formatDuration = (ms: number): string => {
20
25
  return `${minutes}:${seconds.toString().padStart(2, '0')}`
21
26
  }
22
27
 
28
+ // Maps a metering value (dBFS, roughly -160..0) to a 0..1 bar level.
29
+ // Anything at or below the floor reads as silence (MIN_LEVEL dots).
30
+ export const normalizeMetering = (metering: number | null | undefined): number => {
31
+ if (metering === null || metering === undefined || Number.isNaN(metering)) {
32
+ return VOICE_WAVEFORM.MIN_LEVEL
33
+ }
34
+
35
+ const range = -VOICE_WAVEFORM.METERING_FLOOR_DB
36
+ const ratio = (metering - VOICE_WAVEFORM.METERING_FLOOR_DB) / range
37
+ return Math.max(VOICE_WAVEFORM.MIN_LEVEL, Math.min(1, ratio))
38
+ }
39
+
40
+ type WebMediaRecorderHolder = { mediaRecorder: MediaRecorder }
41
+
42
+ // expo-audio's web AudioRecorder keeps its MediaRecorder on a public (but
43
+ // untyped) field. Reach for it defensively — if the internals change after an
44
+ // upgrade, we fall back to our own getUserMedia stream.
45
+ const hasWebMediaRecorder = (value: unknown): value is WebMediaRecorderHolder => {
46
+ if (typeof value !== 'object' || value === null) return false
47
+ if (typeof MediaRecorder === 'undefined') return false
48
+ return 'mediaRecorder' in value && value.mediaRecorder instanceof MediaRecorder
49
+ }
50
+
51
+ type MeteringCallback = (decibels: number | null) => void
52
+
53
+ const computeStreamDecibels = (analyser: AnalyserNode, samples: Float32Array): number => {
54
+ analyser.getFloatTimeDomainData(samples)
55
+ const sumOfSquares = samples.reduce((acc, value) => acc + value * value, 0)
56
+ const rms = Math.sqrt(sumOfSquares / samples.length)
57
+ return rms > 0 ? 20 * Math.log10(rms) : VOICE_WAVEFORM.METERING_FLOOR_DB
58
+ }
59
+
60
+ // expo-audio does not implement metering on web, so we measure the input level
61
+ // ourselves with the Web Audio API. Prefers the recorder's own MediaStream;
62
+ // falls back to a dedicated getUserMedia capture (permission is already
63
+ // granted at this point because recording is in progress).
64
+ export const createWebMeteringMonitor = (
65
+ recorder: AudioRecorder,
66
+ onMetering: MeteringCallback,
67
+ ): (() => void) => {
68
+ if (!IS_WEB || typeof AudioContext === 'undefined') return () => {}
69
+
70
+ let isDisposed = false
71
+ let context: AudioContext | null = null
72
+ let ownedStream: MediaStream | null = null
73
+ let intervalId: ReturnType<typeof setInterval> | null = null
74
+
75
+ const start = async () => {
76
+ const recorderStream = hasWebMediaRecorder(recorder) ? recorder.mediaRecorder.stream : null
77
+ const stream = recorderStream ?? (await navigator.mediaDevices.getUserMedia({ audio: true }))
78
+
79
+ if (isDisposed) {
80
+ if (!recorderStream) stream.getTracks().forEach((track) => track.stop())
81
+ return
82
+ }
83
+ if (!recorderStream) ownedStream = stream
84
+
85
+ context = new AudioContext()
86
+ const analyser = context.createAnalyser()
87
+ analyser.fftSize = WEB_METERING_FFT_SIZE
88
+ context.createMediaStreamSource(stream).connect(analyser)
89
+
90
+ const samples = new Float32Array(analyser.fftSize)
91
+ intervalId = setInterval(() => {
92
+ onMetering(computeStreamDecibels(analyser, samples))
93
+ }, METERING_UPDATE_INTERVAL_MS)
94
+ }
95
+
96
+ start().catch(() => onMetering(null))
97
+
98
+ return () => {
99
+ isDisposed = true
100
+ if (intervalId) clearInterval(intervalId)
101
+ ownedStream?.getTracks().forEach((track) => track.stop())
102
+ context?.close().catch(() => {})
103
+ onMetering(null)
104
+ }
105
+ }
106
+
23
107
  export const ensureRecordingPermissions = async () => {
24
108
  const { status } = await getRecordingPermissionsAsync()
25
109
 
@@ -1,7 +1,14 @@
1
1
  export * from './recording/components/VoiceRecord'
2
+ export * from './recording/components/VoiceRecordWithTranscript'
3
+ export * from './recording/components/VoiceWaveform'
4
+ export * from './recording/components/VoiceTranscriptPanel'
5
+ export * from './recording/hooks/useVoiceWaveform'
2
6
  export * from './recording/model/VoiceRecord.model'
7
+ export * from './recording/model/VoiceRecordWithTranscript.model'
8
+ export * from './recording/model/VoiceTranscriptPanel.model'
3
9
  export * from './recording/modal/VoiceRecordDeleteModal'
4
10
  export * from './recording/modal/VoiceRecordUndoModal'
11
+ export * from './transcript.helpers'
5
12
 
6
13
  export * from './playing/model/VoicePlayer.model'
7
14
  export * from './playing/components/VoiceTranscription'
@@ -8,33 +8,52 @@ import {
8
8
  } from '@magmamath/react-native-ui'
9
9
  import React from 'react'
10
10
  import { StyleSheet, View } from 'react-native'
11
+ import Animated, { FadeIn } from 'react-native-reanimated'
12
+
13
+ import { VOICE_TRANSCRIPT_PANEL } from '../../constants'
11
14
 
12
15
  type VoiceTranscriptContentProps = {
13
16
  text: string
14
17
  isLoading: boolean
18
+ numberOfLines?: number
19
+ dimmed?: boolean
15
20
  }
16
21
 
17
- export const VoiceTranscriptContent = ({ text, isLoading }: VoiceTranscriptContentProps) => {
22
+ export const VoiceTranscriptContent = ({
23
+ text,
24
+ isLoading,
25
+ numberOfLines,
26
+ dimmed,
27
+ }: VoiceTranscriptContentProps) => {
18
28
  if (isLoading) {
19
29
  return (
20
30
  <View style={styles.loaderContainer}>
21
- <Loader size={LoaderSize.SMALL} color={LoaderColor.BLUE} />
31
+ <Loader size={LoaderSize.SMALL} color={LoaderColor.GRAY} />
22
32
  </View>
23
33
  )
24
34
  }
25
35
 
26
36
  return (
27
- <Typography variant="h8" style={styles.text}>
28
- {text}
29
- </Typography>
37
+ <Animated.View entering={FadeIn.duration(400)}>
38
+ <Typography
39
+ variant="h8"
40
+ style={[styles.text, dimmed && styles.dimmed]}
41
+ numberOfLines={numberOfLines}
42
+ >
43
+ {text}
44
+ </Typography>
45
+ </Animated.View>
30
46
  )
31
47
  }
32
48
 
33
49
  const styles = StyleSheet.create({
34
50
  text: {
35
- color: COLORS.NEUTRAL_7,
36
- paddingHorizontal: SPACING[200],
37
- paddingVertical: SPACING[200],
51
+ color: COLORS.NEUTRAL_9,
52
+ paddingVertical: SPACING[100],
53
+ marginRight: SPACING[200],
54
+ },
55
+ dimmed: {
56
+ opacity: VOICE_TRANSCRIPT_PANEL.DIMMED_OPACITY,
38
57
  },
39
58
  loaderContainer: {
40
59
  justifyContent: 'center',