@siteed/expo-audio-stream 1.0.1 → 1.0.3
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/.size-limit.json +6 -0
- package/README.md +6 -6
- package/android/build.gradle +5 -0
- package/android/src/main/java/net/siteed/audiostream/AudioAnalysisData.kt +120 -0
- package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +34 -4
- package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +635 -0
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +194 -79
- package/android/src/main/java/net/siteed/audiostream/Constants.kt +1 -0
- package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +48 -2
- package/android/src/main/java/net/siteed/audiostream/FFT.kt +44 -0
- package/android/src/main/java/net/siteed/audiostream/Features.kt +56 -0
- package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +12 -0
- package/android/src/main/test/java/net/siteed/audiostream/AudioProcessorTest.kt +56 -0
- package/app.plugin.js +1 -1
- package/build/AudioAnalysis/AudioAnalysis.types.d.ts +76 -0
- package/build/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -0
- package/build/AudioAnalysis/AudioAnalysis.types.js +3 -0
- package/build/AudioAnalysis/AudioAnalysis.types.js.map +1 -0
- package/build/AudioAnalysis/extractAudioAnalysis.d.ts +4 -0
- package/build/AudioAnalysis/extractAudioAnalysis.d.ts.map +1 -0
- package/build/AudioAnalysis/extractAudioAnalysis.js +101 -0
- package/build/AudioAnalysis/extractAudioAnalysis.js.map +1 -0
- package/build/AudioAnalysis/extractWaveform.d.ts +8 -0
- package/build/AudioAnalysis/extractWaveform.d.ts.map +1 -0
- package/build/AudioAnalysis/extractWaveform.js +14 -0
- package/build/AudioAnalysis/extractWaveform.js.map +1 -0
- package/build/AudioRecorder.provider.d.ts +14 -1
- package/build/AudioRecorder.provider.d.ts.map +1 -1
- package/build/AudioRecorder.provider.js +18 -5
- package/build/AudioRecorder.provider.js.map +1 -1
- package/build/ExpoAudioStream.native.d.ts +3 -0
- package/build/ExpoAudioStream.native.d.ts.map +1 -0
- package/build/ExpoAudioStream.native.js +6 -0
- package/build/ExpoAudioStream.native.js.map +1 -0
- package/build/ExpoAudioStream.types.d.ts +35 -20
- package/build/ExpoAudioStream.types.d.ts.map +1 -1
- package/build/ExpoAudioStream.types.js.map +1 -1
- package/build/ExpoAudioStream.web.d.ts +42 -0
- package/build/ExpoAudioStream.web.d.ts.map +1 -0
- package/build/ExpoAudioStream.web.js +185 -0
- package/build/ExpoAudioStream.web.js.map +1 -0
- package/build/ExpoAudioStreamModule.d.ts +2 -2
- package/build/ExpoAudioStreamModule.d.ts.map +1 -1
- package/build/ExpoAudioStreamModule.js +16 -3
- package/build/ExpoAudioStreamModule.js.map +1 -1
- package/build/WebRecorder.web.d.ts +51 -0
- package/build/WebRecorder.web.d.ts.map +1 -0
- package/build/WebRecorder.web.js +288 -0
- package/build/WebRecorder.web.js.map +1 -0
- package/build/constants.d.ts +11 -0
- package/build/constants.d.ts.map +1 -0
- package/build/constants.js +14 -0
- package/build/constants.js.map +1 -0
- package/build/events.d.ts +6 -0
- package/build/events.d.ts.map +1 -0
- package/build/events.js +15 -0
- package/build/events.js.map +1 -0
- package/build/index.d.ts +8 -7
- package/build/index.d.ts.map +1 -1
- package/build/index.js +7 -14
- package/build/index.js.map +1 -1
- package/build/logger.d.ts +9 -0
- package/build/logger.d.ts.map +1 -0
- package/build/logger.js +17 -0
- package/build/logger.js.map +1 -0
- package/build/useAudioRecorder.d.ts +37 -0
- package/build/useAudioRecorder.d.ts.map +1 -0
- package/build/useAudioRecorder.js +271 -0
- package/build/useAudioRecorder.js.map +1 -0
- package/build/utils/convertPCMToFloat32.d.ts +11 -0
- package/build/utils/convertPCMToFloat32.d.ts.map +1 -0
- package/build/utils/convertPCMToFloat32.js +41 -0
- package/build/utils/convertPCMToFloat32.js.map +1 -0
- package/build/utils/encodingToBitDepth.d.ts +5 -0
- package/build/utils/encodingToBitDepth.d.ts.map +1 -0
- package/build/utils/encodingToBitDepth.js +13 -0
- package/build/utils/encodingToBitDepth.js.map +1 -0
- package/build/utils/getWavFileInfo.d.ts +25 -0
- package/build/utils/getWavFileInfo.d.ts.map +1 -0
- package/build/utils/getWavFileInfo.js +89 -0
- package/build/utils/getWavFileInfo.js.map +1 -0
- package/build/utils/writeWavHeader.d.ts +9 -0
- package/build/utils/writeWavHeader.d.ts.map +1 -0
- package/build/utils/writeWavHeader.js +41 -0
- package/build/utils/writeWavHeader.js.map +1 -0
- package/build/workers/InlineFeaturesExtractor.web.d.ts +2 -0
- package/build/workers/InlineFeaturesExtractor.web.d.ts.map +1 -0
- package/build/workers/InlineFeaturesExtractor.web.js +303 -0
- package/build/workers/InlineFeaturesExtractor.web.js.map +1 -0
- package/build/workers/inlineAudioWebWorker.web.d.ts +2 -0
- package/build/workers/inlineAudioWebWorker.web.d.ts.map +1 -0
- package/build/workers/inlineAudioWebWorker.web.js +243 -0
- package/build/workers/inlineAudioWebWorker.web.js.map +1 -0
- package/expo-module.config.json +13 -4
- package/ios/AudioAnalysisData.swift +39 -0
- package/ios/AudioProcessingHelpers.swift +59 -0
- package/ios/AudioProcessor.swift +317 -0
- package/ios/AudioStreamError.swift +7 -0
- package/ios/AudioStreamManager.swift +243 -54
- package/ios/AudioStreamManagerDelegate.swift +4 -0
- package/ios/DataPoint.swift +41 -0
- package/ios/ExpoAudioStreamModule.swift +198 -6
- package/ios/Features.swift +44 -0
- package/ios/RecordingResult.swift +19 -0
- package/ios/RecordingSettings.swift +13 -0
- package/ios/WaveformExtractor.swift +105 -0
- package/package.json +13 -12
- package/plugin/tsconfig.json +13 -8
- package/publish.sh +8 -0
- package/src/AudioAnalysis/AudioAnalysis.types.ts +85 -0
- package/src/AudioAnalysis/extractAudioAnalysis.ts +136 -0
- package/src/AudioAnalysis/extractWaveform.ts +25 -0
- package/src/AudioRecorder.provider.tsx +36 -8
- package/src/ExpoAudioStream.native.ts +6 -0
- package/src/ExpoAudioStream.types.ts +50 -25
- package/src/ExpoAudioStream.web.ts +229 -0
- package/src/ExpoAudioStreamModule.ts +22 -3
- package/src/WebRecorder.web.ts +416 -0
- package/src/constants.ts +18 -0
- package/src/events.ts +25 -0
- package/src/index.ts +14 -29
- package/src/logger.ts +26 -0
- package/src/useAudioRecorder.tsx +415 -0
- package/src/utils/convertPCMToFloat32.ts +48 -0
- package/src/utils/encodingToBitDepth.ts +18 -0
- package/src/utils/getWavFileInfo.ts +125 -0
- package/src/utils/writeWavHeader.ts +56 -0
- package/src/workers/InlineFeaturesExtractor.web.tsx +302 -0
- package/src/workers/inlineAudioWebWorker.web.tsx +242 -0
- package/build/ExpoAudioStreamModule.web.d.ts +0 -37
- package/build/ExpoAudioStreamModule.web.d.ts.map +0 -1
- package/build/ExpoAudioStreamModule.web.js +0 -156
- package/build/ExpoAudioStreamModule.web.js.map +0 -1
- package/build/useAudioRecording.d.ts +0 -23
- package/build/useAudioRecording.d.ts.map +0 -1
- package/build/useAudioRecording.js +0 -189
- package/build/useAudioRecording.js.map +0 -1
- package/docs/demo.gif +0 -0
- package/release-it.js +0 -18
- package/src/ExpoAudioStreamModule.web.ts +0 -181
- package/src/useAudioRecording.ts +0 -268
- package/yarn-error.log +0 -7793
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// net/siteed/audiostream/AudioRecorderManager.kt
|
|
1
2
|
package net.siteed.audiostream
|
|
2
3
|
|
|
3
4
|
import android.media.AudioFormat
|
|
@@ -16,6 +17,7 @@ import java.io.ByteArrayOutputStream
|
|
|
16
17
|
import java.io.File
|
|
17
18
|
import java.io.FileOutputStream
|
|
18
19
|
import java.io.IOException
|
|
20
|
+
import java.io.Serializable
|
|
19
21
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
20
22
|
|
|
21
23
|
|
|
@@ -26,11 +28,7 @@ class AudioRecorderManager(
|
|
|
26
28
|
private val eventSender: EventSender
|
|
27
29
|
) {
|
|
28
30
|
private var audioRecord: AudioRecord? = null
|
|
29
|
-
private var
|
|
30
|
-
private var channelConfig = Constants.DEFAULT_CHANNEL_CONFIG
|
|
31
|
-
private var audioFormat = Constants.DEFAULT_AUDIO_FORMAT
|
|
32
|
-
private var bufferSizeInBytes =
|
|
33
|
-
AudioRecord.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat)
|
|
31
|
+
private var bufferSizeInBytes = 0
|
|
34
32
|
private var isRecording = AtomicBoolean(false)
|
|
35
33
|
private val isPaused = AtomicBoolean(false)
|
|
36
34
|
private var streamUuid: String? = null
|
|
@@ -44,13 +42,15 @@ class AudioRecorderManager(
|
|
|
44
42
|
private var lastPauseTime = 0L
|
|
45
43
|
private var pausedDuration = 0L
|
|
46
44
|
private var lastEmittedSize = 0L
|
|
47
|
-
private var mimeType = "audio/wav"
|
|
48
45
|
private val mainHandler = Handler(Looper.getMainLooper())
|
|
49
|
-
private var bitDepth = 16
|
|
50
|
-
private var channels = 1
|
|
51
46
|
private val audioRecordLock = Any()
|
|
52
47
|
private var audioFileHandler: AudioFileHandler = AudioFileHandler(filesDir)
|
|
53
48
|
|
|
49
|
+
private lateinit var recordingConfig: RecordingConfig
|
|
50
|
+
private var mimeType = "audio/wav"
|
|
51
|
+
private var audioFormat: Int = AudioFormat.ENCODING_PCM_16BIT
|
|
52
|
+
private var audioProcessor: AudioProcessor = AudioProcessor(filesDir)
|
|
53
|
+
|
|
54
54
|
@RequiresApi(Build.VERSION_CODES.R)
|
|
55
55
|
fun startRecording(options: Map<String, Any?>, promise: Promise) {
|
|
56
56
|
if (!permissionUtils.checkRecordingPermission()) {
|
|
@@ -63,9 +63,29 @@ class AudioRecorderManager(
|
|
|
63
63
|
return
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
// Extract and
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
// Extract and filter features
|
|
67
|
+
val featuresMap = options["features"] as? Map<*, *>
|
|
68
|
+
val features = featuresMap?.filterKeys { it is String }
|
|
69
|
+
?.filterValues { it is Boolean }
|
|
70
|
+
?.mapKeys { it.key as String }
|
|
71
|
+
?.mapValues { it.value as Boolean }
|
|
72
|
+
?: emptyMap()
|
|
73
|
+
|
|
74
|
+
// Initialize the recording configuration
|
|
75
|
+
var tempRecordingConfig = RecordingConfig(
|
|
76
|
+
sampleRate = (options["sampleRate"] as? Number)?.toInt() ?: Constants.DEFAULT_SAMPLE_RATE,
|
|
77
|
+
channels = (options["channels"] as? Number)?.toInt() ?: 1,
|
|
78
|
+
encoding = options["encoding"] as? String ?: "pcm_16bit",
|
|
79
|
+
interval = (options["interval"] as? Number)?.toLong() ?: Constants.DEFAULT_INTERVAL,
|
|
80
|
+
enableProcessing = options["enableProcessing"] as? Boolean ?: false,
|
|
81
|
+
pointsPerSecond = (options["pointsPerSecond"] as? Number)?.toDouble() ?: 20.0,
|
|
82
|
+
algorithm = options["algorithm"] as? String ?: "rms",
|
|
83
|
+
features = features
|
|
84
|
+
)
|
|
85
|
+
Log.d(Constants.TAG, "Initial recording configuration: $tempRecordingConfig")
|
|
86
|
+
|
|
87
|
+
// Validate sample rate and channels
|
|
88
|
+
if (tempRecordingConfig.sampleRate !in listOf(16000, 44100, 48000)) {
|
|
69
89
|
promise.reject(
|
|
70
90
|
"INVALID_SAMPLE_RATE",
|
|
71
91
|
"Sample rate must be one of 16000, 44100, or 48000 Hz",
|
|
@@ -73,9 +93,7 @@ class AudioRecorderManager(
|
|
|
73
93
|
)
|
|
74
94
|
return
|
|
75
95
|
}
|
|
76
|
-
|
|
77
|
-
channels = options["channels"] as? Int ?: 1
|
|
78
|
-
if (channels !in 1..2) {
|
|
96
|
+
if (tempRecordingConfig.channels !in 1..2) {
|
|
79
97
|
promise.reject(
|
|
80
98
|
"INVALID_CHANNELS",
|
|
81
99
|
"Channels must be either 1 (Mono) or 2 (Stereo)",
|
|
@@ -84,29 +102,26 @@ class AudioRecorderManager(
|
|
|
84
102
|
return
|
|
85
103
|
}
|
|
86
104
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
"Encoding must be one of the following: pcm_16bit, pcm_8bit, aac, opus",
|
|
92
|
-
null
|
|
93
|
-
)
|
|
94
|
-
return
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
var fileExtension = ".wav" // Default
|
|
99
|
-
|
|
100
|
-
audioFormat = when (encodingType) {
|
|
101
|
-
"pcm_8bit", "pcm_16bit" -> {
|
|
105
|
+
// Set encoding and file extension
|
|
106
|
+
var fileExtension = ".wav"
|
|
107
|
+
audioFormat = when (tempRecordingConfig.encoding) {
|
|
108
|
+
"pcm_8bit" -> {
|
|
102
109
|
fileExtension = "wav"
|
|
103
|
-
mimeType = "audio/wav"
|
|
110
|
+
mimeType = "audio/wav"
|
|
111
|
+
AudioFormat.ENCODING_PCM_8BIT
|
|
112
|
+
}
|
|
113
|
+
"pcm_16bit" -> {
|
|
114
|
+
fileExtension = "wav"
|
|
115
|
+
mimeType = "audio/wav"
|
|
104
116
|
AudioFormat.ENCODING_PCM_16BIT
|
|
105
117
|
}
|
|
106
|
-
|
|
118
|
+
"pcm_32bit" -> {
|
|
119
|
+
fileExtension = "wav"
|
|
120
|
+
mimeType = "audio/wav"
|
|
121
|
+
AudioFormat.ENCODING_PCM_FLOAT
|
|
122
|
+
}
|
|
107
123
|
"opus" -> {
|
|
108
124
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
|
109
|
-
// Handle the case where Opus is not supported by the device
|
|
110
125
|
promise.reject(
|
|
111
126
|
"UNSUPPORTED_FORMAT",
|
|
112
127
|
"Opus encoding not supported on this Android version.",
|
|
@@ -118,43 +133,69 @@ class AudioRecorderManager(
|
|
|
118
133
|
mimeType = "audio/opus"
|
|
119
134
|
AudioFormat.ENCODING_OPUS
|
|
120
135
|
}
|
|
121
|
-
|
|
122
136
|
"aac_lc" -> {
|
|
123
137
|
fileExtension = "aac"
|
|
124
138
|
mimeType = "audio/aac"
|
|
125
139
|
AudioFormat.ENCODING_AAC_LC
|
|
126
140
|
}
|
|
127
|
-
|
|
128
141
|
else -> {
|
|
129
142
|
fileExtension = "wav"
|
|
130
|
-
mimeType = "audio/wav"
|
|
143
|
+
mimeType = "audio/wav"
|
|
131
144
|
AudioFormat.ENCODING_DEFAULT
|
|
132
145
|
}
|
|
133
146
|
}
|
|
134
147
|
|
|
135
|
-
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
null
|
|
141
|
-
|
|
142
|
-
|
|
148
|
+
// Check if selected audio format is supported
|
|
149
|
+
if (!isAudioFormatSupported(tempRecordingConfig.sampleRate, tempRecordingConfig.channels, audioFormat)) {
|
|
150
|
+
Log.e(Constants.TAG, "Selected audio format not supported, falling back to 16-bit PCM")
|
|
151
|
+
audioFormat = AudioFormat.ENCODING_PCM_16BIT
|
|
152
|
+
if (!isAudioFormatSupported(tempRecordingConfig.sampleRate, tempRecordingConfig.channels, audioFormat)) {
|
|
153
|
+
promise.reject("INITIALIZATION_FAILED", "Failed to initialize audio recorder with any supported format", null)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
tempRecordingConfig = tempRecordingConfig.copy(encoding = "pcm_16bit")
|
|
143
157
|
}
|
|
144
158
|
|
|
145
|
-
|
|
159
|
+
// Update recordingConfig with potentially new encoding
|
|
160
|
+
recordingConfig = tempRecordingConfig
|
|
146
161
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
162
|
+
|
|
163
|
+
// Check if selected audio format is supported
|
|
164
|
+
if (!isAudioFormatSupported(tempRecordingConfig.sampleRate, tempRecordingConfig.channels, audioFormat)) {
|
|
165
|
+
Log.e(Constants.TAG, "Selected audio format not supported, falling back to 16-bit PCM")
|
|
166
|
+
audioFormat = AudioFormat.ENCODING_PCM_16BIT
|
|
167
|
+
if (!isAudioFormatSupported(tempRecordingConfig.sampleRate, tempRecordingConfig.channels, audioFormat)) {
|
|
168
|
+
promise.reject("INITIALIZATION_FAILED", "Failed to initialize audio recorder with any supported format", null)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
tempRecordingConfig = tempRecordingConfig.copy(encoding = "pcm_16bit")
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Update recordingConfig with potentially new encoding
|
|
175
|
+
recordingConfig = tempRecordingConfig
|
|
176
|
+
|
|
177
|
+
// Recalculate bufferSizeInBytes if the format has changed
|
|
178
|
+
bufferSizeInBytes = AudioRecord.getMinBufferSize(
|
|
179
|
+
recordingConfig.sampleRate,
|
|
180
|
+
if (recordingConfig.channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO,
|
|
181
|
+
audioFormat
|
|
150
182
|
)
|
|
151
183
|
|
|
184
|
+
if (bufferSizeInBytes == AudioRecord.ERROR || bufferSizeInBytes == AudioRecord.ERROR_BAD_VALUE || bufferSizeInBytes < 0) {
|
|
185
|
+
Log.e(Constants.TAG, "Failed to get minimum buffer size, falling back to default buffer size.")
|
|
186
|
+
bufferSizeInBytes = 4096 // Default buffer size in bytes
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
Log.d(Constants.TAG, "AudioFormat: $audioFormat, BufferSize: $bufferSizeInBytes")
|
|
190
|
+
|
|
152
191
|
// Initialize the AudioRecord if it's a new recording or if it's not currently paused
|
|
153
192
|
if (audioRecord == null || !isPaused.get()) {
|
|
193
|
+
Log.d(Constants.TAG, "AudioFormat: $audioFormat, BufferSize: $bufferSizeInBytes")
|
|
194
|
+
|
|
154
195
|
audioRecord = AudioRecord(
|
|
155
196
|
MediaRecorder.AudioSource.MIC,
|
|
156
|
-
|
|
157
|
-
|
|
197
|
+
recordingConfig.sampleRate,
|
|
198
|
+
if (recordingConfig.channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO,
|
|
158
199
|
audioFormat,
|
|
159
200
|
bufferSizeInBytes
|
|
160
201
|
)
|
|
@@ -173,7 +214,12 @@ class AudioRecorderManager(
|
|
|
173
214
|
|
|
174
215
|
try {
|
|
175
216
|
FileOutputStream(audioFile, true).use { fos ->
|
|
176
|
-
audioFileHandler.writeWavHeader(fos,
|
|
217
|
+
audioFileHandler.writeWavHeader(fos, recordingConfig.sampleRate, recordingConfig.channels, when (recordingConfig.encoding) {
|
|
218
|
+
"pcm_8bit" -> 8
|
|
219
|
+
"pcm_16bit" -> 16
|
|
220
|
+
"pcm_32bit" -> 32
|
|
221
|
+
else -> 16 // Default to 16 if the encoding is not recognized
|
|
222
|
+
})
|
|
177
223
|
}
|
|
178
224
|
} catch (e: IOException) {
|
|
179
225
|
promise.reject("FILE_CREATION_FAILED", "Failed to create the audio file", e)
|
|
@@ -193,14 +239,54 @@ class AudioRecorderManager(
|
|
|
193
239
|
|
|
194
240
|
val result = bundleOf(
|
|
195
241
|
"fileUri" to audioFile?.toURI().toString(),
|
|
196
|
-
"channels" to channels,
|
|
197
|
-
"bitDepth" to
|
|
198
|
-
|
|
242
|
+
"channels" to recordingConfig.channels,
|
|
243
|
+
"bitDepth" to when (recordingConfig.encoding) {
|
|
244
|
+
"pcm_8bit" -> 8
|
|
245
|
+
"pcm_16bit" -> 16
|
|
246
|
+
"pcm_32bit" -> 32
|
|
247
|
+
else -> 16 // Default to 16 if the encoding is not recognized
|
|
248
|
+
},
|
|
249
|
+
"sampleRate" to recordingConfig.sampleRate,
|
|
199
250
|
"mimeType" to mimeType
|
|
200
251
|
)
|
|
201
252
|
promise.resolve(result)
|
|
202
253
|
}
|
|
203
254
|
|
|
255
|
+
private fun isAudioFormatSupported(sampleRate: Int, channels: Int, format: Int): Boolean {
|
|
256
|
+
if (!permissionUtils.checkRecordingPermission()) {
|
|
257
|
+
throw SecurityException("Recording permission has not been granted")
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
val channelConfig = if (channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO
|
|
261
|
+
val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, format)
|
|
262
|
+
|
|
263
|
+
if (bufferSize <= 0) {
|
|
264
|
+
return false
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
val audioRecord = AudioRecord(
|
|
268
|
+
MediaRecorder.AudioSource.MIC,
|
|
269
|
+
sampleRate,
|
|
270
|
+
channelConfig,
|
|
271
|
+
format,
|
|
272
|
+
bufferSize
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
val isSupported = audioRecord.state == AudioRecord.STATE_INITIALIZED
|
|
276
|
+
if (isSupported) {
|
|
277
|
+
val testBuffer = ByteArray(bufferSize)
|
|
278
|
+
audioRecord.startRecording()
|
|
279
|
+
val testRead = audioRecord.read(testBuffer, 0, bufferSize)
|
|
280
|
+
audioRecord.stop()
|
|
281
|
+
if (testRead < 0) {
|
|
282
|
+
return false
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
audioRecord.release()
|
|
287
|
+
return isSupported
|
|
288
|
+
}
|
|
289
|
+
|
|
204
290
|
fun stopRecording(promise: Promise) {
|
|
205
291
|
synchronized(audioRecordLock) {
|
|
206
292
|
|
|
@@ -232,18 +318,27 @@ class AudioRecorderManager(
|
|
|
232
318
|
try {
|
|
233
319
|
val fileSize = audioFile?.length() ?: 0
|
|
234
320
|
val dataFileSize = fileSize - 44 // Subtract header size
|
|
235
|
-
val byteRate =
|
|
236
|
-
|
|
321
|
+
val byteRate = recordingConfig.sampleRate * recordingConfig.channels * when (recordingConfig.encoding) {
|
|
322
|
+
"pcm_8bit" -> 1
|
|
323
|
+
"pcm_16bit" -> 2
|
|
324
|
+
"pcm_32bit" -> 4
|
|
325
|
+
else -> 2 // Default to 2 bytes per sample if the encoding is not recognized
|
|
326
|
+
}
|
|
237
327
|
// Calculate duration based on the data size and byte rate
|
|
238
328
|
val duration = if (byteRate > 0) (dataFileSize * 1000 / byteRate) else 0
|
|
239
329
|
|
|
240
330
|
// Create result bundle
|
|
241
331
|
val result = bundleOf(
|
|
242
332
|
"fileUri" to audioFile?.toURI().toString(),
|
|
243
|
-
"
|
|
244
|
-
"channels" to channels,
|
|
245
|
-
"bitDepth" to
|
|
246
|
-
|
|
333
|
+
"durationMs" to duration,
|
|
334
|
+
"channels" to recordingConfig.channels,
|
|
335
|
+
"bitDepth" to when (recordingConfig.encoding) {
|
|
336
|
+
"pcm_8bit" -> 8
|
|
337
|
+
"pcm_16bit" -> 16
|
|
338
|
+
"pcm_32bit" -> 32
|
|
339
|
+
else -> 16 // Default to 16 if the encoding is not recognized
|
|
340
|
+
},
|
|
341
|
+
"sampleRate" to recordingConfig.sampleRate,
|
|
247
342
|
"size" to fileSize,
|
|
248
343
|
"mimeType" to mimeType
|
|
249
344
|
)
|
|
@@ -313,28 +408,20 @@ class AudioRecorderManager(
|
|
|
313
408
|
|
|
314
409
|
val duration = when (mimeType) {
|
|
315
410
|
"audio/wav" -> {
|
|
316
|
-
|
|
317
|
-
val
|
|
318
|
-
fileSize - Constants.WAV_HEADER_SIZE // Assuming header is always 44 bytes
|
|
319
|
-
val byteRate = sampleRateInHz * channels * (bitDepth / 8)
|
|
411
|
+
val dataFileSize = fileSize - Constants.WAV_HEADER_SIZE // Assuming header is always 44 bytes
|
|
412
|
+
val byteRate = recordingConfig.sampleRate * recordingConfig.channels * (if (recordingConfig.encoding == "pcm_8bit") 8 else 16) / 8
|
|
320
413
|
if (byteRate > 0) dataFileSize * 1000 / byteRate else 0
|
|
321
414
|
}
|
|
322
|
-
|
|
323
|
-
"audio/opus", "audio/aac" -> {
|
|
324
|
-
// For compressed formats, the duration might need to be retrieved differently,
|
|
325
|
-
// perhaps from metadata or requiring a library to parse the file if not stored elsewhere.
|
|
326
|
-
getCompressedAudioDuration(audioFile)
|
|
327
|
-
}
|
|
328
|
-
|
|
415
|
+
"audio/opus", "audio/aac" -> getCompressedAudioDuration(audioFile)
|
|
329
416
|
else -> 0
|
|
330
417
|
}
|
|
331
418
|
return bundleOf(
|
|
332
|
-
"
|
|
419
|
+
"durationMs" to duration,
|
|
333
420
|
"isRecording" to isRecording.get(),
|
|
334
421
|
"isPaused" to isPaused.get(),
|
|
335
|
-
"
|
|
422
|
+
"mimeType" to mimeType,
|
|
336
423
|
"size" to totalDataSize,
|
|
337
|
-
"interval" to interval
|
|
424
|
+
"interval" to recordingConfig.interval
|
|
338
425
|
)
|
|
339
426
|
}
|
|
340
427
|
}
|
|
@@ -357,11 +444,15 @@ class AudioRecorderManager(
|
|
|
357
444
|
val accumulatedAudioData = ByteArrayOutputStream()
|
|
358
445
|
audioFileHandler.writeWavHeader(
|
|
359
446
|
accumulatedAudioData,
|
|
360
|
-
|
|
361
|
-
channels,
|
|
362
|
-
|
|
447
|
+
recordingConfig.sampleRate,
|
|
448
|
+
recordingConfig.channels,
|
|
449
|
+
when (recordingConfig.encoding) {
|
|
450
|
+
"pcm_8bit" -> 8
|
|
451
|
+
"pcm_16bit" -> 16
|
|
452
|
+
"pcm_32bit" -> 32
|
|
453
|
+
else -> 16 // Default to 16 if the encoding is not recognized
|
|
454
|
+
}
|
|
363
455
|
)
|
|
364
|
-
|
|
365
456
|
// Write audio data directly to the file
|
|
366
457
|
val audioData = ByteArray(bufferSizeInBytes)
|
|
367
458
|
Log.d(Constants.TAG, "Entering recording loop")
|
|
@@ -378,7 +469,11 @@ class AudioRecorderManager(
|
|
|
378
469
|
Log.e(Constants.TAG, "AudioRecord not initialized")
|
|
379
470
|
return@let -1
|
|
380
471
|
}
|
|
381
|
-
it.read(audioData, 0, bufferSizeInBytes)
|
|
472
|
+
it.read(audioData, 0, bufferSizeInBytes).also { bytes ->
|
|
473
|
+
if (bytes < 0) {
|
|
474
|
+
Log.e(Constants.TAG, "AudioRecord read error: $bytes")
|
|
475
|
+
}
|
|
476
|
+
}
|
|
382
477
|
} ?: -1 // Handle null case
|
|
383
478
|
}
|
|
384
479
|
if (bytesRead > 0) {
|
|
@@ -415,7 +510,7 @@ class AudioRecorderManager(
|
|
|
415
510
|
lastEmittedSize = fileSize
|
|
416
511
|
|
|
417
512
|
// Calculate position in milliseconds
|
|
418
|
-
val positionInMs = (from * 1000) / (
|
|
513
|
+
val positionInMs = (from * 1000) / (recordingConfig.sampleRate * recordingConfig.channels * (if (recordingConfig.encoding == "pcm_8bit") 8 else 16) / 8)
|
|
419
514
|
|
|
420
515
|
mainHandler.post {
|
|
421
516
|
try {
|
|
@@ -435,8 +530,28 @@ class AudioRecorderManager(
|
|
|
435
530
|
Log.e(Constants.TAG, "Failed to send event", e)
|
|
436
531
|
}
|
|
437
532
|
}
|
|
533
|
+
|
|
534
|
+
if (recordingConfig.enableProcessing) {
|
|
535
|
+
processAudioData(audioData)
|
|
536
|
+
}
|
|
438
537
|
}
|
|
439
538
|
|
|
539
|
+
private fun processAudioData(audioData: ByteArray) {
|
|
540
|
+
val audioAnalysisData = audioProcessor.processAudioData(audioData, recordingConfig)
|
|
541
|
+
val analysisBundle = audioAnalysisData.toBundle()
|
|
542
|
+
|
|
543
|
+
mainHandler.post {
|
|
544
|
+
try {
|
|
545
|
+
eventSender.sendExpoEvent(
|
|
546
|
+
Constants.AUDIO_ANALYSIS_EVENT_NAME, analysisBundle
|
|
547
|
+
)
|
|
548
|
+
} catch (e: Exception) {
|
|
549
|
+
Log.e(Constants.TAG, "Failed to send audio analysis event", e)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
|
|
440
555
|
private fun getCompressedAudioDuration(file: File?): Long {
|
|
441
556
|
// Placeholder function for fetching duration from a compressed audio file
|
|
442
557
|
// This would depend on how you store or can retrieve duration info for compressed formats
|
|
@@ -2,6 +2,7 @@ package net.siteed.audiostream
|
|
|
2
2
|
|
|
3
3
|
object Constants {
|
|
4
4
|
const val AUDIO_EVENT_NAME = "AudioData"
|
|
5
|
+
const val AUDIO_ANALYSIS_EVENT_NAME = "AudioAnalysis"
|
|
5
6
|
const val DEFAULT_SAMPLE_RATE = 16000 // Default sample rate for audio recording
|
|
6
7
|
const val DEFAULT_CHANNEL_CONFIG = 1 // Mono
|
|
7
8
|
const val DEFAULT_AUDIO_FORMAT = 16 // 16-bit PCM
|
|
@@ -10,6 +10,7 @@ import expo.modules.kotlin.modules.ModuleDefinition
|
|
|
10
10
|
|
|
11
11
|
class ExpoAudioStreamModule() : Module(), EventSender {
|
|
12
12
|
private lateinit var audioRecorderManager: AudioRecorderManager
|
|
13
|
+
private lateinit var audioProcessor: AudioProcessor
|
|
13
14
|
|
|
14
15
|
@RequiresApi(Build.VERSION_CODES.R)
|
|
15
16
|
override fun definition() = ModuleDefinition {
|
|
@@ -18,7 +19,7 @@ class ExpoAudioStreamModule() : Module(), EventSender {
|
|
|
18
19
|
// The module will be accessible from `requireNativeModule('ExpoAudioStream')` in JavaScript.
|
|
19
20
|
Name("ExpoAudioStream")
|
|
20
21
|
|
|
21
|
-
Events(Constants.AUDIO_EVENT_NAME)
|
|
22
|
+
Events(Constants.AUDIO_EVENT_NAME, Constants.AUDIO_ANALYSIS_EVENT_NAME)
|
|
22
23
|
|
|
23
24
|
// Initialize AudioRecorderManager
|
|
24
25
|
initializeManager()
|
|
@@ -43,6 +44,49 @@ class ExpoAudioStreamModule() : Module(), EventSender {
|
|
|
43
44
|
audioRecorderManager.pauseRecording(promise)
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
|
|
48
|
+
AsyncFunction("extractAudioAnalysis") { options: Map<String, Any>, promise: Promise ->
|
|
49
|
+
val fileUri = options["fileUri"] as? String
|
|
50
|
+
val pointsPerSecond = (options["pointsPerSecond"] as? Double) ?: 20.0
|
|
51
|
+
val algorithm = options["algorithm"] as? String ?: "rms"
|
|
52
|
+
val featuresMap = options["features"] as? Map<*, *>
|
|
53
|
+
val features = featuresMap?.filterKeys { it is String }
|
|
54
|
+
?.filterValues { it is Boolean }
|
|
55
|
+
?.mapKeys { it.key as String }
|
|
56
|
+
?.mapValues { it.value as Boolean }
|
|
57
|
+
?: emptyMap()
|
|
58
|
+
val skipWavHeader = (options["skipWavHeader"] as? Boolean) ?: false
|
|
59
|
+
|
|
60
|
+
if (fileUri == null) {
|
|
61
|
+
promise.reject("INVALID_ARGUMENTS", "fileUri is required", null)
|
|
62
|
+
return@AsyncFunction
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
val audioData = audioProcessor.loadAudioFile(fileUri, skipWavHeader)
|
|
67
|
+
if (audioData == null) {
|
|
68
|
+
promise.reject("PROCESSING_ERROR", "Failed to load audio file", null)
|
|
69
|
+
return@AsyncFunction
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
val recordingConfig = RecordingConfig(
|
|
73
|
+
sampleRate = audioData.sampleRate,
|
|
74
|
+
channels = audioData.channels,
|
|
75
|
+
encoding = "pcm_${audioData.bitDepth}bit",
|
|
76
|
+
pointsPerSecond = pointsPerSecond,
|
|
77
|
+
algorithm = algorithm,
|
|
78
|
+
features = features
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
Log.d("ExpoAudioStreamModule", "extractAudioAnalysis: $recordingConfig")
|
|
82
|
+
|
|
83
|
+
val analysisData = audioProcessor.processAudioData(audioData.data, recordingConfig)
|
|
84
|
+
promise.resolve(analysisData.toDictionary())
|
|
85
|
+
} catch (e: Exception) {
|
|
86
|
+
promise.reject("PROCESSING_ERROR", "Failed to process audio file: ${e.message}", e)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
46
90
|
AsyncFunction("resumeRecording") { promise: Promise ->
|
|
47
91
|
audioRecorderManager.resumeRecording(promise)
|
|
48
92
|
}
|
|
@@ -59,10 +103,12 @@ class ExpoAudioStreamModule() : Module(), EventSender {
|
|
|
59
103
|
val audioEncoder = AudioDataEncoder()
|
|
60
104
|
audioRecorderManager =
|
|
61
105
|
AudioRecorderManager(androidContext.filesDir, permissionUtils, audioEncoder, this)
|
|
106
|
+
audioProcessor = AudioProcessor(androidContext.filesDir) // Instantiate here with filesDir
|
|
62
107
|
}
|
|
63
108
|
|
|
64
109
|
override fun sendExpoEvent(eventName: String, params: Bundle) {
|
|
65
|
-
|
|
110
|
+
Log.d(Constants.TAG, "Sending event: $eventName")
|
|
111
|
+
this@ExpoAudioStreamModule.sendEvent(eventName, params)
|
|
66
112
|
}
|
|
67
113
|
|
|
68
114
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
package net.siteed.audiostream
|
|
2
|
+
|
|
3
|
+
import kotlin.math.PI
|
|
4
|
+
import kotlin.math.cos
|
|
5
|
+
import kotlin.math.sin
|
|
6
|
+
|
|
7
|
+
class FFT(private val n: Int) {
|
|
8
|
+
private val cosTable = FloatArray(n / 2)
|
|
9
|
+
private val sinTable = FloatArray(n / 2)
|
|
10
|
+
|
|
11
|
+
init {
|
|
12
|
+
for (i in 0 until n / 2) {
|
|
13
|
+
cosTable[i] = cos(2.0 * PI * i / n).toFloat()
|
|
14
|
+
sinTable[i] = sin(2.0 * PI * i / n).toFloat()
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
fun realForward(data: FloatArray) {
|
|
19
|
+
realForwardRecursive(data)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private fun realForwardRecursive(data: FloatArray) {
|
|
23
|
+
val n = data.size
|
|
24
|
+
if (n <= 1) return
|
|
25
|
+
|
|
26
|
+
val even = FloatArray(n / 2)
|
|
27
|
+
val odd = FloatArray(n / 2)
|
|
28
|
+
|
|
29
|
+
for (i in 0 until n / 2) {
|
|
30
|
+
even[i] = data[2 * i]
|
|
31
|
+
odd[i] = data[2 * i + 1]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
realForwardRecursive(even)
|
|
35
|
+
realForwardRecursive(odd)
|
|
36
|
+
|
|
37
|
+
for (i in 0 until n / 2) {
|
|
38
|
+
val t = cosTable[i] * odd[i] - sinTable[i] * even[i]
|
|
39
|
+
val u = sinTable[i] * odd[i] + cosTable[i] * even[i]
|
|
40
|
+
data[i] = even[i] + t
|
|
41
|
+
data[i + n / 2] = even[i] - t
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
package net.siteed.audiostream
|
|
2
|
+
|
|
3
|
+
import android.os.Bundle
|
|
4
|
+
import androidx.core.os.bundleOf
|
|
5
|
+
|
|
6
|
+
data class Features(
|
|
7
|
+
val energy: Float = 0f,
|
|
8
|
+
val mfcc: List<Float> = emptyList(),
|
|
9
|
+
val rms: Float = 0f,
|
|
10
|
+
val minAmplitude: Float = 0f,
|
|
11
|
+
val maxAmplitude: Float = 0f,
|
|
12
|
+
val zcr: Float = 0f,
|
|
13
|
+
val spectralCentroid: Float = 0f,
|
|
14
|
+
val spectralFlatness: Float = 0f,
|
|
15
|
+
val spectralRollOff: Float = 0f,
|
|
16
|
+
val spectralBandwidth: Float = 0f,
|
|
17
|
+
val chromagram: List<Float> = emptyList(),
|
|
18
|
+
val tempo: Float = 0f,
|
|
19
|
+
val hnr: Float = 0f
|
|
20
|
+
) {
|
|
21
|
+
fun toDictionary(): Map<String, Any> {
|
|
22
|
+
return mapOf(
|
|
23
|
+
"energy" to energy,
|
|
24
|
+
"mfcc" to mfcc,
|
|
25
|
+
"rms" to rms,
|
|
26
|
+
"minAmplitude" to minAmplitude,
|
|
27
|
+
"maxAmplitude" to maxAmplitude,
|
|
28
|
+
"zcr" to zcr,
|
|
29
|
+
"spectralCentroid" to spectralCentroid,
|
|
30
|
+
"spectralFlatness" to spectralFlatness,
|
|
31
|
+
"spectralRollOff" to spectralRollOff,
|
|
32
|
+
"spectralBandwidth" to spectralBandwidth,
|
|
33
|
+
"chromagram" to chromagram,
|
|
34
|
+
"tempo" to tempo,
|
|
35
|
+
"hnr" to hnr
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
fun toBundle(): Bundle {
|
|
40
|
+
return bundleOf(
|
|
41
|
+
"energy" to energy,
|
|
42
|
+
"mfcc" to mfcc,
|
|
43
|
+
"rms" to rms,
|
|
44
|
+
"minAmplitude" to minAmplitude,
|
|
45
|
+
"maxAmplitude" to maxAmplitude,
|
|
46
|
+
"zcr" to zcr,
|
|
47
|
+
"spectralCentroid" to spectralCentroid,
|
|
48
|
+
"spectralFlatness" to spectralFlatness,
|
|
49
|
+
"spectralRollOff" to spectralRollOff,
|
|
50
|
+
"spectralBandwidth" to spectralBandwidth,
|
|
51
|
+
"chromagram" to chromagram,
|
|
52
|
+
"tempo" to tempo,
|
|
53
|
+
"hnr" to hnr
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
package net.siteed.audiostream
|
|
2
|
+
|
|
3
|
+
data class RecordingConfig(
|
|
4
|
+
val sampleRate: Int = Constants.DEFAULT_SAMPLE_RATE,
|
|
5
|
+
val channels: Int = 1,
|
|
6
|
+
val encoding: String = "pcm_16bit",
|
|
7
|
+
val interval: Long = Constants.DEFAULT_INTERVAL,
|
|
8
|
+
val enableProcessing: Boolean = false,
|
|
9
|
+
val pointsPerSecond: Double = 20.0,
|
|
10
|
+
val algorithm: String = "rms",
|
|
11
|
+
val features: Map<String, Boolean> = emptyMap()
|
|
12
|
+
)
|