@magmamath/students-features 1.7.9 → 1.7.10-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commonjs/features/voice/constants.js +39 -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 +17 -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 +245 -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 +83 -0
- package/dist/commonjs/features/voice/recording/model/VoiceRecordWithTranscript.model.js.map +1 -0
- package/dist/commonjs/features/voice/recording/model/VoiceTranscriptPanel.model.js +65 -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 +38 -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 +15 -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 +238 -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 +78 -0
- package/dist/module/features/voice/recording/model/VoiceRecordWithTranscript.model.js.map +1 -0
- package/dist/module/features/voice/recording/model/VoiceTranscriptPanel.model.js +60 -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 +32 -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 +2 -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 +26 -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 +32 -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 +2 -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 +26 -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 +145 -0
- package/src/features/voice/__tests__/VoiceTranscriptPanel.model.test.ts +135 -0
- package/src/features/voice/constants.ts +40 -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 +16 -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 +285 -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 +81 -0
- package/src/features/voice/recording/model/VoiceTranscriptPanel.model.ts +76 -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
|
@@ -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,41 @@ 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'
|
|
11
12
|
|
|
12
13
|
type VoiceTranscriptContentProps = {
|
|
13
14
|
text: string
|
|
14
15
|
isLoading: boolean
|
|
16
|
+
numberOfLines?: number
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
export const VoiceTranscriptContent = ({
|
|
19
|
+
export const VoiceTranscriptContent = ({
|
|
20
|
+
text,
|
|
21
|
+
isLoading,
|
|
22
|
+
numberOfLines,
|
|
23
|
+
}: VoiceTranscriptContentProps) => {
|
|
18
24
|
if (isLoading) {
|
|
19
25
|
return (
|
|
20
26
|
<View style={styles.loaderContainer}>
|
|
21
|
-
<Loader size={LoaderSize.SMALL} color={LoaderColor.
|
|
27
|
+
<Loader size={LoaderSize.SMALL} color={LoaderColor.GRAY} />
|
|
22
28
|
</View>
|
|
23
29
|
)
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
return (
|
|
27
|
-
<
|
|
28
|
-
{text}
|
|
29
|
-
|
|
33
|
+
<Animated.View entering={FadeIn.duration(400)}>
|
|
34
|
+
<Typography variant="h8" style={styles.text} numberOfLines={numberOfLines}>
|
|
35
|
+
{text}
|
|
36
|
+
</Typography>
|
|
37
|
+
</Animated.View>
|
|
30
38
|
)
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
const styles = StyleSheet.create({
|
|
34
42
|
text: {
|
|
35
|
-
color: COLORS.
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
color: COLORS.NEUTRAL_9,
|
|
44
|
+
paddingVertical: SPACING[100],
|
|
45
|
+
marginRight: SPACING[200],
|
|
38
46
|
},
|
|
39
47
|
loaderContainer: {
|
|
40
48
|
justifyContent: 'center',
|
|
@@ -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,285 @@
|
|
|
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
|
+
useAnimatedStyle,
|
|
28
|
+
useSharedValue,
|
|
29
|
+
withRepeat,
|
|
30
|
+
withTiming,
|
|
31
|
+
} from 'react-native-reanimated'
|
|
32
|
+
|
|
33
|
+
import { useText } from '../../../../i18n/i18n'
|
|
34
|
+
import { VOICE_TRANSCRIPT_PANEL } from '../../constants'
|
|
35
|
+
import { VoiceTranscriptContent } from '../../playing/components/VoiceTranscriptContent'
|
|
36
|
+
import { useTranscriptPanelAnimation } from '../hooks/useTranscriptPanelAnimation'
|
|
37
|
+
import { useVoiceWaveform } from '../hooks/useVoiceWaveform'
|
|
38
|
+
import { VoiceTranscriptPanelModel } from '../model/VoiceTranscriptPanel.model'
|
|
39
|
+
import { VoiceWaveform } from './VoiceWaveform'
|
|
40
|
+
|
|
41
|
+
const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity)
|
|
42
|
+
|
|
43
|
+
const RECORDING_DOT_SIZE = 8
|
|
44
|
+
const BLINK_DURATION_MS = 700
|
|
45
|
+
const RECORDING_DOT_MIN_OPACITY = 0.2
|
|
46
|
+
const CARET_SIZE = 22
|
|
47
|
+
|
|
48
|
+
const PREVIEW_LINE_COUNT = 3
|
|
49
|
+
// Cosmetic only (fade overlay height) — roughly the Typography h8 line height
|
|
50
|
+
const PREVIEW_LINE_HEIGHT = 19.6
|
|
51
|
+
const PREVIEW_FADE_WIDTH = 72
|
|
52
|
+
const PREVIEW_FADE_STRIPES_COUNT = 12
|
|
53
|
+
// Rounding slack when comparing the two measured text heights
|
|
54
|
+
const PREVIEW_HEIGHT_TOLERANCE = 2
|
|
55
|
+
|
|
56
|
+
const WAVEFORM_WIDTH =
|
|
57
|
+
VOICE_TRANSCRIPT_PANEL.WIDTH - SPACING[200] * 2 - RECORDING_DOT_SIZE - SPACING[200]
|
|
58
|
+
|
|
59
|
+
const RecordingDot = () => {
|
|
60
|
+
const opacity = useSharedValue(1)
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
opacity.value = withRepeat(
|
|
64
|
+
withTiming(RECORDING_DOT_MIN_OPACITY, {
|
|
65
|
+
duration: BLINK_DURATION_MS,
|
|
66
|
+
easing: Easing.inOut(Easing.ease),
|
|
67
|
+
}),
|
|
68
|
+
-1,
|
|
69
|
+
true,
|
|
70
|
+
)
|
|
71
|
+
}, [opacity])
|
|
72
|
+
|
|
73
|
+
// Explicit deps: consumers compile this library without the Reanimated Babel plugin
|
|
74
|
+
const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }), [opacity])
|
|
75
|
+
|
|
76
|
+
return <Animated.View style={[styles.recordingDot, animatedStyle]} />
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Stripes of the panel background with rising opacity imitate the text
|
|
80
|
+
// dissolving at the end of the last preview line (no gradient dependency)
|
|
81
|
+
const PreviewLineFade = () => (
|
|
82
|
+
<View style={styles.previewFade} pointerEvents="none">
|
|
83
|
+
{Array.from({ length: PREVIEW_FADE_STRIPES_COUNT }, (_, index) => (
|
|
84
|
+
<View
|
|
85
|
+
key={index}
|
|
86
|
+
style={[styles.previewFadeStripe, { opacity: (index + 1) / PREVIEW_FADE_STRIPES_COUNT }]}
|
|
87
|
+
/>
|
|
88
|
+
))}
|
|
89
|
+
</View>
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
type VoiceTranscriptPanelProps = {
|
|
93
|
+
model: VoiceTranscriptPanelModel
|
|
94
|
+
metering: number | null
|
|
95
|
+
style?: StyleProp<ViewStyle>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const VoiceTranscriptPanel = ({ model, metering, style }: VoiceTranscriptPanelProps) => {
|
|
99
|
+
const t = useText()
|
|
100
|
+
const { flags, transcript, hasError, isExpanded } = useUnit({
|
|
101
|
+
flags: model.$flags,
|
|
102
|
+
transcript: model.$transcript,
|
|
103
|
+
hasError: model.$hasError,
|
|
104
|
+
isExpanded: model.dropdown.$isExpanded,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const [fullTextHeight, setFullTextHeight] = useState(0)
|
|
108
|
+
const [previewHeight, setPreviewHeight] = useState(0)
|
|
109
|
+
|
|
110
|
+
const levels = useVoiceWaveform({ metering, isActive: flags.isRecording })
|
|
111
|
+
const panelAnimation = useTranscriptPanelAnimation(isExpanded)
|
|
112
|
+
|
|
113
|
+
if (!flags.isOpen) return null
|
|
114
|
+
|
|
115
|
+
const transcriptText = hasError ? t('voice.transcriptNotAvailable') : transcript
|
|
116
|
+
|
|
117
|
+
// Both heights are measured, so the comparison stays correct regardless of
|
|
118
|
+
// the text's font metrics or paddings
|
|
119
|
+
const isTruncated =
|
|
120
|
+
fullTextHeight > 0 &&
|
|
121
|
+
previewHeight > 0 &&
|
|
122
|
+
fullTextHeight > previewHeight + PREVIEW_HEIGHT_TOLERANCE
|
|
123
|
+
|
|
124
|
+
const measureFullText = (event: LayoutChangeEvent) => {
|
|
125
|
+
const { height } = event.nativeEvent.layout
|
|
126
|
+
if (height > 0) setFullTextHeight(height)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const measurePreview = (event: LayoutChangeEvent) => {
|
|
130
|
+
const { height } = event.nativeEvent.layout
|
|
131
|
+
// Expanded shows the full text — only the collapsed (3-line) layout counts
|
|
132
|
+
if (height > 0 && !isExpanded) setPreviewHeight(height)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<Animated.View
|
|
137
|
+
entering={FadeIn}
|
|
138
|
+
style={[styles.container, panelAnimation.containerAnimatedStyle, style]}
|
|
139
|
+
>
|
|
140
|
+
{flags.isRecording && (
|
|
141
|
+
<View style={styles.recordingRow}>
|
|
142
|
+
<RecordingDot />
|
|
143
|
+
<VoiceWaveform levels={levels} width={WAVEFORM_WIDTH} />
|
|
144
|
+
</View>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{flags.isProcessing && (
|
|
148
|
+
<View style={styles.processing}>
|
|
149
|
+
<Loader size={LoaderSize.SMALL} color={LoaderColor.BLUE} />
|
|
150
|
+
<Typography variant="h8" style={styles.processingText}>
|
|
151
|
+
{`${t('voice.preparingTranscript')}...`}
|
|
152
|
+
</Typography>
|
|
153
|
+
</View>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
{flags.isDone && (
|
|
157
|
+
<View>
|
|
158
|
+
{/* Invisible twin at the preview width measures the untruncated
|
|
159
|
+
text height — onTextLayout is not implemented on web */}
|
|
160
|
+
<View
|
|
161
|
+
style={[styles.contentCollapsed, styles.fullTextMeasure]}
|
|
162
|
+
pointerEvents="none"
|
|
163
|
+
onLayout={measureFullText}
|
|
164
|
+
>
|
|
165
|
+
<VoiceTranscriptContent text={transcriptText} isLoading={false} />
|
|
166
|
+
</View>
|
|
167
|
+
<Typography variant="h5" style={styles.title}>
|
|
168
|
+
{t('voice.transcriptions')}
|
|
169
|
+
</Typography>
|
|
170
|
+
{/* The text stays mounted across toggles and snaps to its final
|
|
171
|
+
fixed width; the panel frame catches up with explicit
|
|
172
|
+
width/height animations, clipping via overflow: hidden. */}
|
|
173
|
+
<Animated.View style={[styles.transcriptBody, panelAnimation.bodyAnimatedStyle]}>
|
|
174
|
+
<View
|
|
175
|
+
style={[
|
|
176
|
+
styles.transcriptBodyInner,
|
|
177
|
+
isExpanded ? styles.contentExpanded : styles.contentCollapsed,
|
|
178
|
+
]}
|
|
179
|
+
onLayout={panelAnimation.onContentLayout}
|
|
180
|
+
>
|
|
181
|
+
<ScrollableListScrollView
|
|
182
|
+
style={styles.transcriptScroll}
|
|
183
|
+
scrollbarWidth={VOICE_TRANSCRIPT_PANEL.SCROLLBAR_WIDTH}
|
|
184
|
+
scrollEnabled={isExpanded}
|
|
185
|
+
hideShadow
|
|
186
|
+
>
|
|
187
|
+
<View onLayout={measurePreview}>
|
|
188
|
+
<VoiceTranscriptContent
|
|
189
|
+
text={transcriptText}
|
|
190
|
+
isLoading={false}
|
|
191
|
+
numberOfLines={isExpanded ? undefined : PREVIEW_LINE_COUNT}
|
|
192
|
+
/>
|
|
193
|
+
</View>
|
|
194
|
+
</ScrollableListScrollView>
|
|
195
|
+
{!isExpanded && isTruncated && <PreviewLineFade />}
|
|
196
|
+
</View>
|
|
197
|
+
</Animated.View>
|
|
198
|
+
{isTruncated && (
|
|
199
|
+
<AnimatedTouchableOpacity
|
|
200
|
+
style={[styles.expandButton, panelAnimation.caretAnimatedStyle]}
|
|
201
|
+
onPress={() => model.dropdown.toggleExpand()}
|
|
202
|
+
>
|
|
203
|
+
<CaretDownIcon size={CARET_SIZE} />
|
|
204
|
+
</AnimatedTouchableOpacity>
|
|
205
|
+
)}
|
|
206
|
+
</View>
|
|
207
|
+
)}
|
|
208
|
+
</Animated.View>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const styles = StyleSheet.create({
|
|
213
|
+
container: {
|
|
214
|
+
padding: SPACING[200],
|
|
215
|
+
borderRadius: BORDER_RADIUS[300],
|
|
216
|
+
backgroundColor: COLORS.NEUTRAL_1,
|
|
217
|
+
overflow: 'hidden',
|
|
218
|
+
...SHADOWS['4'],
|
|
219
|
+
},
|
|
220
|
+
recordingRow: {
|
|
221
|
+
flexDirection: 'row',
|
|
222
|
+
alignItems: 'center',
|
|
223
|
+
gap: SPACING[200],
|
|
224
|
+
},
|
|
225
|
+
recordingDot: {
|
|
226
|
+
width: RECORDING_DOT_SIZE,
|
|
227
|
+
height: RECORDING_DOT_SIZE,
|
|
228
|
+
borderRadius: RECORDING_DOT_SIZE / 2,
|
|
229
|
+
backgroundColor: COLORS.PRIMARY_RED,
|
|
230
|
+
},
|
|
231
|
+
title: {
|
|
232
|
+
color: COLORS.NEUTRAL_10,
|
|
233
|
+
marginLeft: SPACING[100],
|
|
234
|
+
},
|
|
235
|
+
transcriptBody: {
|
|
236
|
+
overflow: 'hidden',
|
|
237
|
+
},
|
|
238
|
+
// Absolute so its measured height stays the content's natural height,
|
|
239
|
+
// independent of the animated wrapper height around it
|
|
240
|
+
transcriptBodyInner: {
|
|
241
|
+
position: 'absolute',
|
|
242
|
+
top: 0,
|
|
243
|
+
left: 0,
|
|
244
|
+
},
|
|
245
|
+
// Fixed per-state widths: the text snaps to its final layout once while the
|
|
246
|
+
// panel frame animates around it
|
|
247
|
+
contentCollapsed: {
|
|
248
|
+
width: VOICE_TRANSCRIPT_PANEL.WIDTH - SPACING[200] * 2,
|
|
249
|
+
},
|
|
250
|
+
fullTextMeasure: {
|
|
251
|
+
position: 'absolute',
|
|
252
|
+
opacity: 0,
|
|
253
|
+
},
|
|
254
|
+
contentExpanded: {
|
|
255
|
+
width: VOICE_TRANSCRIPT_PANEL.WIDTH_DONE - SPACING[200] * 2,
|
|
256
|
+
},
|
|
257
|
+
transcriptScroll: {
|
|
258
|
+
maxHeight: VOICE_TRANSCRIPT_PANEL.TRANSCRIPT_MAX_HEIGHT,
|
|
259
|
+
},
|
|
260
|
+
previewFade: {
|
|
261
|
+
position: 'absolute',
|
|
262
|
+
right: SPACING[200],
|
|
263
|
+
bottom: SPACING[100],
|
|
264
|
+
width: PREVIEW_FADE_WIDTH,
|
|
265
|
+
height: PREVIEW_LINE_HEIGHT,
|
|
266
|
+
flexDirection: 'row',
|
|
267
|
+
},
|
|
268
|
+
previewFadeStripe: {
|
|
269
|
+
flex: 1,
|
|
270
|
+
backgroundColor: COLORS.NEUTRAL_1,
|
|
271
|
+
},
|
|
272
|
+
expandButton: {
|
|
273
|
+
justifyContent: 'center',
|
|
274
|
+
alignItems: 'center',
|
|
275
|
+
},
|
|
276
|
+
processing: {
|
|
277
|
+
alignItems: 'center',
|
|
278
|
+
justifyContent: 'center',
|
|
279
|
+
gap: SPACING[200],
|
|
280
|
+
paddingVertical: SPACING[200],
|
|
281
|
+
},
|
|
282
|
+
processingText: {
|
|
283
|
+
color: COLORS.NEUTRAL_7,
|
|
284
|
+
},
|
|
285
|
+
})
|
|
@@ -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
|
+
})
|