@magmamath/students-features 1.7.9 → 1.7.10-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/dist/commonjs/features/voice/constants.js +42 -3
  2. package/dist/commonjs/features/voice/constants.js.map +1 -1
  3. package/dist/commonjs/features/voice/helpers.js +67 -1
  4. package/dist/commonjs/features/voice/helpers.js.map +1 -1
  5. package/dist/commonjs/features/voice/index.js +77 -0
  6. package/dist/commonjs/features/voice/index.js.map +1 -1
  7. package/dist/commonjs/features/voice/playing/components/VoiceTranscriptContent.js +22 -9
  8. package/dist/commonjs/features/voice/playing/components/VoiceTranscriptContent.js.map +1 -1
  9. package/dist/commonjs/features/voice/playing/model/TranscriptionsDownloader.model.js +4 -27
  10. package/dist/commonjs/features/voice/playing/model/TranscriptionsDownloader.model.js.map +1 -1
  11. package/dist/commonjs/features/voice/recording/components/VoiceRecordWithTranscript.js +49 -0
  12. package/dist/commonjs/features/voice/recording/components/VoiceRecordWithTranscript.js.map +1 -0
  13. package/dist/commonjs/features/voice/recording/components/VoiceTranscriptPanel.js +253 -0
  14. package/dist/commonjs/features/voice/recording/components/VoiceTranscriptPanel.js.map +1 -0
  15. package/dist/commonjs/features/voice/recording/components/VoiceWaveform.js +93 -0
  16. package/dist/commonjs/features/voice/recording/components/VoiceWaveform.js.map +1 -0
  17. package/dist/commonjs/features/voice/recording/hooks/useTranscriptPanelAnimation.js +42 -0
  18. package/dist/commonjs/features/voice/recording/hooks/useTranscriptPanelAnimation.js.map +1 -0
  19. package/dist/commonjs/features/voice/recording/hooks/useVoiceRecorder.js +17 -2
  20. package/dist/commonjs/features/voice/recording/hooks/useVoiceRecorder.js.map +1 -1
  21. package/dist/commonjs/features/voice/recording/hooks/useVoiceWaveform.js +43 -0
  22. package/dist/commonjs/features/voice/recording/hooks/useVoiceWaveform.js.map +1 -0
  23. package/dist/commonjs/features/voice/recording/model/Recorder.model.js +3 -0
  24. package/dist/commonjs/features/voice/recording/model/Recorder.model.js.map +1 -1
  25. package/dist/commonjs/features/voice/recording/model/VoiceRecordWithTranscript.model.js +100 -0
  26. package/dist/commonjs/features/voice/recording/model/VoiceRecordWithTranscript.model.js.map +1 -0
  27. package/dist/commonjs/features/voice/recording/model/VoiceTranscriptPanel.model.js +115 -0
  28. package/dist/commonjs/features/voice/recording/model/VoiceTranscriptPanel.model.js.map +1 -0
  29. package/dist/commonjs/features/voice/transcript.helpers.js +36 -0
  30. package/dist/commonjs/features/voice/transcript.helpers.js.map +1 -0
  31. package/dist/commonjs/features/voice/types.js +11 -7
  32. package/dist/commonjs/features/voice/types.js.map +1 -1
  33. package/dist/module/features/voice/constants.js +41 -0
  34. package/dist/module/features/voice/constants.js.map +1 -1
  35. package/dist/module/features/voice/helpers.js +65 -1
  36. package/dist/module/features/voice/helpers.js.map +1 -1
  37. package/dist/module/features/voice/index.js +7 -0
  38. package/dist/module/features/voice/index.js.map +1 -1
  39. package/dist/module/features/voice/playing/components/VoiceTranscriptContent.js +20 -9
  40. package/dist/module/features/voice/playing/components/VoiceTranscriptContent.js.map +1 -1
  41. package/dist/module/features/voice/playing/model/TranscriptionsDownloader.model.js +4 -27
  42. package/dist/module/features/voice/playing/model/TranscriptionsDownloader.model.js.map +1 -1
  43. package/dist/module/features/voice/recording/components/VoiceRecordWithTranscript.js +43 -0
  44. package/dist/module/features/voice/recording/components/VoiceRecordWithTranscript.js.map +1 -0
  45. package/dist/module/features/voice/recording/components/VoiceTranscriptPanel.js +246 -0
  46. package/dist/module/features/voice/recording/components/VoiceTranscriptPanel.js.map +1 -0
  47. package/dist/module/features/voice/recording/components/VoiceWaveform.js +86 -0
  48. package/dist/module/features/voice/recording/components/VoiceWaveform.js.map +1 -0
  49. package/dist/module/features/voice/recording/hooks/useTranscriptPanelAnimation.js +37 -0
  50. package/dist/module/features/voice/recording/hooks/useTranscriptPanelAnimation.js.map +1 -0
  51. package/dist/module/features/voice/recording/hooks/useVoiceRecorder.js +20 -3
  52. package/dist/module/features/voice/recording/hooks/useVoiceRecorder.js.map +1 -1
  53. package/dist/module/features/voice/recording/hooks/useVoiceWaveform.js +38 -0
  54. package/dist/module/features/voice/recording/hooks/useVoiceWaveform.js.map +1 -0
  55. package/dist/module/features/voice/recording/model/Recorder.model.js +3 -0
  56. package/dist/module/features/voice/recording/model/Recorder.model.js.map +1 -1
  57. package/dist/module/features/voice/recording/model/VoiceRecordWithTranscript.model.js +95 -0
  58. package/dist/module/features/voice/recording/model/VoiceRecordWithTranscript.model.js.map +1 -0
  59. package/dist/module/features/voice/recording/model/VoiceTranscriptPanel.model.js +110 -0
  60. package/dist/module/features/voice/recording/model/VoiceTranscriptPanel.model.js.map +1 -0
  61. package/dist/module/features/voice/transcript.helpers.js +31 -0
  62. package/dist/module/features/voice/transcript.helpers.js.map +1 -0
  63. package/dist/module/features/voice/types.js +4 -6
  64. package/dist/module/features/voice/types.js.map +1 -1
  65. package/dist/typescript/commonjs/features/voice/__tests__/VoiceRecordWithTranscript.model.test.d.ts +2 -0
  66. package/dist/typescript/commonjs/features/voice/__tests__/VoiceRecordWithTranscript.model.test.d.ts.map +1 -0
  67. package/dist/typescript/commonjs/features/voice/__tests__/VoiceTranscriptPanel.model.test.d.ts +2 -0
  68. package/dist/typescript/commonjs/features/voice/__tests__/VoiceTranscriptPanel.model.test.d.ts.map +1 -0
  69. package/dist/typescript/commonjs/features/voice/constants.d.ts +34 -0
  70. package/dist/typescript/commonjs/features/voice/constants.d.ts.map +1 -1
  71. package/dist/typescript/commonjs/features/voice/helpers.d.ts +3 -0
  72. package/dist/typescript/commonjs/features/voice/helpers.d.ts.map +1 -1
  73. package/dist/typescript/commonjs/features/voice/index.d.ts +7 -0
  74. package/dist/typescript/commonjs/features/voice/index.d.ts.map +1 -1
  75. package/dist/typescript/commonjs/features/voice/playing/components/VoiceTranscriptContent.d.ts +3 -1
  76. package/dist/typescript/commonjs/features/voice/playing/components/VoiceTranscriptContent.d.ts.map +1 -1
  77. package/dist/typescript/commonjs/features/voice/playing/model/TranscriptionsDownloader.model.d.ts +2 -7
  78. package/dist/typescript/commonjs/features/voice/playing/model/TranscriptionsDownloader.model.d.ts.map +1 -1
  79. package/dist/typescript/commonjs/features/voice/recording/components/VoiceRecordWithTranscript.d.ts +13 -0
  80. package/dist/typescript/commonjs/features/voice/recording/components/VoiceRecordWithTranscript.d.ts.map +1 -0
  81. package/dist/typescript/commonjs/features/voice/recording/components/VoiceTranscriptPanel.d.ts +11 -0
  82. package/dist/typescript/commonjs/features/voice/recording/components/VoiceTranscriptPanel.d.ts.map +1 -0
  83. package/dist/typescript/commonjs/features/voice/recording/components/VoiceWaveform.d.ts +8 -0
  84. package/dist/typescript/commonjs/features/voice/recording/components/VoiceWaveform.d.ts.map +1 -0
  85. package/dist/typescript/commonjs/features/voice/recording/hooks/useTranscriptPanelAnimation.d.ts +18 -0
  86. package/dist/typescript/commonjs/features/voice/recording/hooks/useTranscriptPanelAnimation.d.ts.map +1 -0
  87. package/dist/typescript/commonjs/features/voice/recording/hooks/useVoiceRecorder.d.ts.map +1 -1
  88. package/dist/typescript/commonjs/features/voice/recording/hooks/useVoiceWaveform.d.ts +7 -0
  89. package/dist/typescript/commonjs/features/voice/recording/hooks/useVoiceWaveform.d.ts.map +1 -0
  90. package/dist/typescript/commonjs/features/voice/recording/model/Recorder.model.d.ts +2 -0
  91. package/dist/typescript/commonjs/features/voice/recording/model/Recorder.model.d.ts.map +1 -1
  92. package/dist/typescript/commonjs/features/voice/recording/model/VoiceRecordWithTranscript.model.d.ts +16 -0
  93. package/dist/typescript/commonjs/features/voice/recording/model/VoiceRecordWithTranscript.model.d.ts.map +1 -0
  94. package/dist/typescript/commonjs/features/voice/recording/model/VoiceTranscriptPanel.model.d.ts +31 -0
  95. package/dist/typescript/commonjs/features/voice/recording/model/VoiceTranscriptPanel.model.d.ts.map +1 -0
  96. package/dist/typescript/commonjs/features/voice/transcript.helpers.d.ts +8 -0
  97. package/dist/typescript/commonjs/features/voice/transcript.helpers.d.ts.map +1 -0
  98. package/dist/typescript/commonjs/features/voice/types.d.ts +2 -5
  99. package/dist/typescript/commonjs/features/voice/types.d.ts.map +1 -1
  100. package/dist/typescript/module/features/voice/__tests__/VoiceRecordWithTranscript.model.test.d.ts +2 -0
  101. package/dist/typescript/module/features/voice/__tests__/VoiceRecordWithTranscript.model.test.d.ts.map +1 -0
  102. package/dist/typescript/module/features/voice/__tests__/VoiceTranscriptPanel.model.test.d.ts +2 -0
  103. package/dist/typescript/module/features/voice/__tests__/VoiceTranscriptPanel.model.test.d.ts.map +1 -0
  104. package/dist/typescript/module/features/voice/constants.d.ts +34 -0
  105. package/dist/typescript/module/features/voice/constants.d.ts.map +1 -1
  106. package/dist/typescript/module/features/voice/helpers.d.ts +3 -0
  107. package/dist/typescript/module/features/voice/helpers.d.ts.map +1 -1
  108. package/dist/typescript/module/features/voice/index.d.ts +7 -0
  109. package/dist/typescript/module/features/voice/index.d.ts.map +1 -1
  110. package/dist/typescript/module/features/voice/playing/components/VoiceTranscriptContent.d.ts +3 -1
  111. package/dist/typescript/module/features/voice/playing/components/VoiceTranscriptContent.d.ts.map +1 -1
  112. package/dist/typescript/module/features/voice/playing/model/TranscriptionsDownloader.model.d.ts +2 -7
  113. package/dist/typescript/module/features/voice/playing/model/TranscriptionsDownloader.model.d.ts.map +1 -1
  114. package/dist/typescript/module/features/voice/recording/components/VoiceRecordWithTranscript.d.ts +13 -0
  115. package/dist/typescript/module/features/voice/recording/components/VoiceRecordWithTranscript.d.ts.map +1 -0
  116. package/dist/typescript/module/features/voice/recording/components/VoiceTranscriptPanel.d.ts +11 -0
  117. package/dist/typescript/module/features/voice/recording/components/VoiceTranscriptPanel.d.ts.map +1 -0
  118. package/dist/typescript/module/features/voice/recording/components/VoiceWaveform.d.ts +8 -0
  119. package/dist/typescript/module/features/voice/recording/components/VoiceWaveform.d.ts.map +1 -0
  120. package/dist/typescript/module/features/voice/recording/hooks/useTranscriptPanelAnimation.d.ts +18 -0
  121. package/dist/typescript/module/features/voice/recording/hooks/useTranscriptPanelAnimation.d.ts.map +1 -0
  122. package/dist/typescript/module/features/voice/recording/hooks/useVoiceRecorder.d.ts.map +1 -1
  123. package/dist/typescript/module/features/voice/recording/hooks/useVoiceWaveform.d.ts +7 -0
  124. package/dist/typescript/module/features/voice/recording/hooks/useVoiceWaveform.d.ts.map +1 -0
  125. package/dist/typescript/module/features/voice/recording/model/Recorder.model.d.ts +2 -0
  126. package/dist/typescript/module/features/voice/recording/model/Recorder.model.d.ts.map +1 -1
  127. package/dist/typescript/module/features/voice/recording/model/VoiceRecordWithTranscript.model.d.ts +16 -0
  128. package/dist/typescript/module/features/voice/recording/model/VoiceRecordWithTranscript.model.d.ts.map +1 -0
  129. package/dist/typescript/module/features/voice/recording/model/VoiceTranscriptPanel.model.d.ts +31 -0
  130. package/dist/typescript/module/features/voice/recording/model/VoiceTranscriptPanel.model.d.ts.map +1 -0
  131. package/dist/typescript/module/features/voice/transcript.helpers.d.ts +8 -0
  132. package/dist/typescript/module/features/voice/transcript.helpers.d.ts.map +1 -0
  133. package/dist/typescript/module/features/voice/types.d.ts +2 -5
  134. package/dist/typescript/module/features/voice/types.d.ts.map +1 -1
  135. package/package.json +1 -1
  136. package/src/features/voice/__tests__/VoiceRecordWithTranscript.model.test.ts +228 -0
  137. package/src/features/voice/__tests__/VoiceTranscriptPanel.model.test.ts +237 -0
  138. package/src/features/voice/constants.ts +43 -0
  139. package/src/features/voice/helpers.ts +85 -1
  140. package/src/features/voice/index.ts +7 -0
  141. package/src/features/voice/playing/components/VoiceTranscriptContent.tsx +27 -8
  142. package/src/features/voice/playing/model/TranscriptionsDownloader.model.ts +8 -30
  143. package/src/features/voice/recording/components/VoiceRecordWithTranscript.tsx +52 -0
  144. package/src/features/voice/recording/components/VoiceTranscriptPanel.tsx +293 -0
  145. package/src/features/voice/recording/components/VoiceWaveform.tsx +102 -0
  146. package/src/features/voice/recording/hooks/useTranscriptPanelAnimation.ts +49 -0
  147. package/src/features/voice/recording/hooks/useVoiceRecorder.ts +26 -3
  148. package/src/features/voice/recording/hooks/useVoiceWaveform.ts +46 -0
  149. package/src/features/voice/recording/model/Recorder.model.ts +3 -0
  150. package/src/features/voice/recording/model/VoiceRecordWithTranscript.model.ts +104 -0
  151. package/src/features/voice/recording/model/VoiceTranscriptPanel.model.ts +131 -0
  152. package/src/features/voice/transcript.helpers.ts +37 -0
  153. package/src/features/voice/types.ts +5 -6
  154. package/src/i18n/.generated/schema.json +6 -5
@@ -1,13 +1,7 @@
1
1
  import { createEffect } from 'effector'
2
2
  import { TranscriptionsCollection } from './TranscriptionsCollection'
3
- import { TranscriptionStatus, VoicePlayerApi } from '../../types'
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 fetchTranscriptWithRetry(audioFileId: string): Promise<TranscriptResult> {
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 = this.runFetchWithRetry(audioFileId)
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.fetchTranscriptWithRetry(item.audioFileId)
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 { addAudioInputsListenerWeb, getAvailableInputs } from '../../helpers'
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(RecordingPresets.LOW_QUALITY, model.recordingStatusUpdate)
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 {