@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.
Files changed (154) hide show
  1. package/dist/commonjs/features/voice/constants.js +39 -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 +17 -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 +245 -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 +83 -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 +65 -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 +38 -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 +15 -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 +238 -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 +78 -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 +60 -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 +32 -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 +2 -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 +26 -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 +32 -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 +2 -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 +26 -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 +145 -0
  137. package/src/features/voice/__tests__/VoiceTranscriptPanel.model.test.ts +135 -0
  138. package/src/features/voice/constants.ts +40 -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 +16 -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 +285 -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 +81 -0
  151. package/src/features/voice/recording/model/VoiceTranscriptPanel.model.ts +76 -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
@@ -8,7 +8,12 @@ import {
8
8
  } from 'expo-audio'
9
9
  import * as FileSystem from 'expo-file-system'
10
10
  import { VoicePlayerApi, VoiceRecorderApi } from './types'
11
- import { VOICE_RECORDER_MAX_DURATION_MS } from './constants'
11
+ import {
12
+ METERING_UPDATE_INTERVAL_MS,
13
+ VOICE_RECORDER_MAX_DURATION_MS,
14
+ VOICE_WAVEFORM,
15
+ WEB_METERING_FFT_SIZE,
16
+ } from './constants'
12
17
  import { PlayerState } from './playing/model'
13
18
  import { AudioStatus } from '../chatbot'
14
19
  import { AxiosResponse } from 'axios'
@@ -20,6 +25,85 @@ export const formatDuration = (ms: number): string => {
20
25
  return `${minutes}:${seconds.toString().padStart(2, '0')}`
21
26
  }
22
27
 
28
+ // Maps a metering value (dBFS, roughly -160..0) to a 0..1 bar level.
29
+ // Anything at or below the floor reads as silence (MIN_LEVEL dots).
30
+ export const normalizeMetering = (metering: number | null | undefined): number => {
31
+ if (metering === null || metering === undefined || Number.isNaN(metering)) {
32
+ return VOICE_WAVEFORM.MIN_LEVEL
33
+ }
34
+
35
+ const range = -VOICE_WAVEFORM.METERING_FLOOR_DB
36
+ const ratio = (metering - VOICE_WAVEFORM.METERING_FLOOR_DB) / range
37
+ return Math.max(VOICE_WAVEFORM.MIN_LEVEL, Math.min(1, ratio))
38
+ }
39
+
40
+ type WebMediaRecorderHolder = { mediaRecorder: MediaRecorder }
41
+
42
+ // expo-audio's web AudioRecorder keeps its MediaRecorder on a public (but
43
+ // untyped) field. Reach for it defensively — if the internals change after an
44
+ // upgrade, we fall back to our own getUserMedia stream.
45
+ const hasWebMediaRecorder = (value: unknown): value is WebMediaRecorderHolder => {
46
+ if (typeof value !== 'object' || value === null) return false
47
+ if (typeof MediaRecorder === 'undefined') return false
48
+ return 'mediaRecorder' in value && value.mediaRecorder instanceof MediaRecorder
49
+ }
50
+
51
+ type MeteringCallback = (decibels: number | null) => void
52
+
53
+ const computeStreamDecibels = (analyser: AnalyserNode, samples: Float32Array): number => {
54
+ analyser.getFloatTimeDomainData(samples)
55
+ const sumOfSquares = samples.reduce((acc, value) => acc + value * value, 0)
56
+ const rms = Math.sqrt(sumOfSquares / samples.length)
57
+ return rms > 0 ? 20 * Math.log10(rms) : VOICE_WAVEFORM.METERING_FLOOR_DB
58
+ }
59
+
60
+ // expo-audio does not implement metering on web, so we measure the input level
61
+ // ourselves with the Web Audio API. Prefers the recorder's own MediaStream;
62
+ // falls back to a dedicated getUserMedia capture (permission is already
63
+ // granted at this point because recording is in progress).
64
+ export const createWebMeteringMonitor = (
65
+ recorder: AudioRecorder,
66
+ onMetering: MeteringCallback,
67
+ ): (() => void) => {
68
+ if (!IS_WEB || typeof AudioContext === 'undefined') return () => {}
69
+
70
+ let isDisposed = false
71
+ let context: AudioContext | null = null
72
+ let ownedStream: MediaStream | null = null
73
+ let intervalId: ReturnType<typeof setInterval> | null = null
74
+
75
+ const start = async () => {
76
+ const recorderStream = hasWebMediaRecorder(recorder) ? recorder.mediaRecorder.stream : null
77
+ const stream = recorderStream ?? (await navigator.mediaDevices.getUserMedia({ audio: true }))
78
+
79
+ if (isDisposed) {
80
+ if (!recorderStream) stream.getTracks().forEach((track) => track.stop())
81
+ return
82
+ }
83
+ if (!recorderStream) ownedStream = stream
84
+
85
+ context = new AudioContext()
86
+ const analyser = context.createAnalyser()
87
+ analyser.fftSize = WEB_METERING_FFT_SIZE
88
+ context.createMediaStreamSource(stream).connect(analyser)
89
+
90
+ const samples = new Float32Array(analyser.fftSize)
91
+ intervalId = setInterval(() => {
92
+ onMetering(computeStreamDecibels(analyser, samples))
93
+ }, METERING_UPDATE_INTERVAL_MS)
94
+ }
95
+
96
+ start().catch(() => onMetering(null))
97
+
98
+ return () => {
99
+ isDisposed = true
100
+ if (intervalId) clearInterval(intervalId)
101
+ ownedStream?.getTracks().forEach((track) => track.stop())
102
+ context?.close().catch(() => {})
103
+ onMetering(null)
104
+ }
105
+ }
106
+
23
107
  export const ensureRecordingPermissions = async () => {
24
108
  const { status } = await getRecordingPermissionsAsync()
25
109
 
@@ -1,7 +1,14 @@
1
1
  export * from './recording/components/VoiceRecord'
2
+ export * from './recording/components/VoiceRecordWithTranscript'
3
+ export * from './recording/components/VoiceWaveform'
4
+ export * from './recording/components/VoiceTranscriptPanel'
5
+ export * from './recording/hooks/useVoiceWaveform'
2
6
  export * from './recording/model/VoiceRecord.model'
7
+ export * from './recording/model/VoiceRecordWithTranscript.model'
8
+ export * from './recording/model/VoiceTranscriptPanel.model'
3
9
  export * from './recording/modal/VoiceRecordDeleteModal'
4
10
  export * from './recording/modal/VoiceRecordUndoModal'
11
+ export * from './transcript.helpers'
5
12
 
6
13
  export * from './playing/model/VoicePlayer.model'
7
14
  export * from './playing/components/VoiceTranscription'
@@ -8,33 +8,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 = ({ text, isLoading }: VoiceTranscriptContentProps) => {
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.BLUE} />
27
+ <Loader size={LoaderSize.SMALL} color={LoaderColor.GRAY} />
22
28
  </View>
23
29
  )
24
30
  }
25
31
 
26
32
  return (
27
- <Typography variant="h8" style={styles.text}>
28
- {text}
29
- </Typography>
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.NEUTRAL_7,
36
- paddingHorizontal: SPACING[200],
37
- paddingVertical: SPACING[200],
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 { 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,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
+ })