@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
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import { createEffect } from 'effector'
|
|
2
2
|
import { TranscriptionsCollection } from './TranscriptionsCollection'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
NO_AUDIO_BE_MESSAGE,
|
|
6
|
-
TRANSCRIPT_MAX_RETRIES,
|
|
7
|
-
TRANSCRIPT_RETRY_INTERVAL_MS,
|
|
8
|
-
} from '../../constants'
|
|
9
|
-
|
|
10
|
-
type TranscriptResult = { text: string; language?: string }
|
|
3
|
+
import { VoicePlayerApi } from '../../types'
|
|
4
|
+
import { fetchTranscriptWithRetry, TranscriptResult } from '../../transcript.helpers'
|
|
11
5
|
|
|
12
6
|
export class TranscriptionsDownloaderModel {
|
|
13
7
|
private readonly collection: TranscriptionsCollection
|
|
@@ -20,14 +14,17 @@ export class TranscriptionsDownloaderModel {
|
|
|
20
14
|
this.api = api
|
|
21
15
|
}
|
|
22
16
|
|
|
23
|
-
private async
|
|
17
|
+
private async fetchTranscript(audioFileId: string): Promise<TranscriptResult> {
|
|
24
18
|
const cached = this.transcriptCache.get(audioFileId)
|
|
25
19
|
if (cached) return cached
|
|
26
20
|
|
|
27
21
|
const existing = this.inflightFetches.get(audioFileId)
|
|
28
22
|
if (existing) return existing
|
|
29
23
|
|
|
30
|
-
const promise =
|
|
24
|
+
const promise = fetchTranscriptWithRetry(
|
|
25
|
+
(id) => this.api.getAudioFileTranscript(id),
|
|
26
|
+
audioFileId,
|
|
27
|
+
)
|
|
31
28
|
.then((result) => {
|
|
32
29
|
this.transcriptCache.set(audioFileId, result)
|
|
33
30
|
return result
|
|
@@ -39,25 +36,6 @@ export class TranscriptionsDownloaderModel {
|
|
|
39
36
|
return promise
|
|
40
37
|
}
|
|
41
38
|
|
|
42
|
-
private async runFetchWithRetry(audioFileId: string): Promise<TranscriptResult> {
|
|
43
|
-
for (let attempt = 0; attempt < TRANSCRIPT_MAX_RETRIES; attempt++) {
|
|
44
|
-
const response = await this.api.getAudioFileTranscript(audioFileId)
|
|
45
|
-
|
|
46
|
-
if (response.status === TranscriptionStatus.COMPLETED) {
|
|
47
|
-
return { text: response.text, language: response.language }
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (response.status === TranscriptionStatus.FAILED) {
|
|
51
|
-
return { text: NO_AUDIO_BE_MESSAGE }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (attempt < TRANSCRIPT_MAX_RETRIES - 1) {
|
|
55
|
-
await new Promise((resolve) => setTimeout(resolve, TRANSCRIPT_RETRY_INTERVAL_MS))
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return { text: NO_AUDIO_BE_MESSAGE }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
39
|
public readonly loadTranscriptForAttempt = createEffect(async (attemptNumber: number) => {
|
|
62
40
|
const item = this.collection.get(attemptNumber)
|
|
63
41
|
if (!item?.audioFileId) return
|
|
@@ -66,7 +44,7 @@ export class TranscriptionsDownloaderModel {
|
|
|
66
44
|
this.collection.update(attemptNumber, { transcriptLoading: true })
|
|
67
45
|
|
|
68
46
|
try {
|
|
69
|
-
const { text, language } = await this.
|
|
47
|
+
const { text, language } = await this.fetchTranscript(item.audioFileId)
|
|
70
48
|
|
|
71
49
|
this.collection.update(attemptNumber, {
|
|
72
50
|
transcript: text,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'
|
|
3
|
+
|
|
4
|
+
import { SPACING } from '@magmamath/react-native-ui'
|
|
5
|
+
import { useUnit } from 'effector-react'
|
|
6
|
+
|
|
7
|
+
import { RecordButtonVariant } from '../../types'
|
|
8
|
+
import { VoiceRecordWithTranscriptModel } from '../model/VoiceRecordWithTranscript.model'
|
|
9
|
+
import { VoiceRecord } from './VoiceRecord'
|
|
10
|
+
import { VoiceTranscriptPanel } from './VoiceTranscriptPanel'
|
|
11
|
+
|
|
12
|
+
type VoiceRecordWithTranscriptProps = {
|
|
13
|
+
model: VoiceRecordWithTranscriptModel
|
|
14
|
+
buttonVariant: RecordButtonVariant
|
|
15
|
+
style?: StyleProp<ViewStyle>
|
|
16
|
+
panelStyle?: StyleProp<ViewStyle>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Drop-in replacement for VoiceRecord that opens the waveform/transcript panel
|
|
20
|
+
// to the right of the record control (per the "Voice on Drawboard" design).
|
|
21
|
+
export const VoiceRecordWithTranscript = ({
|
|
22
|
+
model,
|
|
23
|
+
buttonVariant,
|
|
24
|
+
style,
|
|
25
|
+
panelStyle,
|
|
26
|
+
}: VoiceRecordWithTranscriptProps) => {
|
|
27
|
+
const metering = useUnit(model.record.recorderModel.$metering)
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<View style={[styles.container, style]}>
|
|
31
|
+
<VoiceRecord model={model.record} buttonVariant={buttonVariant} />
|
|
32
|
+
<VoiceTranscriptPanel
|
|
33
|
+
model={model.panel}
|
|
34
|
+
metering={metering}
|
|
35
|
+
style={[styles.panel, panelStyle]}
|
|
36
|
+
/>
|
|
37
|
+
</View>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const styles = StyleSheet.create({
|
|
42
|
+
// Hug the record control so the panel's `left: 100%` lands right next to it
|
|
43
|
+
container: {
|
|
44
|
+
alignSelf: 'flex-start',
|
|
45
|
+
},
|
|
46
|
+
panel: {
|
|
47
|
+
position: 'absolute',
|
|
48
|
+
left: '100%',
|
|
49
|
+
top: 0,
|
|
50
|
+
marginLeft: SPACING[200],
|
|
51
|
+
},
|
|
52
|
+
})
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
LayoutChangeEvent,
|
|
4
|
+
StyleProp,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
TouchableOpacity,
|
|
7
|
+
View,
|
|
8
|
+
ViewStyle,
|
|
9
|
+
} from 'react-native'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
BORDER_RADIUS,
|
|
13
|
+
CaretDownIcon,
|
|
14
|
+
COLORS,
|
|
15
|
+
Loader,
|
|
16
|
+
LoaderColor,
|
|
17
|
+
LoaderSize,
|
|
18
|
+
ScrollableListScrollView,
|
|
19
|
+
SHADOWS,
|
|
20
|
+
SPACING,
|
|
21
|
+
Typography,
|
|
22
|
+
} from '@magmamath/react-native-ui'
|
|
23
|
+
import { useUnit } from 'effector-react'
|
|
24
|
+
import Animated, {
|
|
25
|
+
Easing,
|
|
26
|
+
FadeIn,
|
|
27
|
+
FadeOut,
|
|
28
|
+
useAnimatedStyle,
|
|
29
|
+
useSharedValue,
|
|
30
|
+
withRepeat,
|
|
31
|
+
withTiming,
|
|
32
|
+
} from 'react-native-reanimated'
|
|
33
|
+
|
|
34
|
+
import { useText } from '../../../../i18n/i18n'
|
|
35
|
+
import { VOICE_TRANSCRIPT_PANEL } from '../../constants'
|
|
36
|
+
import { VoiceTranscriptContent } from '../../playing/components/VoiceTranscriptContent'
|
|
37
|
+
import { useTranscriptPanelAnimation } from '../hooks/useTranscriptPanelAnimation'
|
|
38
|
+
import { useVoiceWaveform } from '../hooks/useVoiceWaveform'
|
|
39
|
+
import { VoiceTranscriptPanelModel } from '../model/VoiceTranscriptPanel.model'
|
|
40
|
+
import { VoiceWaveform } from './VoiceWaveform'
|
|
41
|
+
|
|
42
|
+
const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity)
|
|
43
|
+
|
|
44
|
+
const RECORDING_DOT_SIZE = 8
|
|
45
|
+
const BLINK_DURATION_MS = 700
|
|
46
|
+
const RECORDING_DOT_MIN_OPACITY = 0.2
|
|
47
|
+
const CARET_SIZE = 22
|
|
48
|
+
const DIMMED_OPACITY = VOICE_TRANSCRIPT_PANEL.DIMMED_OPACITY
|
|
49
|
+
|
|
50
|
+
const PREVIEW_LINE_COUNT = 3
|
|
51
|
+
// Cosmetic only (fade overlay height) — roughly the Typography h8 line height
|
|
52
|
+
const PREVIEW_LINE_HEIGHT = 19.6
|
|
53
|
+
const PREVIEW_FADE_WIDTH = 72
|
|
54
|
+
const PREVIEW_FADE_STRIPES_COUNT = 12
|
|
55
|
+
// Rounding slack when comparing the two measured text heights
|
|
56
|
+
const PREVIEW_HEIGHT_TOLERANCE = 2
|
|
57
|
+
|
|
58
|
+
const WAVEFORM_WIDTH =
|
|
59
|
+
VOICE_TRANSCRIPT_PANEL.WIDTH - SPACING[200] * 2 - RECORDING_DOT_SIZE - SPACING[200]
|
|
60
|
+
|
|
61
|
+
const RecordingDot = () => {
|
|
62
|
+
const opacity = useSharedValue(1)
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
opacity.value = withRepeat(
|
|
66
|
+
withTiming(RECORDING_DOT_MIN_OPACITY, {
|
|
67
|
+
duration: BLINK_DURATION_MS,
|
|
68
|
+
easing: Easing.inOut(Easing.ease),
|
|
69
|
+
}),
|
|
70
|
+
-1,
|
|
71
|
+
true,
|
|
72
|
+
)
|
|
73
|
+
}, [opacity])
|
|
74
|
+
|
|
75
|
+
// Explicit deps: consumers compile this library without the Reanimated Babel plugin
|
|
76
|
+
const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }), [opacity])
|
|
77
|
+
|
|
78
|
+
return <Animated.View style={[styles.recordingDot, animatedStyle]} />
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Stripes of the panel background with rising opacity imitate the text
|
|
82
|
+
// dissolving at the end of the last preview line (no gradient dependency)
|
|
83
|
+
const PreviewLineFade = () => (
|
|
84
|
+
<View style={styles.previewFade} pointerEvents="none">
|
|
85
|
+
{Array.from({ length: PREVIEW_FADE_STRIPES_COUNT }, (_, index) => (
|
|
86
|
+
<View
|
|
87
|
+
key={index}
|
|
88
|
+
style={[styles.previewFadeStripe, { opacity: (index + 1) / PREVIEW_FADE_STRIPES_COUNT }]}
|
|
89
|
+
/>
|
|
90
|
+
))}
|
|
91
|
+
</View>
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
type VoiceTranscriptPanelProps = {
|
|
95
|
+
model: VoiceTranscriptPanelModel
|
|
96
|
+
metering: number | null
|
|
97
|
+
style?: StyleProp<ViewStyle>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const VoiceTranscriptPanel = ({ model, metering, style }: VoiceTranscriptPanelProps) => {
|
|
101
|
+
const t = useText()
|
|
102
|
+
const { flags, transcript, hasError, isExpanded, isDimmed } = useUnit({
|
|
103
|
+
flags: model.$flags,
|
|
104
|
+
transcript: model.$transcript,
|
|
105
|
+
hasError: model.$hasError,
|
|
106
|
+
isExpanded: model.dropdown.$isExpanded,
|
|
107
|
+
isDimmed: model.$isDimmed,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const [fullTextHeight, setFullTextHeight] = useState(0)
|
|
111
|
+
const [previewHeight, setPreviewHeight] = useState(0)
|
|
112
|
+
|
|
113
|
+
const levels = useVoiceWaveform({ metering, isActive: flags.isRecording })
|
|
114
|
+
const panelAnimation = useTranscriptPanelAnimation(isExpanded)
|
|
115
|
+
|
|
116
|
+
if (!flags.isOpen) return null
|
|
117
|
+
|
|
118
|
+
const transcriptText = hasError ? t('voice.transcriptNotAvailable') : transcript
|
|
119
|
+
|
|
120
|
+
// Both heights are measured, so the comparison stays correct regardless of
|
|
121
|
+
// the text's font metrics or paddings
|
|
122
|
+
const isTruncated =
|
|
123
|
+
fullTextHeight > 0 &&
|
|
124
|
+
previewHeight > 0 &&
|
|
125
|
+
fullTextHeight > previewHeight + PREVIEW_HEIGHT_TOLERANCE
|
|
126
|
+
|
|
127
|
+
const measureFullText = (event: LayoutChangeEvent) => {
|
|
128
|
+
const { height } = event.nativeEvent.layout
|
|
129
|
+
if (height > 0) setFullTextHeight(height)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const measurePreview = (event: LayoutChangeEvent) => {
|
|
133
|
+
const { height } = event.nativeEvent.layout
|
|
134
|
+
// Expanded shows the full text — only the collapsed (3-line) layout counts
|
|
135
|
+
if (height > 0 && !isExpanded) setPreviewHeight(height)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<Animated.View
|
|
140
|
+
entering={FadeIn}
|
|
141
|
+
exiting={FadeOut}
|
|
142
|
+
style={[styles.container, panelAnimation.containerAnimatedStyle, style]}
|
|
143
|
+
>
|
|
144
|
+
{flags.isRecording && (
|
|
145
|
+
<View style={styles.recordingRow}>
|
|
146
|
+
<RecordingDot />
|
|
147
|
+
<VoiceWaveform levels={levels} width={WAVEFORM_WIDTH} />
|
|
148
|
+
</View>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{flags.isProcessing && (
|
|
152
|
+
<View style={styles.processing}>
|
|
153
|
+
<Loader size={LoaderSize.SMALL} color={LoaderColor.BLUE} />
|
|
154
|
+
<Typography variant="h8" style={styles.processingText}>
|
|
155
|
+
{`${t('voice.preparingTranscript')}...`}
|
|
156
|
+
</Typography>
|
|
157
|
+
</View>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
{flags.isDone && (
|
|
161
|
+
<View>
|
|
162
|
+
{/* Invisible twin at the preview width measures the untruncated
|
|
163
|
+
text height — onTextLayout is not implemented on web */}
|
|
164
|
+
<View
|
|
165
|
+
style={[styles.contentCollapsed, styles.fullTextMeasure]}
|
|
166
|
+
pointerEvents="none"
|
|
167
|
+
onLayout={measureFullText}
|
|
168
|
+
>
|
|
169
|
+
<VoiceTranscriptContent text={transcriptText} isLoading={false} />
|
|
170
|
+
</View>
|
|
171
|
+
<Typography variant="h5" style={[styles.title, isDimmed && styles.dimmed]}>
|
|
172
|
+
{t('voice.transcriptions')}
|
|
173
|
+
</Typography>
|
|
174
|
+
{/* The text stays mounted across toggles and snaps to its final
|
|
175
|
+
fixed width; the panel frame catches up with explicit
|
|
176
|
+
width/height animations, clipping via overflow: hidden. */}
|
|
177
|
+
<Animated.View style={[styles.transcriptBody, panelAnimation.bodyAnimatedStyle]}>
|
|
178
|
+
<View
|
|
179
|
+
style={[
|
|
180
|
+
styles.transcriptBodyInner,
|
|
181
|
+
isExpanded ? styles.contentExpanded : styles.contentCollapsed,
|
|
182
|
+
]}
|
|
183
|
+
onLayout={panelAnimation.onContentLayout}
|
|
184
|
+
>
|
|
185
|
+
<ScrollableListScrollView
|
|
186
|
+
style={styles.transcriptScroll}
|
|
187
|
+
scrollbarWidth={VOICE_TRANSCRIPT_PANEL.SCROLLBAR_WIDTH}
|
|
188
|
+
scrollEnabled={isExpanded}
|
|
189
|
+
hideShadow
|
|
190
|
+
>
|
|
191
|
+
<View onLayout={measurePreview}>
|
|
192
|
+
<VoiceTranscriptContent
|
|
193
|
+
text={transcriptText}
|
|
194
|
+
isLoading={false}
|
|
195
|
+
numberOfLines={isExpanded ? undefined : PREVIEW_LINE_COUNT}
|
|
196
|
+
dimmed={isDimmed}
|
|
197
|
+
/>
|
|
198
|
+
</View>
|
|
199
|
+
</ScrollableListScrollView>
|
|
200
|
+
{!isExpanded && isTruncated && <PreviewLineFade />}
|
|
201
|
+
</View>
|
|
202
|
+
</Animated.View>
|
|
203
|
+
{isTruncated && (
|
|
204
|
+
<AnimatedTouchableOpacity
|
|
205
|
+
style={[styles.expandButton, panelAnimation.caretAnimatedStyle]}
|
|
206
|
+
onPress={() => model.dropdown.toggleExpand()}
|
|
207
|
+
>
|
|
208
|
+
<CaretDownIcon size={CARET_SIZE} />
|
|
209
|
+
</AnimatedTouchableOpacity>
|
|
210
|
+
)}
|
|
211
|
+
</View>
|
|
212
|
+
)}
|
|
213
|
+
</Animated.View>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const styles = StyleSheet.create({
|
|
218
|
+
container: {
|
|
219
|
+
padding: SPACING[200],
|
|
220
|
+
borderRadius: BORDER_RADIUS[300],
|
|
221
|
+
backgroundColor: COLORS.NEUTRAL_1,
|
|
222
|
+
overflow: 'hidden',
|
|
223
|
+
...SHADOWS['4'],
|
|
224
|
+
},
|
|
225
|
+
recordingRow: {
|
|
226
|
+
flexDirection: 'row',
|
|
227
|
+
alignItems: 'center',
|
|
228
|
+
gap: SPACING[200],
|
|
229
|
+
},
|
|
230
|
+
recordingDot: {
|
|
231
|
+
width: RECORDING_DOT_SIZE,
|
|
232
|
+
height: RECORDING_DOT_SIZE,
|
|
233
|
+
borderRadius: RECORDING_DOT_SIZE / 2,
|
|
234
|
+
backgroundColor: COLORS.PRIMARY_RED,
|
|
235
|
+
},
|
|
236
|
+
title: {
|
|
237
|
+
color: COLORS.NEUTRAL_10,
|
|
238
|
+
marginLeft: SPACING[100],
|
|
239
|
+
},
|
|
240
|
+
dimmed: {
|
|
241
|
+
opacity: DIMMED_OPACITY,
|
|
242
|
+
},
|
|
243
|
+
transcriptBody: {
|
|
244
|
+
overflow: 'hidden',
|
|
245
|
+
},
|
|
246
|
+
// Absolute so its measured height stays the content's natural height,
|
|
247
|
+
// independent of the animated wrapper height around it
|
|
248
|
+
transcriptBodyInner: {
|
|
249
|
+
position: 'absolute',
|
|
250
|
+
top: 0,
|
|
251
|
+
left: 0,
|
|
252
|
+
},
|
|
253
|
+
// Fixed per-state widths: the text snaps to its final layout once while the
|
|
254
|
+
// panel frame animates around it
|
|
255
|
+
contentCollapsed: {
|
|
256
|
+
width: VOICE_TRANSCRIPT_PANEL.WIDTH - SPACING[200] * 2,
|
|
257
|
+
},
|
|
258
|
+
fullTextMeasure: {
|
|
259
|
+
position: 'absolute',
|
|
260
|
+
opacity: 0,
|
|
261
|
+
},
|
|
262
|
+
contentExpanded: {
|
|
263
|
+
width: VOICE_TRANSCRIPT_PANEL.WIDTH_DONE - SPACING[200] * 2,
|
|
264
|
+
},
|
|
265
|
+
transcriptScroll: {
|
|
266
|
+
maxHeight: VOICE_TRANSCRIPT_PANEL.TRANSCRIPT_MAX_HEIGHT,
|
|
267
|
+
},
|
|
268
|
+
previewFade: {
|
|
269
|
+
position: 'absolute',
|
|
270
|
+
right: SPACING[200],
|
|
271
|
+
bottom: SPACING[100],
|
|
272
|
+
width: PREVIEW_FADE_WIDTH,
|
|
273
|
+
height: PREVIEW_LINE_HEIGHT,
|
|
274
|
+
flexDirection: 'row',
|
|
275
|
+
},
|
|
276
|
+
previewFadeStripe: {
|
|
277
|
+
flex: 1,
|
|
278
|
+
backgroundColor: COLORS.NEUTRAL_1,
|
|
279
|
+
},
|
|
280
|
+
expandButton: {
|
|
281
|
+
justifyContent: 'center',
|
|
282
|
+
alignItems: 'center',
|
|
283
|
+
},
|
|
284
|
+
processing: {
|
|
285
|
+
alignItems: 'center',
|
|
286
|
+
justifyContent: 'center',
|
|
287
|
+
gap: SPACING[200],
|
|
288
|
+
paddingVertical: SPACING[200],
|
|
289
|
+
},
|
|
290
|
+
processingText: {
|
|
291
|
+
color: COLORS.NEUTRAL_7,
|
|
292
|
+
},
|
|
293
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React, { useEffect } from 'react'
|
|
2
|
+
import { StyleSheet, View } from 'react-native'
|
|
3
|
+
|
|
4
|
+
import { COLORS } from '@magmamath/react-native-ui'
|
|
5
|
+
import Animated, {
|
|
6
|
+
Easing,
|
|
7
|
+
useAnimatedStyle,
|
|
8
|
+
useSharedValue,
|
|
9
|
+
withTiming,
|
|
10
|
+
} from 'react-native-reanimated'
|
|
11
|
+
|
|
12
|
+
import { VOICE_WAVEFORM } from '../../constants'
|
|
13
|
+
|
|
14
|
+
type VoiceWaveformProps = {
|
|
15
|
+
levels: number[]
|
|
16
|
+
width: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const BAR_STEP = VOICE_WAVEFORM.BAR_WIDTH + VOICE_WAVEFORM.BAR_GAP
|
|
20
|
+
// One extra bar is rendered off-screen on the right and slides in as the row
|
|
21
|
+
// shifts — that is what makes the scroll continuous instead of stepping
|
|
22
|
+
const OFFSCREEN_BARS = 1
|
|
23
|
+
|
|
24
|
+
const getVisibleBarsCount = (width: number) => {
|
|
25
|
+
const barsForWidth = Math.floor(width / BAR_STEP)
|
|
26
|
+
return Math.max(VOICE_WAVEFORM.MIN_BARS, barsForWidth)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const getBarHeight = (level: number) =>
|
|
30
|
+
Math.max(
|
|
31
|
+
VOICE_WAVEFORM.MIN_BAR_HEIGHT,
|
|
32
|
+
Math.round(level * (VOICE_WAVEFORM.HEIGHT - VOICE_WAVEFORM.AMPLITUDE_PADDING)),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
// Older bars (toward the left) fade out, mirroring the dotted lead-in in the
|
|
36
|
+
// design. Opacity ramps from 0 up to full across the first FADE_RATIO of bars.
|
|
37
|
+
const getBarOpacity = (index: number, count: number) => {
|
|
38
|
+
const fadeBars = count * VOICE_WAVEFORM.FADE_RATIO
|
|
39
|
+
return Math.min(1, index / fadeBars)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const VoiceWaveform = ({ levels, width }: VoiceWaveformProps) => {
|
|
43
|
+
const count = getVisibleBarsCount(width)
|
|
44
|
+
const visibleLevels = levels.slice(-(count + OFFSCREEN_BARS))
|
|
45
|
+
|
|
46
|
+
// Conveyor-belt motion: each new sample shifts the buffer one slot left and
|
|
47
|
+
// snaps the row back, then the row glides left by one step until the next
|
|
48
|
+
// sample lands — the two cancel out into continuous movement.
|
|
49
|
+
const shift = useSharedValue(0)
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
shift.value = 0
|
|
53
|
+
shift.value = withTiming(-BAR_STEP, {
|
|
54
|
+
duration: VOICE_WAVEFORM.SAMPLE_INTERVAL_MS,
|
|
55
|
+
easing: Easing.linear,
|
|
56
|
+
})
|
|
57
|
+
}, [levels, shift])
|
|
58
|
+
|
|
59
|
+
// Explicit deps: consumers compile this library without the Reanimated Babel plugin
|
|
60
|
+
const rowAnimatedStyle = useAnimatedStyle(
|
|
61
|
+
() => ({ transform: [{ translateX: shift.value }] }),
|
|
62
|
+
[shift],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<View style={[styles.container, { width }]}>
|
|
67
|
+
<Animated.View style={[styles.row, rowAnimatedStyle]}>
|
|
68
|
+
{visibleLevels.map((level, index) => (
|
|
69
|
+
<View
|
|
70
|
+
key={index}
|
|
71
|
+
style={[
|
|
72
|
+
styles.bar,
|
|
73
|
+
{ height: getBarHeight(level), opacity: getBarOpacity(index, visibleLevels.length) },
|
|
74
|
+
]}
|
|
75
|
+
/>
|
|
76
|
+
))}
|
|
77
|
+
</Animated.View>
|
|
78
|
+
</View>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const styles = StyleSheet.create({
|
|
83
|
+
container: {
|
|
84
|
+
height: VOICE_WAVEFORM.HEIGHT,
|
|
85
|
+
overflow: 'hidden',
|
|
86
|
+
},
|
|
87
|
+
// Hangs one step past the right edge so the newest bar starts hidden
|
|
88
|
+
row: {
|
|
89
|
+
position: 'absolute',
|
|
90
|
+
top: 0,
|
|
91
|
+
bottom: 0,
|
|
92
|
+
right: -BAR_STEP,
|
|
93
|
+
flexDirection: 'row',
|
|
94
|
+
alignItems: 'center',
|
|
95
|
+
gap: VOICE_WAVEFORM.BAR_GAP,
|
|
96
|
+
},
|
|
97
|
+
bar: {
|
|
98
|
+
width: VOICE_WAVEFORM.BAR_WIDTH,
|
|
99
|
+
borderRadius: VOICE_WAVEFORM.BAR_WIDTH / 2,
|
|
100
|
+
backgroundColor: COLORS.NEUTRAL_7,
|
|
101
|
+
},
|
|
102
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { LayoutChangeEvent } from 'react-native'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useAnimatedStyle,
|
|
5
|
+
useSharedValue,
|
|
6
|
+
withTiming,
|
|
7
|
+
} from 'react-native-reanimated'
|
|
8
|
+
|
|
9
|
+
import { VOICE_TRANSCRIPT_PANEL } from '../../constants'
|
|
10
|
+
|
|
11
|
+
const TIMING = { duration: 170 } as const
|
|
12
|
+
|
|
13
|
+
// Width and height are animated as explicit numbers instead of reanimated
|
|
14
|
+
// layout transitions: on web those run as FLIP animations that scale the
|
|
15
|
+
// subtree, visibly stretching the text while the panel resizes.
|
|
16
|
+
export const useTranscriptPanelAnimation = (isExpanded: boolean) => {
|
|
17
|
+
const contentHeight = useSharedValue(0)
|
|
18
|
+
|
|
19
|
+
const onContentLayout = (event: LayoutChangeEvent) => {
|
|
20
|
+
const height = event.nativeEvent.layout.height
|
|
21
|
+
if (height <= 0) return
|
|
22
|
+
contentHeight.value =
|
|
23
|
+
contentHeight.value === 0 ? height : withTiming(height, TIMING)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const containerAnimatedStyle = useAnimatedStyle(
|
|
27
|
+
() => ({
|
|
28
|
+
width: withTiming(
|
|
29
|
+
isExpanded ? VOICE_TRANSCRIPT_PANEL.WIDTH_DONE : VOICE_TRANSCRIPT_PANEL.WIDTH,
|
|
30
|
+
TIMING,
|
|
31
|
+
),
|
|
32
|
+
}),
|
|
33
|
+
[isExpanded],
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const bodyAnimatedStyle = useAnimatedStyle(
|
|
37
|
+
() => (contentHeight.value === 0 ? {} : { height: contentHeight.value }),
|
|
38
|
+
[contentHeight],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const caretAnimatedStyle = useAnimatedStyle(
|
|
42
|
+
() => ({
|
|
43
|
+
transform: [{ rotate: withTiming(isExpanded ? '180deg' : '0deg', TIMING) }],
|
|
44
|
+
}),
|
|
45
|
+
[isExpanded],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return { onContentLayout, containerAnimatedStyle, bodyAnimatedStyle, caretAnimatedStyle }
|
|
49
|
+
}
|
|
@@ -3,11 +3,34 @@ import { VoiceRecordModel } from '../model/VoiceRecord.model'
|
|
|
3
3
|
import { useEffect } from 'react'
|
|
4
4
|
import { IS_WEB } from '@magmamath/react-native-ui'
|
|
5
5
|
import { VOICE_RECORDER_MAX_DURATION_MS, VoiceRecorderState } from '../../constants'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
addAudioInputsListenerWeb,
|
|
8
|
+
createWebMeteringMonitor,
|
|
9
|
+
getAvailableInputs,
|
|
10
|
+
} from '../../helpers'
|
|
11
|
+
import { METERING_UPDATE_INTERVAL_MS } from '../../constants'
|
|
12
|
+
|
|
13
|
+
// Metering powers the live recording waveform (recorderState.metering).
|
|
14
|
+
// Only native respects it — on web the level is measured by
|
|
15
|
+
// createWebMeteringMonitor instead.
|
|
16
|
+
const RECORDING_OPTIONS = {
|
|
17
|
+
...RecordingPresets.LOW_QUALITY,
|
|
18
|
+
isMeteringEnabled: true,
|
|
19
|
+
}
|
|
7
20
|
|
|
8
21
|
export const useVoiceRecorder = (model: VoiceRecordModel) => {
|
|
9
|
-
const recorder = useAudioRecorder(
|
|
10
|
-
const recorderState = useAudioRecorderState(recorder)
|
|
22
|
+
const recorder = useAudioRecorder(RECORDING_OPTIONS, model.recordingStatusUpdate)
|
|
23
|
+
const recorderState = useAudioRecorderState(recorder, METERING_UPDATE_INTERVAL_MS)
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (IS_WEB) return
|
|
27
|
+
model.recorderModel.setMetering(recorderState.metering ?? null)
|
|
28
|
+
}, [recorderState.metering, model.recorderModel])
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!IS_WEB || !recorderState.isRecording) return
|
|
32
|
+
return createWebMeteringMonitor(recorder, model.recorderModel.setMetering)
|
|
33
|
+
}, [recorderState.isRecording, recorder, model.recorderModel])
|
|
11
34
|
|
|
12
35
|
useEffect(() => {
|
|
13
36
|
if (
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { VOICE_WAVEFORM } from '../../constants'
|
|
4
|
+
import { normalizeMetering } from '../../helpers'
|
|
5
|
+
|
|
6
|
+
type UseVoiceWaveformParams = {
|
|
7
|
+
metering: number | null
|
|
8
|
+
isActive: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const createSilentBuffer = () =>
|
|
12
|
+
new Array<number>(VOICE_WAVEFORM.BUFFER_SIZE).fill(VOICE_WAVEFORM.MIN_LEVEL)
|
|
13
|
+
|
|
14
|
+
// Keeps a rolling buffer of normalized levels that scrolls right-to-left while
|
|
15
|
+
// recording. We sample on a fixed interval (rather than on every metering
|
|
16
|
+
// update) so the scroll speed stays constant regardless of the recorder's
|
|
17
|
+
// status cadence. The latest metering value is read from a ref to avoid
|
|
18
|
+
// resubscribing the interval on each change. Levels are low-pass filtered so
|
|
19
|
+
// neighboring bars flow into each other instead of jumping.
|
|
20
|
+
export const useVoiceWaveform = ({ metering, isActive }: UseVoiceWaveformParams) => {
|
|
21
|
+
const meteringRef = useRef(metering)
|
|
22
|
+
meteringRef.current = metering
|
|
23
|
+
|
|
24
|
+
const smoothedLevelRef = useRef(VOICE_WAVEFORM.MIN_LEVEL)
|
|
25
|
+
const [levels, setLevels] = useState<number[]>(createSilentBuffer)
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!isActive) {
|
|
29
|
+
smoothedLevelRef.current = VOICE_WAVEFORM.MIN_LEVEL
|
|
30
|
+
setLevels(createSilentBuffer())
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const intervalId = setInterval(() => {
|
|
35
|
+
const target = normalizeMetering(meteringRef.current)
|
|
36
|
+
smoothedLevelRef.current +=
|
|
37
|
+
(target - smoothedLevelRef.current) * VOICE_WAVEFORM.LEVEL_SMOOTHING
|
|
38
|
+
const level = Math.max(VOICE_WAVEFORM.MIN_LEVEL, smoothedLevelRef.current)
|
|
39
|
+
setLevels((prev) => [...prev.slice(1), level])
|
|
40
|
+
}, VOICE_WAVEFORM.SAMPLE_INTERVAL_MS)
|
|
41
|
+
|
|
42
|
+
return () => clearInterval(intervalId)
|
|
43
|
+
}, [isActive])
|
|
44
|
+
|
|
45
|
+
return levels
|
|
46
|
+
}
|
|
@@ -18,6 +18,7 @@ export class RecorderModel<T = unknown> {
|
|
|
18
18
|
public readonly setVoiceRecordState = createEvent<VoiceRecorderState>()
|
|
19
19
|
public readonly setAvailableInputs = createEvent<AvailableAudioInputs>()
|
|
20
20
|
public readonly setLastKnownDurationMs = createEvent<number>()
|
|
21
|
+
public readonly setMetering = createEvent<number | null>()
|
|
21
22
|
|
|
22
23
|
public readonly $voiceRecordState = restore(
|
|
23
24
|
this.setVoiceRecordState,
|
|
@@ -25,6 +26,8 @@ export class RecorderModel<T = unknown> {
|
|
|
25
26
|
).reset(this.reset)
|
|
26
27
|
public readonly $availableInputs = restore(this.setAvailableInputs, [])
|
|
27
28
|
public readonly $lastKnownDurationMs = restore(this.setLastKnownDurationMs, 0).reset(this.reset)
|
|
29
|
+
// Live input level in dBFS while recording (powers the waveform)
|
|
30
|
+
public readonly $metering = restore(this.setMetering, null).reset(this.reset)
|
|
28
31
|
|
|
29
32
|
public readonly start = createEffect(async () => {
|
|
30
33
|
try {
|