@siteed/expo-audio-stream 2.1.0 → 2.2.0

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 (187) hide show
  1. package/README.md +40 -222
  2. package/build/index.d.ts +11 -15
  3. package/build/index.js +44 -14
  4. package/package.json +49 -110
  5. package/src/index.ts +18 -32
  6. package/CHANGELOG.md +0 -206
  7. package/android/build.gradle +0 -105
  8. package/android/src/main/AndroidManifest.xml +0 -27
  9. package/android/src/main/java/net/siteed/audiostream/AudioAnalysisData.kt +0 -166
  10. package/android/src/main/java/net/siteed/audiostream/AudioDataEncoder.kt +0 -9
  11. package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +0 -131
  12. package/android/src/main/java/net/siteed/audiostream/AudioFormatUtils.kt +0 -103
  13. package/android/src/main/java/net/siteed/audiostream/AudioNotificationsManager.kt +0 -435
  14. package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +0 -2235
  15. package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +0 -1437
  16. package/android/src/main/java/net/siteed/audiostream/AudioRecordingService.kt +0 -152
  17. package/android/src/main/java/net/siteed/audiostream/AudioTrimmer.kt +0 -1099
  18. package/android/src/main/java/net/siteed/audiostream/Constants.kt +0 -21
  19. package/android/src/main/java/net/siteed/audiostream/EventSender.kt +0 -7
  20. package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +0 -739
  21. package/android/src/main/java/net/siteed/audiostream/FFT.kt +0 -99
  22. package/android/src/main/java/net/siteed/audiostream/Features.kt +0 -98
  23. package/android/src/main/java/net/siteed/audiostream/NotificationConfig.kt +0 -70
  24. package/android/src/main/java/net/siteed/audiostream/PermissionUtils.kt +0 -59
  25. package/android/src/main/java/net/siteed/audiostream/RecordingActionReceiver.kt +0 -59
  26. package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +0 -205
  27. package/android/src/main/java/net/siteed/audiostream/WaveformConfig.kt +0 -19
  28. package/android/src/main/java/net/siteed/audiostream/WaveformRenderer.kt +0 -159
  29. package/android/src/main/res/drawable/ic_default_action_icon.xml +0 -16
  30. package/android/src/main/res/drawable/ic_microphone.xml +0 -13
  31. package/android/src/main/res/drawable/ic_pause.xml +0 -10
  32. package/android/src/main/res/drawable/ic_play.xml +0 -10
  33. package/android/src/main/res/drawable/ic_stop.xml +0 -10
  34. package/android/src/main/res/layout/notification_recording.xml +0 -37
  35. package/android/src/main/test/java/net/siteed/audiostream/AudioProcessorTest.kt +0 -56
  36. package/app.plugin.js +0 -1
  37. package/build/AudioAnalysis/AudioAnalysis.types.d.ts +0 -179
  38. package/build/AudioAnalysis/AudioAnalysis.types.d.ts.map +0 -1
  39. package/build/AudioAnalysis/AudioAnalysis.types.js +0 -3
  40. package/build/AudioAnalysis/AudioAnalysis.types.js.map +0 -1
  41. package/build/AudioAnalysis/extractAudioAnalysis.d.ts +0 -68
  42. package/build/AudioAnalysis/extractAudioAnalysis.d.ts.map +0 -1
  43. package/build/AudioAnalysis/extractAudioAnalysis.js +0 -203
  44. package/build/AudioAnalysis/extractAudioAnalysis.js.map +0 -1
  45. package/build/AudioAnalysis/extractAudioData.d.ts +0 -3
  46. package/build/AudioAnalysis/extractAudioData.d.ts.map +0 -1
  47. package/build/AudioAnalysis/extractAudioData.js +0 -5
  48. package/build/AudioAnalysis/extractAudioData.js.map +0 -1
  49. package/build/AudioAnalysis/extractMelSpectrogram.d.ts +0 -14
  50. package/build/AudioAnalysis/extractMelSpectrogram.d.ts.map +0 -1
  51. package/build/AudioAnalysis/extractMelSpectrogram.js +0 -85
  52. package/build/AudioAnalysis/extractMelSpectrogram.js.map +0 -1
  53. package/build/AudioAnalysis/extractPreview.d.ts +0 -11
  54. package/build/AudioAnalysis/extractPreview.d.ts.map +0 -1
  55. package/build/AudioAnalysis/extractPreview.js +0 -25
  56. package/build/AudioAnalysis/extractPreview.js.map +0 -1
  57. package/build/AudioAnalysis/extractWaveform.d.ts +0 -8
  58. package/build/AudioAnalysis/extractWaveform.d.ts.map +0 -1
  59. package/build/AudioAnalysis/extractWaveform.js +0 -11
  60. package/build/AudioAnalysis/extractWaveform.js.map +0 -1
  61. package/build/AudioRecorder.provider.d.ts +0 -11
  62. package/build/AudioRecorder.provider.d.ts.map +0 -1
  63. package/build/AudioRecorder.provider.js +0 -37
  64. package/build/AudioRecorder.provider.js.map +0 -1
  65. package/build/ExpoAudioStream.native.d.ts +0 -3
  66. package/build/ExpoAudioStream.native.d.ts.map +0 -1
  67. package/build/ExpoAudioStream.native.js +0 -6
  68. package/build/ExpoAudioStream.native.js.map +0 -1
  69. package/build/ExpoAudioStream.types.d.ts +0 -532
  70. package/build/ExpoAudioStream.types.d.ts.map +0 -1
  71. package/build/ExpoAudioStream.types.js +0 -2
  72. package/build/ExpoAudioStream.types.js.map +0 -1
  73. package/build/ExpoAudioStream.web.d.ts +0 -59
  74. package/build/ExpoAudioStream.web.d.ts.map +0 -1
  75. package/build/ExpoAudioStream.web.js +0 -285
  76. package/build/ExpoAudioStream.web.js.map +0 -1
  77. package/build/ExpoAudioStreamModule.d.ts +0 -3
  78. package/build/ExpoAudioStreamModule.d.ts.map +0 -1
  79. package/build/ExpoAudioStreamModule.js +0 -693
  80. package/build/ExpoAudioStreamModule.js.map +0 -1
  81. package/build/WebRecorder.web.d.ts +0 -119
  82. package/build/WebRecorder.web.d.ts.map +0 -1
  83. package/build/WebRecorder.web.js +0 -436
  84. package/build/WebRecorder.web.js.map +0 -1
  85. package/build/constants.d.ts +0 -11
  86. package/build/constants.d.ts.map +0 -1
  87. package/build/constants.js +0 -14
  88. package/build/constants.js.map +0 -1
  89. package/build/events.d.ts +0 -26
  90. package/build/events.d.ts.map +0 -1
  91. package/build/events.js +0 -21
  92. package/build/events.js.map +0 -1
  93. package/build/index.d.ts.map +0 -1
  94. package/build/index.js.map +0 -1
  95. package/build/trimAudio.d.ts +0 -25
  96. package/build/trimAudio.d.ts.map +0 -1
  97. package/build/trimAudio.js +0 -67
  98. package/build/trimAudio.js.map +0 -1
  99. package/build/useAudioRecorder.d.ts +0 -21
  100. package/build/useAudioRecorder.d.ts.map +0 -1
  101. package/build/useAudioRecorder.js +0 -427
  102. package/build/useAudioRecorder.js.map +0 -1
  103. package/build/utils/BlobFix.d.ts +0 -9
  104. package/build/utils/BlobFix.d.ts.map +0 -1
  105. package/build/utils/BlobFix.js +0 -498
  106. package/build/utils/BlobFix.js.map +0 -1
  107. package/build/utils/audioProcessing.d.ts +0 -24
  108. package/build/utils/audioProcessing.d.ts.map +0 -1
  109. package/build/utils/audioProcessing.js +0 -133
  110. package/build/utils/audioProcessing.js.map +0 -1
  111. package/build/utils/concatenateBuffers.d.ts +0 -8
  112. package/build/utils/concatenateBuffers.d.ts.map +0 -1
  113. package/build/utils/concatenateBuffers.js +0 -21
  114. package/build/utils/concatenateBuffers.js.map +0 -1
  115. package/build/utils/convertPCMToFloat32.d.ts +0 -13
  116. package/build/utils/convertPCMToFloat32.d.ts.map +0 -1
  117. package/build/utils/convertPCMToFloat32.js +0 -120
  118. package/build/utils/convertPCMToFloat32.js.map +0 -1
  119. package/build/utils/encodingToBitDepth.d.ts +0 -5
  120. package/build/utils/encodingToBitDepth.d.ts.map +0 -1
  121. package/build/utils/encodingToBitDepth.js +0 -13
  122. package/build/utils/encodingToBitDepth.js.map +0 -1
  123. package/build/utils/getWavFileInfo.d.ts +0 -26
  124. package/build/utils/getWavFileInfo.d.ts.map +0 -1
  125. package/build/utils/getWavFileInfo.js +0 -92
  126. package/build/utils/getWavFileInfo.js.map +0 -1
  127. package/build/utils/writeWavHeader.d.ts +0 -49
  128. package/build/utils/writeWavHeader.d.ts.map +0 -1
  129. package/build/utils/writeWavHeader.js +0 -91
  130. package/build/utils/writeWavHeader.js.map +0 -1
  131. package/build/workers/InlineFeaturesExtractor.web.d.ts +0 -2
  132. package/build/workers/InlineFeaturesExtractor.web.d.ts.map +0 -1
  133. package/build/workers/InlineFeaturesExtractor.web.js +0 -828
  134. package/build/workers/InlineFeaturesExtractor.web.js.map +0 -1
  135. package/build/workers/inlineAudioWebWorker.web.d.ts +0 -2
  136. package/build/workers/inlineAudioWebWorker.web.d.ts.map +0 -1
  137. package/build/workers/inlineAudioWebWorker.web.js +0 -157
  138. package/build/workers/inlineAudioWebWorker.web.js.map +0 -1
  139. package/expo-module.config.json +0 -9
  140. package/ios/AudioAnalysisData.swift +0 -74
  141. package/ios/AudioNotificationManager.swift +0 -135
  142. package/ios/AudioProcessingHelpers.swift +0 -743
  143. package/ios/AudioProcessor.swift +0 -1313
  144. package/ios/AudioStreamError.swift +0 -7
  145. package/ios/AudioStreamManager.swift +0 -1708
  146. package/ios/AudioStreamManagerDelegate.swift +0 -16
  147. package/ios/DataPoint.swift +0 -54
  148. package/ios/DecodingConfig.swift +0 -47
  149. package/ios/ExpoAudioStream.podspec +0 -27
  150. package/ios/ExpoAudioStreamModule.swift +0 -805
  151. package/ios/FFT.swift +0 -62
  152. package/ios/Features.swift +0 -95
  153. package/ios/Logger.swift +0 -7
  154. package/ios/NotificationExtension.swift +0 -15
  155. package/ios/RecordingResult.swift +0 -22
  156. package/ios/RecordingSettings.swift +0 -265
  157. package/ios/WaveformExtractor.swift +0 -105
  158. package/plugin/build/index.d.ts +0 -21
  159. package/plugin/build/index.js +0 -191
  160. package/plugin/src/index.ts +0 -278
  161. package/plugin/tsconfig.json +0 -10
  162. package/plugin/tsconfig.tsbuildinfo +0 -1
  163. package/src/AudioAnalysis/AudioAnalysis.types.ts +0 -202
  164. package/src/AudioAnalysis/extractAudioAnalysis.ts +0 -333
  165. package/src/AudioAnalysis/extractAudioData.ts +0 -6
  166. package/src/AudioAnalysis/extractMelSpectrogram.ts +0 -144
  167. package/src/AudioAnalysis/extractPreview.ts +0 -34
  168. package/src/AudioAnalysis/extractWaveform.ts +0 -22
  169. package/src/AudioRecorder.provider.tsx +0 -54
  170. package/src/ExpoAudioStream.native.ts +0 -6
  171. package/src/ExpoAudioStream.types.ts +0 -641
  172. package/src/ExpoAudioStream.web.ts +0 -359
  173. package/src/ExpoAudioStreamModule.ts +0 -967
  174. package/src/WebRecorder.web.ts +0 -580
  175. package/src/constants.ts +0 -18
  176. package/src/events.ts +0 -60
  177. package/src/trimAudio.ts +0 -90
  178. package/src/useAudioRecorder.tsx +0 -620
  179. package/src/utils/BlobFix.ts +0 -559
  180. package/src/utils/audioProcessing.ts +0 -205
  181. package/src/utils/concatenateBuffers.ts +0 -24
  182. package/src/utils/convertPCMToFloat32.ts +0 -170
  183. package/src/utils/encodingToBitDepth.ts +0 -18
  184. package/src/utils/getWavFileInfo.ts +0 -132
  185. package/src/utils/writeWavHeader.ts +0 -114
  186. package/src/workers/InlineFeaturesExtractor.web.tsx +0 -827
  187. package/src/workers/inlineAudioWebWorker.web.tsx +0 -156
@@ -1,1437 +0,0 @@
1
- // net/siteed/audiostream/AudioRecorderManager.kt
2
- package net.siteed.audiostream
3
-
4
- import android.annotation.SuppressLint
5
- import android.media.AudioFormat
6
- import android.media.AudioRecord
7
- import android.media.MediaRecorder
8
- import android.os.Build
9
- import android.os.Bundle
10
- import android.os.Handler
11
- import android.os.Looper
12
- import android.os.SystemClock
13
- import android.util.Log
14
- import androidx.annotation.RequiresApi
15
- import androidx.core.os.bundleOf
16
- import expo.modules.kotlin.Promise
17
- import java.io.ByteArrayOutputStream
18
- import java.io.File
19
- import java.io.FileOutputStream
20
- import java.io.IOException
21
- import java.util.concurrent.atomic.AtomicBoolean
22
- import android.os.PowerManager
23
- import android.content.Context
24
- import java.nio.ByteBuffer
25
- import java.nio.ByteOrder
26
- import android.media.AudioManager
27
- import android.media.AudioAttributes
28
- import android.media.AudioFocusRequest
29
- import android.telephony.PhoneStateListener
30
- import android.telephony.TelephonyManager
31
- import android.app.ActivityManager
32
- import java.util.UUID
33
- import android.media.AudioDeviceInfo
34
-
35
- class AudioRecorderManager(
36
- private val context: Context,
37
- private val filesDir: File,
38
- private val permissionUtils: PermissionUtils,
39
- private val audioDataEncoder: AudioDataEncoder,
40
- private val eventSender: EventSender
41
- ) {
42
- private var audioRecord: AudioRecord? = null
43
- private var bufferSizeInBytes = 0
44
- private var isRecording = AtomicBoolean(false)
45
- private val isPaused = AtomicBoolean(false)
46
- private var streamUuid: String? = null
47
- private var audioFile: File? = null
48
- private var recordingThread: Thread? = null
49
- private var recordingStartTime: Long = 0
50
- private var totalRecordedTime: Long = 0
51
- private var totalDataSize = 0
52
- private var lastEmitTime = SystemClock.elapsedRealtime()
53
- private var lastPauseTime = 0L
54
- private var pausedDuration = 0L
55
- private var lastEmittedSize = 0L
56
- private var lastEmittedCompressedSize = 0L
57
- private val mainHandler = Handler(Looper.getMainLooper())
58
- private val audioRecordLock = Any()
59
- private var audioFileHandler: AudioFileHandler = AudioFileHandler(filesDir)
60
-
61
- private lateinit var recordingConfig: RecordingConfig
62
- private var mimeType = "audio/wav"
63
- private var audioFormat: Int = AudioFormat.ENCODING_PCM_16BIT
64
- private var audioProcessor: AudioProcessor = AudioProcessor(filesDir)
65
- private var isFirstChunk = true
66
-
67
- private var wakeLock: PowerManager.WakeLock? = null
68
- private var wasWakeLockEnabled = false
69
- private val notificationManager = AudioNotificationManager.getInstance(context)
70
-
71
- private var compressedRecorder: MediaRecorder? = null
72
- private var compressedFile: File? = null
73
-
74
- private var audioManager: AudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
75
- private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
76
- private var audioFocusRequest: Any? = null // Type Any to handle both old and new APIs
77
- private var phoneStateListener: PhoneStateListener? = null
78
- private var telephonyManager: TelephonyManager? = null
79
- get() {
80
- if (field == null) {
81
- field = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
82
- Log.d(Constants.TAG, "TelephonyManager initialization: ${if (field != null) "successful" else "failed"}")
83
- }
84
- return field
85
- }
86
-
87
- private var lastEmissionTimeAnalysis = 0L
88
- private val analysisBuffer = ByteArrayOutputStream()
89
- private var isFirstAnalysis = true
90
-
91
- private fun initializePhoneStateListener() {
92
- try {
93
- Log.d(Constants.TAG, "Initializing phone state listener...")
94
-
95
- if (permissionUtils.checkPhoneStatePermission()) {
96
- Log.d(Constants.TAG, "Phone state permission granted")
97
-
98
- phoneStateListener = object : PhoneStateListener() {
99
- override fun onCallStateChanged(state: Int, phoneNumber: String?) {
100
- val stateStr = when (state) {
101
- TelephonyManager.CALL_STATE_RINGING -> "RINGING"
102
- TelephonyManager.CALL_STATE_OFFHOOK -> "OFFHOOK"
103
- TelephonyManager.CALL_STATE_IDLE -> "IDLE"
104
- else -> "UNKNOWN"
105
- }
106
- Log.d(Constants.TAG, "Phone state changed to: $stateStr")
107
-
108
- when (state) {
109
- TelephonyManager.CALL_STATE_RINGING,
110
- TelephonyManager.CALL_STATE_OFFHOOK -> {
111
- if (isRecording.get() && !isPaused.get()) {
112
- Log.d(Constants.TAG, "Pausing recording due to incoming/ongoing call")
113
- mainHandler.post {
114
- pauseRecording(object : Promise {
115
- override fun resolve(value: Any?) {
116
- Log.d(Constants.TAG, "Successfully paused recording due to call")
117
- eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
118
- "reason" to "phoneCall",
119
- "isPaused" to true
120
- ))
121
- }
122
- override fun reject(code: String, message: String?, cause: Throwable?) {
123
- Log.e(Constants.TAG, "Failed to pause recording on phone call", cause)
124
- }
125
- })
126
- }
127
- }
128
- }
129
- TelephonyManager.CALL_STATE_IDLE -> {
130
- if (isRecording.get() && isPaused.get()) {
131
- Log.d(Constants.TAG, "Call ended, handling auto-resume (enabled: ${recordingConfig.autoResumeAfterInterruption})")
132
- if (recordingConfig.autoResumeAfterInterruption) {
133
- mainHandler.post {
134
- resumeRecording(object : Promise {
135
- override fun resolve(value: Any?) {
136
- Log.d(Constants.TAG, "Successfully resumed recording after call")
137
- eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
138
- "reason" to "phoneCallEnded",
139
- "isPaused" to false
140
- ))
141
- }
142
- override fun reject(code: String, message: String?, cause: Throwable?) {
143
- Log.e(Constants.TAG, "Failed to resume recording after phone call", cause)
144
- }
145
- })
146
- }
147
- } else {
148
- Log.d(Constants.TAG, "Auto-resume disabled, staying paused")
149
- eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
150
- "reason" to "phoneCallEnded",
151
- "isPaused" to true
152
- ))
153
- }
154
- }
155
- }
156
- }
157
- }
158
- }
159
-
160
- if (telephonyManager != null) {
161
- try {
162
- telephonyManager?.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE)
163
- Log.d(Constants.TAG, "Successfully registered phone state listener")
164
- } catch (e: Exception) {
165
- Log.e(Constants.TAG, "Failed to register phone state listener", e)
166
- }
167
- } else {
168
- Log.e(Constants.TAG, "TelephonyManager is null, cannot register phone state listener")
169
- }
170
- } else {
171
- Log.w(Constants.TAG, "READ_PHONE_STATE permission not granted, phone call interruption handling disabled")
172
- }
173
- } catch (e: Exception) {
174
- Log.e(Constants.TAG, "Failed to initialize phone state listener", e)
175
- }
176
- }
177
-
178
- @RequiresApi(Build.VERSION_CODES.O)
179
- private val audioFocusCallback = object : AudioManager.OnAudioFocusChangeListener {
180
- override fun onAudioFocusChange(focusChange: Int) {
181
- when (focusChange) {
182
- AudioManager.AUDIOFOCUS_LOSS,
183
- AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
184
- if (isRecording.get() && !isPaused.get()) {
185
- mainHandler.post {
186
- pauseRecording(object : Promise {
187
- override fun resolve(value: Any?) {
188
- eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
189
- "reason" to "audioFocusLoss",
190
- "isPaused" to true
191
- ))
192
- }
193
- override fun reject(code: String, message: String?, cause: Throwable?) {
194
- Log.e(Constants.TAG, "Failed to pause recording on audio focus loss")
195
- }
196
- })
197
- }
198
- }
199
- }
200
- AudioManager.AUDIOFOCUS_GAIN -> {
201
- if (isRecording.get() && isPaused.get() && recordingConfig.autoResumeAfterInterruption) {
202
- mainHandler.post {
203
- resumeRecording(object : Promise {
204
- override fun resolve(value: Any?) {
205
- eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
206
- "reason" to "audioFocusGain",
207
- "isPaused" to false
208
- ))
209
- }
210
- override fun reject(code: String, message: String?, cause: Throwable?) {
211
- Log.e(Constants.TAG, "Failed to resume recording on audio focus gain")
212
- }
213
- })
214
- }
215
- }
216
- }
217
- }
218
- }
219
- }
220
-
221
- companion object {
222
- @SuppressLint("StaticFieldLeak")
223
- @Volatile
224
- private var instance: AudioRecorderManager? = null
225
-
226
- fun getInstance(): AudioRecorderManager? = instance
227
-
228
- fun initialize(
229
- context: Context,
230
- filesDir: File,
231
- permissionUtils: PermissionUtils,
232
- audioDataEncoder: AudioDataEncoder,
233
- eventSender: EventSender
234
- ): AudioRecorderManager {
235
- return instance ?: synchronized(this) {
236
- instance ?: AudioRecorderManager(
237
- context, filesDir, permissionUtils, audioDataEncoder, eventSender
238
- ).also { instance = it }
239
- }
240
- }
241
-
242
- fun destroy() {
243
- instance?.cleanup()
244
- instance = null
245
- }
246
- }
247
-
248
- private fun isOngoingCall(): Boolean {
249
- try {
250
- if (!permissionUtils.checkPhoneStatePermission()) {
251
- Log.w(Constants.TAG, "READ_PHONE_STATE permission not granted, cannot check call state")
252
- return false
253
- }
254
-
255
- val tm = telephonyManager
256
- if (tm == null) {
257
- Log.e(Constants.TAG, "TelephonyManager is null")
258
- return false
259
- }
260
-
261
- // Get audio manager state
262
- val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
263
- val audioMode = audioManager.mode
264
- val isMusicActive = audioManager.isMusicActive
265
-
266
- // Get current audio device info
267
- val currentRoute =
268
- audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull()?.type?.let { type ->
269
- when (type) {
270
- AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "SPEAKER"
271
- AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "EARPIECE"
272
- AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BLUETOOTH_SCO"
273
- AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "BLUETOOTH_A2DP"
274
- AudioDeviceInfo.TYPE_WIRED_HEADSET -> "WIRED_HEADSET"
275
- AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "WIRED_HEADPHONES"
276
- else -> "OTHER($type)"
277
- }
278
- } ?: "UNKNOWN"
279
-
280
- // Get communication device info
281
- val communicationDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
282
- audioManager.communicationDevice?.type?.let { type ->
283
- when (type) {
284
- AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BLUETOOTH_SCO"
285
- AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "SPEAKER"
286
- AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "EARPIECE"
287
- else -> "OTHER($type)"
288
- }
289
- } ?: "NONE"
290
- } else {
291
- @Suppress("DEPRECATION")
292
- if (audioManager.isBluetoothScoOn) "BLUETOOTH_SCO" else "NONE"
293
- }
294
-
295
- Log.d(Constants.TAG, """
296
- Audio State Check:
297
- - Audio Mode: ${getAudioModeString(audioMode)}
298
- - Music Active: $isMusicActive
299
- - Current Audio Route: $currentRoute
300
- - Communication Device: $communicationDevice
301
- """.trimIndent())
302
-
303
- // Check telephony state
304
- val callState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
305
- tm.callStateForSubscription
306
- } else {
307
- @Suppress("DEPRECATION")
308
- tm.callState
309
- }
310
-
311
- val isVoipCall = audioMode == AudioManager.MODE_IN_COMMUNICATION
312
- val isRegularCall = callState == TelephonyManager.CALL_STATE_OFFHOOK ||
313
- callState == TelephonyManager.CALL_STATE_RINGING
314
-
315
- Log.d(Constants.TAG, """
316
- Call State Check:
317
- - Telephony Call State: ${getCallStateString(callState)}
318
- - VoIP Call Detected: $isVoipCall
319
- - Regular Call Detected: $isRegularCall
320
- """.trimIndent())
321
-
322
- return isVoipCall || isRegularCall
323
-
324
- } catch (e: SecurityException) {
325
- Log.e(Constants.TAG, "SecurityException when checking call state", e)
326
- return false
327
- } catch (e: Exception) {
328
- Log.e(Constants.TAG, "Error checking call state", e)
329
- return false
330
- }
331
- }
332
-
333
- private fun getAudioModeString(mode: Int): String = when (mode) {
334
- AudioManager.MODE_NORMAL -> "MODE_NORMAL"
335
- AudioManager.MODE_RINGTONE -> "MODE_RINGTONE"
336
- AudioManager.MODE_IN_CALL -> "MODE_IN_CALL"
337
- AudioManager.MODE_IN_COMMUNICATION -> "MODE_IN_COMMUNICATION"
338
- else -> "MODE_UNKNOWN($mode)"
339
- }
340
-
341
- private fun getCallStateString(state: Int): String = when (state) {
342
- TelephonyManager.CALL_STATE_IDLE -> "CALL_STATE_IDLE"
343
- TelephonyManager.CALL_STATE_RINGING -> "CALL_STATE_RINGING"
344
- TelephonyManager.CALL_STATE_OFFHOOK -> "CALL_STATE_OFFHOOK"
345
- else -> "CALL_STATE_UNKNOWN($state)"
346
- }
347
-
348
- @RequiresApi(Build.VERSION_CODES.R)
349
- fun startRecording(options: Map<String, Any?>, promise: Promise) {
350
- try {
351
- // Initialize phone state listener
352
- initializePhoneStateListener()
353
-
354
- // Request audio focus
355
- if (!requestAudioFocus()) {
356
- promise.reject("AUDIO_FOCUS_ERROR", "Failed to obtain audio focus", null)
357
- return
358
- }
359
-
360
- Log.d(Constants.TAG, "Starting recording with options: $options")
361
-
362
- // Check permissions
363
- if (!checkPermissions(options, promise)) return
364
-
365
- // Check if already recording
366
- if (isRecording.get() && !isPaused.get()) {
367
- promise.reject("ALREADY_RECORDING", "Recording is already in progress", null)
368
- return
369
- }
370
-
371
- // Parse recording configuration
372
- val configResult = RecordingConfig.fromMap(options)
373
- if (configResult.isFailure) {
374
- promise.reject(
375
- "INVALID_CONFIG",
376
- configResult.exceptionOrNull()?.message ?: "Invalid configuration",
377
- configResult.exceptionOrNull()
378
- )
379
- return
380
- }
381
-
382
- val (tempRecordingConfig, audioFormatInfo) = configResult.getOrNull()!!
383
- recordingConfig = tempRecordingConfig
384
- audioFormat = audioFormatInfo.format
385
- mimeType = audioFormatInfo.mimeType
386
-
387
- if (!initializeAudioFormat(promise)) return
388
-
389
- if (!initializeBufferSize(promise)) return
390
-
391
- if (!initializeAudioRecord(promise)) return
392
-
393
- if (recordingConfig.enableCompressedOutput && !initializeCompressedRecorder(
394
- if (recordingConfig.compressedFormat == "aac") "aac" else "opus",
395
- promise
396
- )) return
397
-
398
- if (!initializeRecordingResources(audioFormatInfo.fileExtension, promise)) return
399
-
400
- if (!startRecordingProcess(promise)) return
401
-
402
- // Start compressed recording if enabled
403
- try {
404
- compressedRecorder?.start()
405
- } catch (e: Exception) {
406
- Log.e(Constants.TAG, "Failed to start compressed recording", e)
407
- cleanup()
408
- promise.reject("COMPRESSED_START_FAILED", "Failed to start compressed recording", e)
409
- return
410
- }
411
-
412
- // Return success result with both file URIs
413
- val result = bundleOf(
414
- "fileUri" to audioFile?.toURI().toString(),
415
- "channels" to recordingConfig.channels,
416
- "bitDepth" to AudioFormatUtils.getBitDepth(recordingConfig.encoding),
417
- "sampleRate" to recordingConfig.sampleRate,
418
- "mimeType" to mimeType,
419
- "compression" to if (compressedFile != null) bundleOf(
420
- "mimeType" to if (recordingConfig.compressedFormat == "aac") "audio/aac" else "audio/opus",
421
- "bitrate" to recordingConfig.compressedBitRate,
422
- "format" to recordingConfig.compressedFormat,
423
- "size" to 0,
424
- "compressedFileUri" to compressedFile?.toURI().toString()
425
- ) else null
426
- )
427
- promise.resolve(result)
428
-
429
- } catch (e: Exception) {
430
- releaseAudioFocus()
431
- telephonyManager?.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
432
- promise.reject("UNEXPECTED_ERROR", "Unexpected error: ${e.message}", e)
433
- }
434
- }
435
-
436
- private fun isAudioFormatSupported(sampleRate: Int, channels: Int, format: Int): Boolean {
437
- if (!permissionUtils.checkRecordingPermission()) {
438
- throw SecurityException("Recording permission has not been granted")
439
- }
440
-
441
- val channelConfig =
442
- if (channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO
443
- val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, format)
444
-
445
- if (bufferSize <= 0) {
446
- return false
447
- }
448
-
449
- val audioRecord = AudioRecord(
450
- MediaRecorder.AudioSource.MIC,
451
- sampleRate,
452
- channelConfig,
453
- format,
454
- bufferSize
455
- )
456
-
457
- val isSupported = audioRecord.state == AudioRecord.STATE_INITIALIZED
458
- if (isSupported) {
459
- val testBuffer = ByteArray(bufferSize)
460
- audioRecord.startRecording()
461
- val testRead = audioRecord.read(testBuffer, 0, bufferSize)
462
- audioRecord.stop()
463
- if (testRead < 0) {
464
- return false
465
- }
466
- }
467
-
468
- audioRecord.release()
469
- return isSupported
470
- }
471
-
472
- private fun checkPermissions(options: Map<String, Any?>, promise: Promise): Boolean {
473
- if (!permissionUtils.checkRecordingPermission()) {
474
- promise.reject(
475
- "PERMISSION_DENIED",
476
- "Recording permission has not been granted",
477
- null
478
- )
479
- return false
480
- }
481
-
482
- if (!permissionUtils.checkPhoneStatePermission()) {
483
- Log.w(Constants.TAG, "READ_PHONE_STATE permission not granted, phone call interruption handling will be disabled")
484
- // Don't reject here, just log warning as this is optional
485
- }
486
-
487
- if (options["showNotification"] as? Boolean == true && !permissionUtils.checkNotificationPermission()) {
488
- promise.reject(
489
- "NOTIFICATION_PERMISSION_DENIED",
490
- "Notification permission has not been granted",
491
- null
492
- )
493
- return false
494
- }
495
- return true
496
- }
497
-
498
-
499
- private fun initializeAudioFormat(promise: Promise): Boolean {
500
- if (!isAudioFormatSupported(
501
- recordingConfig.sampleRate,
502
- recordingConfig.channels,
503
- audioFormat
504
- )
505
- ) {
506
- Log.e(Constants.TAG, "Selected audio format not supported, falling back to 16-bit PCM")
507
- audioFormat = AudioFormat.ENCODING_PCM_16BIT
508
-
509
- if (!isAudioFormatSupported(
510
- recordingConfig.sampleRate,
511
- recordingConfig.channels,
512
- audioFormat
513
- )
514
- ) {
515
- promise.reject(
516
- "INITIALIZATION_FAILED",
517
- "Failed to initialize audio recorder with any supported format",
518
- null
519
- )
520
- return false
521
- }
522
- recordingConfig = recordingConfig.copy(encoding = "pcm_16bit")
523
- mimeType = "audio/wav"
524
- }
525
- return true
526
- }
527
-
528
- private fun initializeBufferSize(promise: Promise): Boolean {
529
- try {
530
- val channelConfig = if (recordingConfig.channels == 1) {
531
- AudioFormat.CHANNEL_IN_MONO
532
- } else {
533
- AudioFormat.CHANNEL_IN_STEREO
534
- }
535
-
536
- bufferSizeInBytes = AudioRecord.getMinBufferSize(
537
- recordingConfig.sampleRate,
538
- channelConfig,
539
- audioFormat
540
- )
541
-
542
- when {
543
- bufferSizeInBytes == AudioRecord.ERROR -> {
544
- Log.e(Constants.TAG, "Error getting minimum buffer size: ERROR")
545
- promise.reject(
546
- "BUFFER_SIZE_ERROR",
547
- "Failed to get minimum buffer size: generic error",
548
- null
549
- )
550
- return false
551
- }
552
- bufferSizeInBytes == AudioRecord.ERROR_BAD_VALUE -> {
553
- Log.e(Constants.TAG, "Error getting minimum buffer size: BAD_VALUE")
554
- promise.reject(
555
- "BUFFER_SIZE_ERROR",
556
- "Failed to get minimum buffer size: invalid parameters",
557
- null
558
- )
559
- return false
560
- }
561
- bufferSizeInBytes <= 0 -> {
562
- Log.e(Constants.TAG, "Invalid buffer size: $bufferSizeInBytes")
563
- promise.reject(
564
- "BUFFER_SIZE_ERROR",
565
- "Failed to get valid buffer size",
566
- null
567
- )
568
- return false
569
- }
570
- else -> {
571
- Log.d(Constants.TAG, "AudioFormat: $audioFormat, BufferSize: $bufferSizeInBytes")
572
- return true
573
- }
574
- }
575
- } catch (e: Exception) {
576
- Log.e(Constants.TAG, "Failed to initialize buffer size", e)
577
- promise.reject(
578
- "BUFFER_SIZE_ERROR",
579
- "Failed to initialize buffer size: ${e.message}",
580
- e
581
- )
582
- return false
583
- }
584
- }
585
-
586
-
587
- private fun initializeAudioRecord(promise: Promise): Boolean {
588
- if (!permissionUtils.checkRecordingPermission()) {
589
- promise.reject(
590
- "PERMISSION_DENIED",
591
- "Recording permission has not been granted",
592
- null
593
- )
594
- return false
595
- }
596
-
597
- try {
598
- if (audioRecord == null || !isPaused.get()) {
599
- Log.d(Constants.TAG, "Initializing AudioRecord with format: $audioFormat, BufferSize: $bufferSizeInBytes")
600
-
601
- audioRecord = AudioRecord(
602
- MediaRecorder.AudioSource.MIC,
603
- recordingConfig.sampleRate,
604
- if (recordingConfig.channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO,
605
- audioFormat,
606
- bufferSizeInBytes
607
- )
608
-
609
- if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
610
- promise.reject(
611
- "INITIALIZATION_FAILED",
612
- "Failed to initialize the audio recorder",
613
- null
614
- )
615
- return false
616
- }
617
- }
618
- return true
619
-
620
- } catch (e: SecurityException) {
621
- Log.e(Constants.TAG, "Security exception while initializing AudioRecord", e)
622
- promise.reject(
623
- "PERMISSION_DENIED",
624
- "Recording permission denied: ${e.message}",
625
- e
626
- )
627
- return false
628
- } catch (e: Exception) {
629
- Log.e(Constants.TAG, "Failed to initialize AudioRecord", e)
630
- promise.reject(
631
- "INITIALIZATION_FAILED",
632
- "Failed to initialize the audio recorder: ${e.message}",
633
- e
634
- )
635
- return false
636
- }
637
- }
638
-
639
- private fun initializeRecordingResources(fileExtension: String, promise: Promise): Boolean {
640
- try {
641
- streamUuid = java.util.UUID.randomUUID().toString()
642
- audioFile = createRecordingFile(recordingConfig)
643
- totalDataSize = 0
644
-
645
- FileOutputStream(audioFile, true).use { fos ->
646
- audioFileHandler.writeWavHeader(
647
- fos,
648
- recordingConfig.sampleRate,
649
- recordingConfig.channels,
650
- AudioFormatUtils.getBitDepth(recordingConfig.encoding)
651
- )
652
- }
653
-
654
- if (recordingConfig.showNotification) {
655
- notificationManager.initialize(recordingConfig)
656
- notificationManager.startUpdates(System.currentTimeMillis())
657
- AudioRecordingService.startService(context)
658
- }
659
-
660
- acquireWakeLock()
661
- audioProcessor.resetCumulativeAmplitudeRange()
662
- return true
663
-
664
- } catch (e: IOException) {
665
- releaseWakeLock()
666
- promise.reject("FILE_CREATION_FAILED", "Failed to create the audio file", e)
667
- return false
668
- } catch (e: Exception) {
669
- releaseWakeLock()
670
- Log.e(Constants.TAG, "Unexpected error in startRecording", e)
671
- promise.reject("UNEXPECTED_ERROR", "Unexpected error: ${e.message}", e)
672
- return false
673
- }
674
- }
675
-
676
- private fun startRecordingProcess(promise: Promise): Boolean {
677
- try {
678
- // Add detailed logging of recording configuration
679
- Log.d(Constants.TAG, """
680
- Starting audio recording with configuration:
681
- - Sample Rate: ${recordingConfig.sampleRate} Hz
682
- - Channels: ${recordingConfig.channels}
683
- - Encoding: ${recordingConfig.encoding}
684
- - Data Emission Interval: ${recordingConfig.interval}ms
685
- - Analysis Interval: ${recordingConfig.intervalAnalysis}ms
686
- - Processing Enabled: ${recordingConfig.enableProcessing}
687
- - Keep Awake: ${recordingConfig.keepAwake}
688
- - Show Notification: ${recordingConfig.showNotification}
689
- - Show Waveform: ${recordingConfig.showWaveformInNotification}
690
- - Compressed Output: ${recordingConfig.enableCompressedOutput}
691
- ${if (recordingConfig.enableCompressedOutput) """
692
- - Compressed Format: ${recordingConfig.compressedFormat}
693
- - Compressed Bitrate: ${recordingConfig.compressedBitRate}
694
- """.trimIndent() else ""}
695
- - Auto Resume: ${recordingConfig.autoResumeAfterInterruption}
696
- - Output Directory: ${recordingConfig.outputDirectory ?: "default"}
697
- - Filename: ${recordingConfig.filename ?: "auto-generated"}
698
- - Features: ${recordingConfig.features.entries.joinToString { "${it.key}=${it.value}" }}
699
- """.trimIndent())
700
-
701
- audioRecord?.startRecording()
702
- isPaused.set(false)
703
- isRecording.set(true)
704
- isFirstChunk = true
705
-
706
- if (!isPaused.get()) {
707
- recordingStartTime = System.currentTimeMillis()
708
- }
709
-
710
- recordingThread = Thread { recordingProcess() }.apply { start() }
711
-
712
- // Start service if keepAwake is true, regardless of notification settings
713
- if (recordingConfig.keepAwake) {
714
- AudioRecordingService.startService(context)
715
- }
716
-
717
- return true
718
-
719
- } catch (e: Exception) {
720
- Log.e(Constants.TAG, "Failed to start recording", e)
721
- cleanup()
722
- promise.reject("START_FAILED", "Failed to start recording: ${e.message}", e)
723
- return false
724
- }
725
- }
726
-
727
- fun stopRecording(promise: Promise) {
728
- synchronized(audioRecordLock) {
729
- if (!isRecording.get()) {
730
- Log.e(Constants.TAG, "Recording is not active")
731
- promise.reject("NOT_RECORDING", "Recording is not active", null)
732
- return
733
- }
734
-
735
- try {
736
- if (isPaused.get()) {
737
- val remainingData = ByteArray(bufferSizeInBytes)
738
- val bytesRead = audioRecord?.read(remainingData, 0, bufferSizeInBytes) ?: -1
739
- if (bytesRead > 0) {
740
- emitAudioData(remainingData.copyOfRange(0, bytesRead), bytesRead)
741
- }
742
- }
743
-
744
- if (recordingConfig.showNotification) {
745
- notificationManager.stopUpdates()
746
- AudioRecordingService.stopService(context)
747
- }
748
-
749
- isRecording.set(false)
750
- recordingThread?.join(1000)
751
-
752
- val audioData = ByteArray(bufferSizeInBytes)
753
- val bytesRead = audioRecord?.read(audioData, 0, bufferSizeInBytes) ?: -1
754
- Log.d(Constants.TAG, "Last Read $bytesRead bytes")
755
- if (bytesRead > 0) {
756
- emitAudioData(audioData.copyOfRange(0, bytesRead), bytesRead)
757
- }
758
-
759
- Log.d(Constants.TAG, "Stopping recording state = ${audioRecord?.state}")
760
- if (audioRecord != null && audioRecord!!.state == AudioRecord.STATE_INITIALIZED) {
761
- Log.d(Constants.TAG, "Stopping AudioRecord")
762
- audioRecord!!.stop()
763
- }
764
-
765
- cleanup()
766
- } catch (e: IllegalStateException) {
767
- Log.e(Constants.TAG, "Error reading from AudioRecord", e)
768
- } finally {
769
- releaseWakeLock()
770
- audioRecord?.release()
771
- }
772
-
773
- try {
774
- AudioProcessor.resetUniqueIdCounter()
775
- audioProcessor.resetCumulativeAmplitudeRange()
776
-
777
- val fileSize = audioFile?.length() ?: 0
778
- Log.d(Constants.TAG, "WAV File validation - Size: $fileSize bytes, Path: ${audioFile?.absolutePath}")
779
-
780
- val dataFileSize = fileSize - 44 // Subtract header size
781
- val byteRate =
782
- recordingConfig.sampleRate * recordingConfig.channels * when (recordingConfig.encoding) {
783
- "pcm_8bit" -> 1
784
- "pcm_16bit" -> 2
785
- "pcm_32bit" -> 4
786
- else -> 2 // Default to 2 bytes per sample if the encoding is not recognized
787
- }
788
- val duration = if (byteRate > 0) (dataFileSize * 1000 / byteRate) else 0
789
-
790
- compressedRecorder?.apply {
791
- stop()
792
- release()
793
- }
794
- compressedRecorder = null
795
-
796
- // Log compressed file status if enabled
797
- if (recordingConfig.enableCompressedOutput) {
798
- val compressedSize = compressedFile?.length() ?: 0
799
- Log.d(Constants.TAG, "Compressed File validation - Size: $compressedSize bytes, Path: ${compressedFile?.absolutePath}")
800
- }
801
-
802
- val result = bundleOf(
803
- "fileUri" to audioFile?.toURI().toString(),
804
- "filename" to audioFile?.name,
805
- "durationMs" to duration,
806
- "channels" to recordingConfig.channels,
807
- "bitDepth" to AudioFormatUtils.getBitDepth(recordingConfig.encoding),
808
- "sampleRate" to recordingConfig.sampleRate,
809
- "size" to fileSize,
810
- "mimeType" to mimeType,
811
- "createdAt" to System.currentTimeMillis(),
812
- "compression" to if (compressedFile != null) bundleOf(
813
- "size" to compressedFile?.length(),
814
- "mimeType" to if (recordingConfig.compressedFormat == "aac") "audio/aac" else "audio/opus",
815
- "bitrate" to recordingConfig.compressedBitRate,
816
- "format" to recordingConfig.compressedFormat,
817
- "compressedFileUri" to compressedFile?.toURI().toString()
818
- ) else null
819
- )
820
- promise.resolve(result)
821
-
822
- // Reset the timing variables
823
- isRecording.set(false)
824
- isPaused.set(false)
825
- totalRecordedTime = 0
826
- pausedDuration = 0
827
- } catch (e: Exception) {
828
- Log.d(Constants.TAG, "Failed to stop recording", e)
829
- promise.reject("STOP_FAILED", "Failed to stop recording", e)
830
- } finally {
831
- audioRecord = null
832
- }
833
- }
834
- }
835
-
836
- fun resumeRecording(promise: Promise) {
837
- if (!isPaused.get()) {
838
- promise.reject("NOT_PAUSED", "Recording is not paused", null)
839
- return
840
- }
841
-
842
- if (isOngoingCall()) {
843
- promise.reject("ONGOING_CALL", "Cannot resume recording during an ongoing call", null)
844
- return
845
- }
846
-
847
- try {
848
- if (recordingConfig.showNotification) {
849
- notificationManager.resumeUpdates()
850
- }
851
-
852
- acquireWakeLock()
853
- pausedDuration += System.currentTimeMillis() - lastPauseTime
854
- isPaused.set(false)
855
-
856
- audioRecord?.startRecording()
857
- compressedRecorder?.resume()
858
-
859
- promise.resolve("Recording resumed")
860
- } catch (e: Exception) {
861
- releaseWakeLock()
862
- promise.reject("RESUME_FAILED", "Failed to resume recording", e)
863
- }
864
- }
865
-
866
- fun pauseRecording(promise: Promise) {
867
- if (isRecording.get() && !isPaused.get()) {
868
- audioRecord?.stop()
869
- compressedRecorder?.pause()
870
-
871
- lastPauseTime = System.currentTimeMillis()
872
- isPaused.set(true)
873
-
874
- if (recordingConfig.showNotification) {
875
- notificationManager.pauseUpdates()
876
- }
877
-
878
- releaseWakeLock()
879
- promise.resolve("Recording paused")
880
- } else {
881
- promise.reject(
882
- "NOT_RECORDING_OR_ALREADY_PAUSED",
883
- "Recording is either not active or already paused",
884
- null
885
- )
886
- }
887
- }
888
-
889
- fun getStatus(): Bundle {
890
- synchronized(audioRecordLock) {
891
- // Check if service is actually running
892
- val isServiceRunning = context.let { ctx ->
893
- val manager = ctx.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
894
- manager?.getRunningServices(Integer.MAX_VALUE)
895
- ?.any { it.service.className == AudioRecordingService::class.java.name }
896
- } ?: false
897
-
898
- // If service is running but we think we're not recording, clean up
899
- if (isServiceRunning && !isRecording.get()) {
900
- Log.d(Constants.TAG, "Detected orphaned recording service, cleaning up...")
901
- cleanup()
902
- AudioRecordingService.stopService(context)
903
- }
904
-
905
- if (!isRecording.get()) {
906
- Log.d(Constants.TAG, "Not recording --- skip status with default values")
907
- return bundleOf(
908
- "isRecording" to false,
909
- "isPaused" to false,
910
- "mime" to mimeType,
911
- "size" to 0,
912
- "interval" to (recordingConfig?.interval ?: 0)
913
- )
914
- }
915
-
916
- val fileSize = audioFile?.length() ?: 0
917
- val duration = when (mimeType) {
918
- "audio/wav" -> {
919
- val dataFileSize = fileSize - Constants.WAV_HEADER_SIZE
920
- val byteRate = recordingConfig.sampleRate * recordingConfig.channels *
921
- (if (recordingConfig.encoding == "pcm_8bit") 8 else 16) / 8
922
- if (byteRate > 0) dataFileSize * 1000 / byteRate else 0
923
- }
924
- else -> totalRecordedTime
925
- }
926
-
927
- val compressionBundle = if (recordingConfig.enableCompressedOutput) {
928
- bundleOf(
929
- "size" to (compressedFile?.length() ?: 0),
930
- "mimeType" to if (recordingConfig.compressedFormat == "aac") "audio/aac" else "audio/opus",
931
- "bitrate" to recordingConfig.compressedBitRate,
932
- "format" to recordingConfig.compressedFormat
933
- )
934
- } else null
935
-
936
- return bundleOf(
937
- "durationMs" to duration,
938
- "isRecording" to isRecording.get(),
939
- "isPaused" to isPaused.get(),
940
- "mimeType" to mimeType,
941
- "size" to totalDataSize,
942
- "interval" to recordingConfig.interval,
943
- "compression" to compressionBundle
944
- )
945
- }
946
- }
947
-
948
- private fun acquireWakeLock() {
949
- if (recordingConfig.keepAwake && wakeLock == null) {
950
- try {
951
- val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
952
- wakeLock = powerManager.newWakeLock(
953
- PowerManager.PARTIAL_WAKE_LOCK,
954
- "AudioRecorderManager::RecordingWakeLock"
955
- ).apply {
956
- setReferenceCounted(false)
957
- acquire()
958
- }
959
- wasWakeLockEnabled = true
960
- Log.d(Constants.TAG, "Wake lock acquired")
961
- } catch (e: Exception) {
962
- Log.e(Constants.TAG, "Failed to acquire wake lock", e)
963
- }
964
- }
965
- }
966
-
967
-
968
- private fun releaseWakeLock() {
969
- try {
970
- wakeLock?.let {
971
- if (it.isHeld) {
972
- it.release()
973
- Log.d(Constants.TAG, "Wake lock released")
974
- }
975
- wakeLock = null
976
- wasWakeLockEnabled = false
977
- }
978
- } catch (e: Exception) {
979
- Log.e(Constants.TAG, "Failed to release wake lock", e)
980
- }
981
- }
982
-
983
- fun listAudioFiles(promise: Promise) {
984
- val fileList =
985
- filesDir.list()?.filter { it.endsWith(".wav") }?.map { File(filesDir, it).absolutePath }
986
- ?: listOf()
987
- promise.resolve(fileList)
988
- }
989
-
990
- fun clearAudioStorage() {
991
- audioFileHandler.clearAudioStorage()
992
- }
993
-
994
- private fun recordingProcess() {
995
- try {
996
- Log.i(Constants.TAG, "Starting recording process...")
997
- FileOutputStream(audioFile, true).use { fos ->
998
- // Write audio data directly to the file
999
- val audioData = ByteArray(bufferSizeInBytes)
1000
- Log.d(Constants.TAG, "Entering recording loop")
1001
-
1002
- // Buffer to accumulate data
1003
- val accumulatedAudioData = ByteArrayOutputStream()
1004
- val accumulatedAnalysisData = ByteArrayOutputStream() // Separate buffer for analysis
1005
- audioFileHandler.writeWavHeader(
1006
- accumulatedAudioData,
1007
- recordingConfig.sampleRate,
1008
- recordingConfig.channels,
1009
- when (recordingConfig.encoding) {
1010
- "pcm_8bit" -> 8
1011
- "pcm_16bit" -> 16
1012
- "pcm_32bit" -> 32
1013
- else -> 16 // Default to 16 if the encoding is not recognized
1014
- }
1015
- )
1016
-
1017
- // Initialize timing variables
1018
- var lastEmitTime = System.currentTimeMillis()
1019
- var lastEmissionTimeAnalysis = System.currentTimeMillis()
1020
- var isFirstAnalysis = true
1021
- var shouldProcessAnalysis = false
1022
-
1023
- // Debug log for intervals
1024
- Log.d(Constants.TAG, """
1025
- Recording process started with intervals:
1026
- - Data emission interval: ${recordingConfig.interval}ms
1027
- - Analysis interval: ${recordingConfig.intervalAnalysis}ms
1028
- - Buffer size: $bufferSizeInBytes bytes
1029
- """.trimIndent())
1030
-
1031
- // Recording loop
1032
- while (isRecording.get() && !Thread.currentThread().isInterrupted) {
1033
- if (isPaused.get()) {
1034
- Thread.sleep(100) // Add small delay when paused
1035
- continue
1036
- }
1037
-
1038
- val currentTime = System.currentTimeMillis()
1039
- val timeSinceLastAnalysis = currentTime - lastEmissionTimeAnalysis
1040
- shouldProcessAnalysis = recordingConfig.enableProcessing &&
1041
- (isFirstAnalysis || timeSinceLastAnalysis >= recordingConfig.intervalAnalysis)
1042
-
1043
- val bytesRead = synchronized(audioRecordLock) {
1044
- audioRecord?.let {
1045
- if (it.state != AudioRecord.STATE_INITIALIZED) {
1046
- Log.e(Constants.TAG, "AudioRecord not initialized")
1047
- return@let -1
1048
- }
1049
- it.read(audioData, 0, bufferSizeInBytes).also { bytes ->
1050
- if (bytes < 0) {
1051
- Log.e(Constants.TAG, "AudioRecord read error: $bytes")
1052
- }
1053
- }
1054
- } ?: -1 // Handle null case
1055
- }
1056
-
1057
- if (bytesRead > 0) {
1058
- fos.write(audioData, 0, bytesRead)
1059
- totalDataSize += bytesRead
1060
-
1061
- accumulatedAudioData.write(audioData, 0, bytesRead)
1062
-
1063
- // Handle regular audio data emission
1064
- if (currentTime - lastEmitTime >= recordingConfig.interval) {
1065
- emitAudioData(
1066
- accumulatedAudioData.toByteArray(),
1067
- accumulatedAudioData.size()
1068
- )
1069
- lastEmitTime = currentTime
1070
- accumulatedAudioData.reset() // Clear the accumulator
1071
- }
1072
-
1073
- // Handle analysis emission separately
1074
- if (shouldProcessAnalysis) {
1075
- val analysisDataSize = accumulatedAnalysisData.size()
1076
- Log.d(Constants.TAG, """
1077
- Processing analysis data:
1078
- - Time since last: ${currentTime - lastEmissionTimeAnalysis}ms
1079
- - Configured interval: ${recordingConfig.intervalAnalysis}ms
1080
- - Accumulated size: $analysisDataSize bytes
1081
- - Is first analysis: $isFirstAnalysis
1082
- """.trimIndent())
1083
-
1084
- if (analysisDataSize > 0) {
1085
- // Add this check to enforce minimum interval
1086
- if (isFirstAnalysis || (currentTime - lastEmissionTimeAnalysis) >= recordingConfig.intervalAnalysis) {
1087
- // Process and emit analysis data
1088
- val analysisData = audioProcessor.processAudioData(
1089
- accumulatedAnalysisData.toByteArray(),
1090
- recordingConfig
1091
- )
1092
-
1093
- Log.d(Constants.TAG, """
1094
- Analysis data details:
1095
- - Raw data size: ${accumulatedAnalysisData.size()} bytes
1096
- """.trimIndent())
1097
-
1098
- mainHandler.post {
1099
- try {
1100
- eventSender.sendExpoEvent(
1101
- Constants.AUDIO_ANALYSIS_EVENT_NAME,
1102
- analysisData.toBundle()
1103
- )
1104
- } catch (e: Exception) {
1105
- Log.e(Constants.TAG, "Failed to send audio analysis event", e)
1106
- }
1107
- }
1108
-
1109
- lastEmissionTimeAnalysis = currentTime
1110
- accumulatedAnalysisData.reset() // Clear the analysis accumulator
1111
- isFirstAnalysis = false
1112
- }
1113
- }
1114
- }
1115
- }
1116
- }
1117
- }
1118
- // Update the WAV header to reflect the actual data size
1119
- audioFile?.let { file ->
1120
- audioFileHandler.updateWavHeader(file)
1121
- }
1122
-
1123
- } catch (e: Exception) {
1124
- // Ensure wake lock is released if the thread is interrupted
1125
- if (!isPaused.get()) {
1126
- releaseWakeLock()
1127
- }
1128
- Log.e(Constants.TAG, "Error in recording process", e)
1129
- }
1130
- }
1131
-
1132
- private fun emitAudioData(audioData: ByteArray, length: Int) {
1133
- val encodedBuffer = audioDataEncoder.encodeToBase64(audioData)
1134
-
1135
- val fileSize = audioFile?.length() ?: 0
1136
- val from = lastEmittedSize
1137
- lastEmittedSize = fileSize
1138
-
1139
- // Calculate position in milliseconds
1140
- val positionInMs =
1141
- (from * 1000) / (recordingConfig.sampleRate * recordingConfig.channels * (if (recordingConfig.encoding == "pcm_8bit") 8 else 16) / 8)
1142
-
1143
- val compressionBundle = if (recordingConfig.enableCompressedOutput) {
1144
- val compressedSize = compressedFile?.length() ?: 0
1145
- val eventDataSize = compressedSize - lastEmittedCompressedSize
1146
-
1147
- // Read the new compressed data
1148
- val compressedData = if (eventDataSize > 0) {
1149
- try {
1150
- compressedFile?.inputStream()?.use { input ->
1151
- input.skip(lastEmittedCompressedSize)
1152
- val buffer = ByteArray(eventDataSize.toInt())
1153
- input.read(buffer)
1154
- audioDataEncoder.encodeToBase64(buffer)
1155
- }
1156
- } catch (e: Exception) {
1157
- Log.e(Constants.TAG, "Failed to read compressed data", e)
1158
- null
1159
- }
1160
- } else null
1161
-
1162
- lastEmittedCompressedSize = compressedSize
1163
-
1164
- bundleOf(
1165
- "position" to positionInMs,
1166
- "fileUri" to compressedFile?.toURI().toString(),
1167
- "eventDataSize" to eventDataSize,
1168
- "totalSize" to compressedSize,
1169
- "data" to compressedData
1170
- )
1171
- } else null
1172
-
1173
- mainHandler.post {
1174
- try {
1175
- eventSender.sendExpoEvent(
1176
- Constants.AUDIO_EVENT_NAME, bundleOf(
1177
- "fileUri" to audioFile?.toURI().toString(),
1178
- "lastEmittedSize" to from,
1179
- "encoded" to encodedBuffer,
1180
- "deltaSize" to length,
1181
- "position" to positionInMs,
1182
- "mimeType" to mimeType,
1183
- "totalSize" to fileSize,
1184
- "streamUuid" to streamUuid,
1185
- "compression" to compressionBundle
1186
- )
1187
- )
1188
- } catch (e: Exception) {
1189
- Log.e(Constants.TAG, "Failed to send event", e)
1190
- }
1191
- }
1192
-
1193
- if (recordingConfig.enableProcessing) {
1194
- processAudioData(audioData)
1195
- }
1196
- }
1197
-
1198
- private fun convertByteArrayToFloatArray(audioData: ByteArray): FloatArray {
1199
- val floatArray = FloatArray(audioData.size / 2) // Assuming 16-bit PCM
1200
- val buffer = ByteBuffer.wrap(audioData).order(ByteOrder.LITTLE_ENDIAN)
1201
- for (i in floatArray.indices) {
1202
- floatArray[i] = buffer.short.toFloat()
1203
- }
1204
- return floatArray
1205
- }
1206
-
1207
- private fun processAudioData(audioData: ByteArray) {
1208
- // Skip the WAV header only for the first chunk
1209
- val dataToProcess = if (isFirstChunk && audioData.size > Constants.WAV_HEADER_SIZE) {
1210
- audioData.copyOfRange(Constants.WAV_HEADER_SIZE, audioData.size)
1211
- } else {
1212
- audioData
1213
- }
1214
-
1215
- // Accumulate data for analysis
1216
- if (recordingConfig.enableProcessing) {
1217
- synchronized(analysisBuffer) {
1218
- analysisBuffer.write(dataToProcess)
1219
- }
1220
-
1221
- val currentTime = SystemClock.elapsedRealtime()
1222
- if (isFirstAnalysis || (currentTime - lastEmissionTimeAnalysis) >= recordingConfig.intervalAnalysis) {
1223
- synchronized(analysisBuffer) {
1224
- if (analysisBuffer.size() > 0) {
1225
- val analysisData = audioProcessor.processAudioData(
1226
- analysisBuffer.toByteArray(),
1227
- recordingConfig
1228
- )
1229
-
1230
- mainHandler.post {
1231
- eventSender.sendExpoEvent(
1232
- Constants.AUDIO_ANALYSIS_EVENT_NAME,
1233
- analysisData.toBundle()
1234
- )
1235
- }
1236
-
1237
- // Reset buffer after processing
1238
- analysisBuffer.reset()
1239
- lastEmissionTimeAnalysis = currentTime
1240
- isFirstAnalysis = false
1241
- }
1242
- }
1243
- }
1244
- }
1245
-
1246
- // Only update notification if needed
1247
- if (recordingConfig.showNotification && recordingConfig.showWaveformInNotification) {
1248
- val floatArray = convertByteArrayToFloatArray(audioData)
1249
- notificationManager.updateNotification(floatArray)
1250
- }
1251
-
1252
- // Reset isFirstChunk after processing
1253
- isFirstChunk = false
1254
- }
1255
-
1256
- fun cleanup() {
1257
- synchronized(audioRecordLock) {
1258
- try {
1259
- if (isRecording.get()) {
1260
- audioRecord?.stop()
1261
- compressedRecorder?.stop()
1262
- compressedRecorder?.release()
1263
- }
1264
-
1265
- isRecording.set(false)
1266
- isPaused.set(false)
1267
-
1268
- if (recordingConfig.showNotification) {
1269
- notificationManager.stopUpdates()
1270
- AudioRecordingService.stopService(context)
1271
- }
1272
-
1273
- releaseWakeLock()
1274
- releaseAudioFocus()
1275
- audioRecord?.release()
1276
- audioRecord = null
1277
-
1278
- // Reset all state
1279
- totalRecordedTime = 0
1280
- pausedDuration = 0
1281
- lastEmittedSize = 0
1282
- recordingStartTime = 0
1283
-
1284
- // Update the WAV header if needed
1285
- audioFile?.let { file ->
1286
- audioFileHandler.updateWavHeader(file)
1287
- }
1288
-
1289
- // Send event to notify that recording was stopped
1290
- eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1291
- "reason" to "recordingStopped",
1292
- "isPaused" to false
1293
- ))
1294
- } catch (e: Exception) {
1295
- Log.e(Constants.TAG, "Error during cleanup", e)
1296
- }
1297
- }
1298
- }
1299
-
1300
- @RequiresApi(Build.VERSION_CODES.Q)
1301
- private fun initializeCompressedRecorder(fileExtension: String, promise: Promise): Boolean {
1302
- try {
1303
- // Pass true to indicate this is a compressed file
1304
- compressedFile = createRecordingFile(recordingConfig, isCompressed = true)
1305
-
1306
- compressedRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
1307
- MediaRecorder(context)
1308
- } else {
1309
- @Suppress("DEPRECATION")
1310
- MediaRecorder()
1311
- }
1312
-
1313
- compressedRecorder?.apply {
1314
- setAudioSource(MediaRecorder.AudioSource.MIC)
1315
- setOutputFormat(if (recordingConfig.compressedFormat == "aac")
1316
- MediaRecorder.OutputFormat.AAC_ADTS
1317
- else MediaRecorder.OutputFormat.OGG)
1318
- setAudioEncoder(if (recordingConfig.compressedFormat == "aac")
1319
- MediaRecorder.AudioEncoder.AAC
1320
- else MediaRecorder.AudioEncoder.OPUS)
1321
- setAudioChannels(recordingConfig.channels)
1322
- setAudioSamplingRate(recordingConfig.sampleRate)
1323
- setAudioEncodingBitRate(recordingConfig.compressedBitRate)
1324
- setOutputFile(compressedFile?.absolutePath)
1325
- prepare()
1326
- }
1327
- return true
1328
- } catch (e: Exception) {
1329
- Log.e(Constants.TAG, "Failed to initialize compressed recorder", e)
1330
- promise.reject("COMPRESSED_INIT_FAILED", "Failed to initialize compressed recorder", e)
1331
- return false
1332
- }
1333
- }
1334
-
1335
- @SuppressLint("NewApi")
1336
- private fun requestAudioFocus(): Boolean {
1337
- audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
1338
- when (focusChange) {
1339
- AudioManager.AUDIOFOCUS_LOSS,
1340
- AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
1341
- if (isRecording.get() && !isPaused.get()) {
1342
- mainHandler.post {
1343
- pauseRecording(object : Promise {
1344
- override fun resolve(value: Any?) {
1345
- isPaused.set(true)
1346
- eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1347
- "reason" to "audioFocusLoss",
1348
- "isPaused" to true
1349
- ))
1350
- }
1351
- override fun reject(code: String, message: String?, cause: Throwable?) {
1352
- Log.e(Constants.TAG, "Failed to pause recording on audio focus loss")
1353
- }
1354
- })
1355
- }
1356
- }
1357
- }
1358
- AudioManager.AUDIOFOCUS_GAIN -> {
1359
- if (isRecording.get() && isPaused.get() && recordingConfig.autoResumeAfterInterruption) {
1360
- mainHandler.post {
1361
- resumeRecording(object : Promise {
1362
- override fun resolve(value: Any?) {
1363
- eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1364
- "reason" to "audioFocusGain",
1365
- "isPaused" to false
1366
- ))
1367
- }
1368
- override fun reject(code: String, message: String?, cause: Throwable?) {
1369
- Log.e(Constants.TAG, "Failed to resume recording on audio focus gain")
1370
- }
1371
- })
1372
- }
1373
- }
1374
- }
1375
- }
1376
- }
1377
-
1378
- val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1379
- val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
1380
- .setAudioAttributes(AudioAttributes.Builder()
1381
- .setUsage(AudioAttributes.USAGE_MEDIA)
1382
- .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
1383
- .build())
1384
- .setOnAudioFocusChangeListener(audioFocusChangeListener!!)
1385
- .build()
1386
- audioFocusRequest = focusRequest
1387
- audioManager.requestAudioFocus(focusRequest)
1388
- } else {
1389
- @Suppress("DEPRECATION")
1390
- audioManager.requestAudioFocus(
1391
- audioFocusChangeListener,
1392
- AudioManager.STREAM_MUSIC,
1393
- AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
1394
- )
1395
- }
1396
-
1397
- return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
1398
- }
1399
-
1400
- private fun releaseAudioFocus() {
1401
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1402
- (audioFocusRequest as? AudioFocusRequest)?.let { request ->
1403
- audioManager.abandonAudioFocusRequest(request)
1404
- }
1405
- } else {
1406
- @Suppress("DEPRECATION")
1407
- audioFocusChangeListener?.let { listener ->
1408
- audioManager.abandonAudioFocus(listener)
1409
- }
1410
- }
1411
- audioFocusRequest = null
1412
- audioFocusChangeListener = null
1413
- }
1414
-
1415
- private fun createRecordingFile(config: RecordingConfig, isCompressed: Boolean = false): File {
1416
- // Use custom directory or default to existing behavior
1417
- val baseDir = config.outputDirectory?.let { File(it) } ?: filesDir
1418
-
1419
- // Get base filename and remove any existing extension
1420
- val baseFilename = config.filename?.let {
1421
- it.substringBeforeLast('.', it) // Remove extension if present
1422
- } ?: UUID.randomUUID().toString()
1423
-
1424
- // Choose extension based on whether this is a compressed file
1425
- val extension = if (isCompressed) {
1426
- config.compressedFormat.lowercase()
1427
- } else {
1428
- "wav"
1429
- }
1430
-
1431
- return File(baseDir, "$baseFilename.$extension")
1432
- }
1433
-
1434
- fun getKeepAwakeStatus(): Boolean {
1435
- return recordingConfig?.keepAwake ?: true
1436
- }
1437
- }