@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.
- package/dist/commonjs/features/voice/constants.js +42 -3
- package/dist/commonjs/features/voice/constants.js.map +1 -1
- package/dist/commonjs/features/voice/helpers.js +67 -1
- package/dist/commonjs/features/voice/helpers.js.map +1 -1
- package/dist/commonjs/features/voice/index.js +77 -0
- package/dist/commonjs/features/voice/index.js.map +1 -1
- package/dist/commonjs/features/voice/playing/components/VoiceTranscriptContent.js +22 -9
- package/dist/commonjs/features/voice/playing/components/VoiceTranscriptContent.js.map +1 -1
- package/dist/commonjs/features/voice/playing/model/TranscriptionsDownloader.model.js +4 -27
- package/dist/commonjs/features/voice/playing/model/TranscriptionsDownloader.model.js.map +1 -1
- package/dist/commonjs/features/voice/recording/components/VoiceRecordWithTranscript.js +49 -0
- package/dist/commonjs/features/voice/recording/components/VoiceRecordWithTranscript.js.map +1 -0
- package/dist/commonjs/features/voice/recording/components/VoiceTranscriptPanel.js +253 -0
- package/dist/commonjs/features/voice/recording/components/VoiceTranscriptPanel.js.map +1 -0
- package/dist/commonjs/features/voice/recording/components/VoiceWaveform.js +93 -0
- package/dist/commonjs/features/voice/recording/components/VoiceWaveform.js.map +1 -0
- package/dist/commonjs/features/voice/recording/hooks/useTranscriptPanelAnimation.js +42 -0
- package/dist/commonjs/features/voice/recording/hooks/useTranscriptPanelAnimation.js.map +1 -0
- package/dist/commonjs/features/voice/recording/hooks/useVoiceRecorder.js +17 -2
- package/dist/commonjs/features/voice/recording/hooks/useVoiceRecorder.js.map +1 -1
- package/dist/commonjs/features/voice/recording/hooks/useVoiceWaveform.js +43 -0
- package/dist/commonjs/features/voice/recording/hooks/useVoiceWaveform.js.map +1 -0
- package/dist/commonjs/features/voice/recording/model/Recorder.model.js +3 -0
- package/dist/commonjs/features/voice/recording/model/Recorder.model.js.map +1 -1
- package/dist/commonjs/features/voice/recording/model/VoiceRecordWithTranscript.model.js +100 -0
- package/dist/commonjs/features/voice/recording/model/VoiceRecordWithTranscript.model.js.map +1 -0
- package/dist/commonjs/features/voice/recording/model/VoiceTranscriptPanel.model.js +115 -0
- package/dist/commonjs/features/voice/recording/model/VoiceTranscriptPanel.model.js.map +1 -0
- package/dist/commonjs/features/voice/transcript.helpers.js +36 -0
- package/dist/commonjs/features/voice/transcript.helpers.js.map +1 -0
- package/dist/commonjs/features/voice/types.js +11 -7
- package/dist/commonjs/features/voice/types.js.map +1 -1
- package/dist/module/features/voice/constants.js +41 -0
- package/dist/module/features/voice/constants.js.map +1 -1
- package/dist/module/features/voice/helpers.js +65 -1
- package/dist/module/features/voice/helpers.js.map +1 -1
- package/dist/module/features/voice/index.js +7 -0
- package/dist/module/features/voice/index.js.map +1 -1
- package/dist/module/features/voice/playing/components/VoiceTranscriptContent.js +20 -9
- package/dist/module/features/voice/playing/components/VoiceTranscriptContent.js.map +1 -1
- package/dist/module/features/voice/playing/model/TranscriptionsDownloader.model.js +4 -27
- package/dist/module/features/voice/playing/model/TranscriptionsDownloader.model.js.map +1 -1
- package/dist/module/features/voice/recording/components/VoiceRecordWithTranscript.js +43 -0
- package/dist/module/features/voice/recording/components/VoiceRecordWithTranscript.js.map +1 -0
- package/dist/module/features/voice/recording/components/VoiceTranscriptPanel.js +246 -0
- package/dist/module/features/voice/recording/components/VoiceTranscriptPanel.js.map +1 -0
- package/dist/module/features/voice/recording/components/VoiceWaveform.js +86 -0
- package/dist/module/features/voice/recording/components/VoiceWaveform.js.map +1 -0
- package/dist/module/features/voice/recording/hooks/useTranscriptPanelAnimation.js +37 -0
- package/dist/module/features/voice/recording/hooks/useTranscriptPanelAnimation.js.map +1 -0
- package/dist/module/features/voice/recording/hooks/useVoiceRecorder.js +20 -3
- package/dist/module/features/voice/recording/hooks/useVoiceRecorder.js.map +1 -1
- package/dist/module/features/voice/recording/hooks/useVoiceWaveform.js +38 -0
- package/dist/module/features/voice/recording/hooks/useVoiceWaveform.js.map +1 -0
- package/dist/module/features/voice/recording/model/Recorder.model.js +3 -0
- package/dist/module/features/voice/recording/model/Recorder.model.js.map +1 -1
- package/dist/module/features/voice/recording/model/VoiceRecordWithTranscript.model.js +95 -0
- package/dist/module/features/voice/recording/model/VoiceRecordWithTranscript.model.js.map +1 -0
- package/dist/module/features/voice/recording/model/VoiceTranscriptPanel.model.js +110 -0
- package/dist/module/features/voice/recording/model/VoiceTranscriptPanel.model.js.map +1 -0
- package/dist/module/features/voice/transcript.helpers.js +31 -0
- package/dist/module/features/voice/transcript.helpers.js.map +1 -0
- package/dist/module/features/voice/types.js +4 -6
- package/dist/module/features/voice/types.js.map +1 -1
- package/dist/typescript/commonjs/features/voice/__tests__/VoiceRecordWithTranscript.model.test.d.ts +2 -0
- package/dist/typescript/commonjs/features/voice/__tests__/VoiceRecordWithTranscript.model.test.d.ts.map +1 -0
- package/dist/typescript/commonjs/features/voice/__tests__/VoiceTranscriptPanel.model.test.d.ts +2 -0
- package/dist/typescript/commonjs/features/voice/__tests__/VoiceTranscriptPanel.model.test.d.ts.map +1 -0
- package/dist/typescript/commonjs/features/voice/constants.d.ts +34 -0
- package/dist/typescript/commonjs/features/voice/constants.d.ts.map +1 -1
- package/dist/typescript/commonjs/features/voice/helpers.d.ts +3 -0
- package/dist/typescript/commonjs/features/voice/helpers.d.ts.map +1 -1
- package/dist/typescript/commonjs/features/voice/index.d.ts +7 -0
- package/dist/typescript/commonjs/features/voice/index.d.ts.map +1 -1
- package/dist/typescript/commonjs/features/voice/playing/components/VoiceTranscriptContent.d.ts +3 -1
- package/dist/typescript/commonjs/features/voice/playing/components/VoiceTranscriptContent.d.ts.map +1 -1
- package/dist/typescript/commonjs/features/voice/playing/model/TranscriptionsDownloader.model.d.ts +2 -7
- package/dist/typescript/commonjs/features/voice/playing/model/TranscriptionsDownloader.model.d.ts.map +1 -1
- package/dist/typescript/commonjs/features/voice/recording/components/VoiceRecordWithTranscript.d.ts +13 -0
- package/dist/typescript/commonjs/features/voice/recording/components/VoiceRecordWithTranscript.d.ts.map +1 -0
- package/dist/typescript/commonjs/features/voice/recording/components/VoiceTranscriptPanel.d.ts +11 -0
- package/dist/typescript/commonjs/features/voice/recording/components/VoiceTranscriptPanel.d.ts.map +1 -0
- package/dist/typescript/commonjs/features/voice/recording/components/VoiceWaveform.d.ts +8 -0
- package/dist/typescript/commonjs/features/voice/recording/components/VoiceWaveform.d.ts.map +1 -0
- package/dist/typescript/commonjs/features/voice/recording/hooks/useTranscriptPanelAnimation.d.ts +18 -0
- package/dist/typescript/commonjs/features/voice/recording/hooks/useTranscriptPanelAnimation.d.ts.map +1 -0
- package/dist/typescript/commonjs/features/voice/recording/hooks/useVoiceRecorder.d.ts.map +1 -1
- package/dist/typescript/commonjs/features/voice/recording/hooks/useVoiceWaveform.d.ts +7 -0
- package/dist/typescript/commonjs/features/voice/recording/hooks/useVoiceWaveform.d.ts.map +1 -0
- package/dist/typescript/commonjs/features/voice/recording/model/Recorder.model.d.ts +2 -0
- package/dist/typescript/commonjs/features/voice/recording/model/Recorder.model.d.ts.map +1 -1
- package/dist/typescript/commonjs/features/voice/recording/model/VoiceRecordWithTranscript.model.d.ts +16 -0
- package/dist/typescript/commonjs/features/voice/recording/model/VoiceRecordWithTranscript.model.d.ts.map +1 -0
- package/dist/typescript/commonjs/features/voice/recording/model/VoiceTranscriptPanel.model.d.ts +31 -0
- package/dist/typescript/commonjs/features/voice/recording/model/VoiceTranscriptPanel.model.d.ts.map +1 -0
- package/dist/typescript/commonjs/features/voice/transcript.helpers.d.ts +8 -0
- package/dist/typescript/commonjs/features/voice/transcript.helpers.d.ts.map +1 -0
- package/dist/typescript/commonjs/features/voice/types.d.ts +2 -5
- package/dist/typescript/commonjs/features/voice/types.d.ts.map +1 -1
- package/dist/typescript/module/features/voice/__tests__/VoiceRecordWithTranscript.model.test.d.ts +2 -0
- package/dist/typescript/module/features/voice/__tests__/VoiceRecordWithTranscript.model.test.d.ts.map +1 -0
- package/dist/typescript/module/features/voice/__tests__/VoiceTranscriptPanel.model.test.d.ts +2 -0
- package/dist/typescript/module/features/voice/__tests__/VoiceTranscriptPanel.model.test.d.ts.map +1 -0
- package/dist/typescript/module/features/voice/constants.d.ts +34 -0
- package/dist/typescript/module/features/voice/constants.d.ts.map +1 -1
- package/dist/typescript/module/features/voice/helpers.d.ts +3 -0
- package/dist/typescript/module/features/voice/helpers.d.ts.map +1 -1
- package/dist/typescript/module/features/voice/index.d.ts +7 -0
- package/dist/typescript/module/features/voice/index.d.ts.map +1 -1
- package/dist/typescript/module/features/voice/playing/components/VoiceTranscriptContent.d.ts +3 -1
- package/dist/typescript/module/features/voice/playing/components/VoiceTranscriptContent.d.ts.map +1 -1
- package/dist/typescript/module/features/voice/playing/model/TranscriptionsDownloader.model.d.ts +2 -7
- package/dist/typescript/module/features/voice/playing/model/TranscriptionsDownloader.model.d.ts.map +1 -1
- package/dist/typescript/module/features/voice/recording/components/VoiceRecordWithTranscript.d.ts +13 -0
- package/dist/typescript/module/features/voice/recording/components/VoiceRecordWithTranscript.d.ts.map +1 -0
- package/dist/typescript/module/features/voice/recording/components/VoiceTranscriptPanel.d.ts +11 -0
- package/dist/typescript/module/features/voice/recording/components/VoiceTranscriptPanel.d.ts.map +1 -0
- package/dist/typescript/module/features/voice/recording/components/VoiceWaveform.d.ts +8 -0
- package/dist/typescript/module/features/voice/recording/components/VoiceWaveform.d.ts.map +1 -0
- package/dist/typescript/module/features/voice/recording/hooks/useTranscriptPanelAnimation.d.ts +18 -0
- package/dist/typescript/module/features/voice/recording/hooks/useTranscriptPanelAnimation.d.ts.map +1 -0
- package/dist/typescript/module/features/voice/recording/hooks/useVoiceRecorder.d.ts.map +1 -1
- package/dist/typescript/module/features/voice/recording/hooks/useVoiceWaveform.d.ts +7 -0
- package/dist/typescript/module/features/voice/recording/hooks/useVoiceWaveform.d.ts.map +1 -0
- package/dist/typescript/module/features/voice/recording/model/Recorder.model.d.ts +2 -0
- package/dist/typescript/module/features/voice/recording/model/Recorder.model.d.ts.map +1 -1
- package/dist/typescript/module/features/voice/recording/model/VoiceRecordWithTranscript.model.d.ts +16 -0
- package/dist/typescript/module/features/voice/recording/model/VoiceRecordWithTranscript.model.d.ts.map +1 -0
- package/dist/typescript/module/features/voice/recording/model/VoiceTranscriptPanel.model.d.ts +31 -0
- package/dist/typescript/module/features/voice/recording/model/VoiceTranscriptPanel.model.d.ts.map +1 -0
- package/dist/typescript/module/features/voice/transcript.helpers.d.ts +8 -0
- package/dist/typescript/module/features/voice/transcript.helpers.d.ts.map +1 -0
- package/dist/typescript/module/features/voice/types.d.ts +2 -5
- package/dist/typescript/module/features/voice/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/features/voice/__tests__/VoiceRecordWithTranscript.model.test.ts +228 -0
- package/src/features/voice/__tests__/VoiceTranscriptPanel.model.test.ts +237 -0
- package/src/features/voice/constants.ts +43 -0
- package/src/features/voice/helpers.ts +85 -1
- package/src/features/voice/index.ts +7 -0
- package/src/features/voice/playing/components/VoiceTranscriptContent.tsx +27 -8
- package/src/features/voice/playing/model/TranscriptionsDownloader.model.ts +8 -30
- package/src/features/voice/recording/components/VoiceRecordWithTranscript.tsx +52 -0
- package/src/features/voice/recording/components/VoiceTranscriptPanel.tsx +293 -0
- package/src/features/voice/recording/components/VoiceWaveform.tsx +102 -0
- package/src/features/voice/recording/hooks/useTranscriptPanelAnimation.ts +49 -0
- package/src/features/voice/recording/hooks/useVoiceRecorder.ts +26 -3
- package/src/features/voice/recording/hooks/useVoiceWaveform.ts +46 -0
- package/src/features/voice/recording/model/Recorder.model.ts +3 -0
- package/src/features/voice/recording/model/VoiceRecordWithTranscript.model.ts +104 -0
- package/src/features/voice/recording/model/VoiceTranscriptPanel.model.ts +131 -0
- package/src/features/voice/transcript.helpers.ts +37 -0
- package/src/features/voice/types.ts +5 -6
- 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 {
|
|
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 = ({
|
|
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.
|
|
31
|
+
<Loader size={LoaderSize.SMALL} color={LoaderColor.GRAY} />
|
|
22
32
|
</View>
|
|
23
33
|
)
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
return (
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
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.
|
|
36
|
-
|
|
37
|
-
|
|
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',
|