@siteed/expo-audio-stream 2.1.0 → 2.2.1-beta.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.
- package/README.md +23 -260
- package/build/index.d.ts +11 -15
- package/build/index.js +54 -14
- package/build/src/index.d.ts +11 -0
- package/build/src/index.js +54 -0
- package/package.json +49 -110
- package/src/index.ts +18 -32
- package/CHANGELOG.md +0 -206
- package/android/build.gradle +0 -105
- package/android/src/main/AndroidManifest.xml +0 -27
- package/android/src/main/java/net/siteed/audiostream/AudioAnalysisData.kt +0 -166
- package/android/src/main/java/net/siteed/audiostream/AudioDataEncoder.kt +0 -9
- package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +0 -131
- package/android/src/main/java/net/siteed/audiostream/AudioFormatUtils.kt +0 -103
- package/android/src/main/java/net/siteed/audiostream/AudioNotificationsManager.kt +0 -435
- package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +0 -2235
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +0 -1437
- package/android/src/main/java/net/siteed/audiostream/AudioRecordingService.kt +0 -152
- package/android/src/main/java/net/siteed/audiostream/AudioTrimmer.kt +0 -1099
- package/android/src/main/java/net/siteed/audiostream/Constants.kt +0 -21
- package/android/src/main/java/net/siteed/audiostream/EventSender.kt +0 -7
- package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +0 -739
- package/android/src/main/java/net/siteed/audiostream/FFT.kt +0 -99
- package/android/src/main/java/net/siteed/audiostream/Features.kt +0 -98
- package/android/src/main/java/net/siteed/audiostream/NotificationConfig.kt +0 -70
- package/android/src/main/java/net/siteed/audiostream/PermissionUtils.kt +0 -59
- package/android/src/main/java/net/siteed/audiostream/RecordingActionReceiver.kt +0 -59
- package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +0 -205
- package/android/src/main/java/net/siteed/audiostream/WaveformConfig.kt +0 -19
- package/android/src/main/java/net/siteed/audiostream/WaveformRenderer.kt +0 -159
- package/android/src/main/res/drawable/ic_default_action_icon.xml +0 -16
- package/android/src/main/res/drawable/ic_microphone.xml +0 -13
- package/android/src/main/res/drawable/ic_pause.xml +0 -10
- package/android/src/main/res/drawable/ic_play.xml +0 -10
- package/android/src/main/res/drawable/ic_stop.xml +0 -10
- package/android/src/main/res/layout/notification_recording.xml +0 -37
- package/android/src/main/test/java/net/siteed/audiostream/AudioProcessorTest.kt +0 -56
- package/app.plugin.js +0 -1
- package/build/AudioAnalysis/AudioAnalysis.types.d.ts +0 -179
- package/build/AudioAnalysis/AudioAnalysis.types.d.ts.map +0 -1
- package/build/AudioAnalysis/AudioAnalysis.types.js +0 -3
- package/build/AudioAnalysis/AudioAnalysis.types.js.map +0 -1
- package/build/AudioAnalysis/extractAudioAnalysis.d.ts +0 -68
- package/build/AudioAnalysis/extractAudioAnalysis.d.ts.map +0 -1
- package/build/AudioAnalysis/extractAudioAnalysis.js +0 -203
- package/build/AudioAnalysis/extractAudioAnalysis.js.map +0 -1
- package/build/AudioAnalysis/extractAudioData.d.ts +0 -3
- package/build/AudioAnalysis/extractAudioData.d.ts.map +0 -1
- package/build/AudioAnalysis/extractAudioData.js +0 -5
- package/build/AudioAnalysis/extractAudioData.js.map +0 -1
- package/build/AudioAnalysis/extractMelSpectrogram.d.ts +0 -14
- package/build/AudioAnalysis/extractMelSpectrogram.d.ts.map +0 -1
- package/build/AudioAnalysis/extractMelSpectrogram.js +0 -85
- package/build/AudioAnalysis/extractMelSpectrogram.js.map +0 -1
- package/build/AudioAnalysis/extractPreview.d.ts +0 -11
- package/build/AudioAnalysis/extractPreview.d.ts.map +0 -1
- package/build/AudioAnalysis/extractPreview.js +0 -25
- package/build/AudioAnalysis/extractPreview.js.map +0 -1
- package/build/AudioAnalysis/extractWaveform.d.ts +0 -8
- package/build/AudioAnalysis/extractWaveform.d.ts.map +0 -1
- package/build/AudioAnalysis/extractWaveform.js +0 -11
- package/build/AudioAnalysis/extractWaveform.js.map +0 -1
- package/build/AudioRecorder.provider.d.ts +0 -11
- package/build/AudioRecorder.provider.d.ts.map +0 -1
- package/build/AudioRecorder.provider.js +0 -37
- package/build/AudioRecorder.provider.js.map +0 -1
- package/build/ExpoAudioStream.native.d.ts +0 -3
- package/build/ExpoAudioStream.native.d.ts.map +0 -1
- package/build/ExpoAudioStream.native.js +0 -6
- package/build/ExpoAudioStream.native.js.map +0 -1
- package/build/ExpoAudioStream.types.d.ts +0 -532
- package/build/ExpoAudioStream.types.d.ts.map +0 -1
- package/build/ExpoAudioStream.types.js +0 -2
- package/build/ExpoAudioStream.types.js.map +0 -1
- package/build/ExpoAudioStream.web.d.ts +0 -59
- package/build/ExpoAudioStream.web.d.ts.map +0 -1
- package/build/ExpoAudioStream.web.js +0 -285
- package/build/ExpoAudioStream.web.js.map +0 -1
- package/build/ExpoAudioStreamModule.d.ts +0 -3
- package/build/ExpoAudioStreamModule.d.ts.map +0 -1
- package/build/ExpoAudioStreamModule.js +0 -693
- package/build/ExpoAudioStreamModule.js.map +0 -1
- package/build/WebRecorder.web.d.ts +0 -119
- package/build/WebRecorder.web.d.ts.map +0 -1
- package/build/WebRecorder.web.js +0 -436
- package/build/WebRecorder.web.js.map +0 -1
- package/build/constants.d.ts +0 -11
- package/build/constants.d.ts.map +0 -1
- package/build/constants.js +0 -14
- package/build/constants.js.map +0 -1
- package/build/events.d.ts +0 -26
- package/build/events.d.ts.map +0 -1
- package/build/events.js +0 -21
- package/build/events.js.map +0 -1
- package/build/index.d.ts.map +0 -1
- package/build/index.js.map +0 -1
- package/build/trimAudio.d.ts +0 -25
- package/build/trimAudio.d.ts.map +0 -1
- package/build/trimAudio.js +0 -67
- package/build/trimAudio.js.map +0 -1
- package/build/useAudioRecorder.d.ts +0 -21
- package/build/useAudioRecorder.d.ts.map +0 -1
- package/build/useAudioRecorder.js +0 -427
- package/build/useAudioRecorder.js.map +0 -1
- package/build/utils/BlobFix.d.ts +0 -9
- package/build/utils/BlobFix.d.ts.map +0 -1
- package/build/utils/BlobFix.js +0 -498
- package/build/utils/BlobFix.js.map +0 -1
- package/build/utils/audioProcessing.d.ts +0 -24
- package/build/utils/audioProcessing.d.ts.map +0 -1
- package/build/utils/audioProcessing.js +0 -133
- package/build/utils/audioProcessing.js.map +0 -1
- package/build/utils/concatenateBuffers.d.ts +0 -8
- package/build/utils/concatenateBuffers.d.ts.map +0 -1
- package/build/utils/concatenateBuffers.js +0 -21
- package/build/utils/concatenateBuffers.js.map +0 -1
- package/build/utils/convertPCMToFloat32.d.ts +0 -13
- package/build/utils/convertPCMToFloat32.d.ts.map +0 -1
- package/build/utils/convertPCMToFloat32.js +0 -120
- package/build/utils/convertPCMToFloat32.js.map +0 -1
- package/build/utils/encodingToBitDepth.d.ts +0 -5
- package/build/utils/encodingToBitDepth.d.ts.map +0 -1
- package/build/utils/encodingToBitDepth.js +0 -13
- package/build/utils/encodingToBitDepth.js.map +0 -1
- package/build/utils/getWavFileInfo.d.ts +0 -26
- package/build/utils/getWavFileInfo.d.ts.map +0 -1
- package/build/utils/getWavFileInfo.js +0 -92
- package/build/utils/getWavFileInfo.js.map +0 -1
- package/build/utils/writeWavHeader.d.ts +0 -49
- package/build/utils/writeWavHeader.d.ts.map +0 -1
- package/build/utils/writeWavHeader.js +0 -91
- package/build/utils/writeWavHeader.js.map +0 -1
- package/build/workers/InlineFeaturesExtractor.web.d.ts +0 -2
- package/build/workers/InlineFeaturesExtractor.web.d.ts.map +0 -1
- package/build/workers/InlineFeaturesExtractor.web.js +0 -828
- package/build/workers/InlineFeaturesExtractor.web.js.map +0 -1
- package/build/workers/inlineAudioWebWorker.web.d.ts +0 -2
- package/build/workers/inlineAudioWebWorker.web.d.ts.map +0 -1
- package/build/workers/inlineAudioWebWorker.web.js +0 -157
- package/build/workers/inlineAudioWebWorker.web.js.map +0 -1
- package/expo-module.config.json +0 -9
- package/ios/AudioAnalysisData.swift +0 -74
- package/ios/AudioNotificationManager.swift +0 -135
- package/ios/AudioProcessingHelpers.swift +0 -743
- package/ios/AudioProcessor.swift +0 -1313
- package/ios/AudioStreamError.swift +0 -7
- package/ios/AudioStreamManager.swift +0 -1708
- package/ios/AudioStreamManagerDelegate.swift +0 -16
- package/ios/DataPoint.swift +0 -54
- package/ios/DecodingConfig.swift +0 -47
- package/ios/ExpoAudioStream.podspec +0 -27
- package/ios/ExpoAudioStreamModule.swift +0 -805
- package/ios/FFT.swift +0 -62
- package/ios/Features.swift +0 -95
- package/ios/Logger.swift +0 -7
- package/ios/NotificationExtension.swift +0 -15
- package/ios/RecordingResult.swift +0 -22
- package/ios/RecordingSettings.swift +0 -265
- package/ios/WaveformExtractor.swift +0 -105
- package/plugin/build/index.d.ts +0 -21
- package/plugin/build/index.js +0 -191
- package/plugin/src/index.ts +0 -278
- package/plugin/tsconfig.json +0 -10
- package/plugin/tsconfig.tsbuildinfo +0 -1
- package/src/AudioAnalysis/AudioAnalysis.types.ts +0 -202
- package/src/AudioAnalysis/extractAudioAnalysis.ts +0 -333
- package/src/AudioAnalysis/extractAudioData.ts +0 -6
- package/src/AudioAnalysis/extractMelSpectrogram.ts +0 -144
- package/src/AudioAnalysis/extractPreview.ts +0 -34
- package/src/AudioAnalysis/extractWaveform.ts +0 -22
- package/src/AudioRecorder.provider.tsx +0 -54
- package/src/ExpoAudioStream.native.ts +0 -6
- package/src/ExpoAudioStream.types.ts +0 -641
- package/src/ExpoAudioStream.web.ts +0 -359
- package/src/ExpoAudioStreamModule.ts +0 -967
- package/src/WebRecorder.web.ts +0 -580
- package/src/constants.ts +0 -18
- package/src/events.ts +0 -60
- package/src/trimAudio.ts +0 -90
- package/src/useAudioRecorder.tsx +0 -620
- package/src/utils/BlobFix.ts +0 -559
- package/src/utils/audioProcessing.ts +0 -205
- package/src/utils/concatenateBuffers.ts +0 -24
- package/src/utils/convertPCMToFloat32.ts +0 -170
- package/src/utils/encodingToBitDepth.ts +0 -18
- package/src/utils/getWavFileInfo.ts +0 -132
- package/src/utils/writeWavHeader.ts +0 -114
- package/src/workers/InlineFeaturesExtractor.web.tsx +0 -827
- 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
|
-
}
|