@siteed/expo-audio-stream 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/android/src/main/java/net/siteed/audiostream/AudioDataEncoder.kt +9 -0
  2. package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +62 -0
  3. package/android/src/main/java/net/siteed/audiostream/AudioFormatUtils.kt +4 -0
  4. package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +445 -0
  5. package/android/src/main/java/net/siteed/audiostream/Constants.kt +12 -0
  6. package/android/src/main/java/net/siteed/audiostream/EventSender.kt +7 -0
  7. package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +43 -392
  8. package/android/src/main/java/net/siteed/audiostream/PermissionUtils.kt +16 -0
  9. package/build/ExpoAudioStream.types.d.ts +12 -1
  10. package/build/ExpoAudioStream.types.d.ts.map +1 -1
  11. package/build/ExpoAudioStream.types.js.map +1 -1
  12. package/build/ExpoAudioStreamModule.web.d.ts +2 -2
  13. package/build/ExpoAudioStreamModule.web.d.ts.map +1 -1
  14. package/build/ExpoAudioStreamModule.web.js +5 -1
  15. package/build/ExpoAudioStreamModule.web.js.map +1 -1
  16. package/build/index.d.ts +1 -0
  17. package/build/index.d.ts.map +1 -1
  18. package/build/index.js +3 -0
  19. package/build/index.js.map +1 -1
  20. package/build/useAudioRecording.d.ts +5 -4
  21. package/build/useAudioRecording.d.ts.map +1 -1
  22. package/build/useAudioRecording.js +38 -27
  23. package/build/useAudioRecording.js.map +1 -1
  24. package/ios/AudioStreamManager.swift +46 -16
  25. package/ios/ExpoAudioStreamModule.swift +2 -2
  26. package/package.json +1 -1
  27. package/src/ExpoAudioStream.types.ts +14 -5
  28. package/src/ExpoAudioStreamModule.web.ts +8 -3
  29. package/src/index.ts +4 -0
  30. package/src/useAudioRecording.ts +48 -34
@@ -0,0 +1,9 @@
1
+ package net.siteed.audiostream
2
+
3
+ import android.util.Base64
4
+
5
+ class AudioDataEncoder {
6
+ public fun encodeToBase64(rawData: ByteArray): String {
7
+ return Base64.encodeToString(rawData, Base64.NO_WRAP)
8
+ }
9
+ }
@@ -0,0 +1,62 @@
1
+ package net.siteed.audiostream
2
+
3
+ import java.io.File
4
+ import java.io.IOException
5
+ import java.io.OutputStream
6
+ import java.io.RandomAccessFile
7
+
8
+ class AudioFileHandler(private val filesDir: File) {
9
+ // Method to write WAV file header
10
+ fun writeWavHeader(out: OutputStream, sampleRateInHz: Int, channels: Int, bitDepth: Int) {
11
+ val header = ByteArray(44)
12
+ val byteRate = sampleRateInHz * channels * bitDepth / 8
13
+ val blockAlign = channels * bitDepth / 8
14
+
15
+ // RIFF/WAVE header
16
+ "RIFF".toByteArray().copyInto(header, 0)
17
+ header[4] = 0 // Size will be updated later
18
+ "WAVE".toByteArray().copyInto(header, 8)
19
+ "fmt ".toByteArray().copyInto(header, 12)
20
+
21
+ // 16 for PCM
22
+ header[16] = 16
23
+ header[20] = 1 // Audio format 1 for PCM (not compressed)
24
+ header[22] = channels.toByte()
25
+ header[24] = (sampleRateInHz and 0xff).toByte()
26
+ header[25] = (sampleRateInHz shr 8 and 0xff).toByte()
27
+ header[26] = (sampleRateInHz shr 16 and 0xff).toByte()
28
+ header[27] = (sampleRateInHz shr 24 and 0xff).toByte()
29
+ header[28] = (byteRate and 0xff).toByte()
30
+ header[29] = (byteRate shr 8 and 0xff).toByte()
31
+ header[30] = (byteRate shr 16 and 0xff).toByte()
32
+ header[31] = (byteRate shr 24 and 0xff).toByte()
33
+ header[32] = blockAlign.toByte()
34
+ header[34] = bitDepth.toByte()
35
+ "data".toByteArray().copyInto(header, 36)
36
+
37
+ out.write(header, 0, 44)
38
+ }
39
+
40
+ fun updateWavHeader(file: File) {
41
+ try {
42
+ RandomAccessFile(file, "rw").use { raf ->
43
+ val fileSize = raf.length()
44
+ val dataSize = fileSize - 44 // Subtract the header size
45
+
46
+ raf.seek(4) // Write correct file size, excluding the first 8 bytes of the RIFF header
47
+ raf.writeInt(Integer.reverseBytes((dataSize + 36).toInt()))
48
+
49
+ raf.seek(40) // Go to the data size position
50
+ raf.writeInt(Integer.reverseBytes(dataSize.toInt())) // Write the size of the data segment
51
+ }
52
+ } catch (e: IOException) {
53
+ println("Could not update WAV header: ${e.message}")
54
+ }
55
+ }
56
+
57
+ fun clearAudioStorage() {
58
+ filesDir.listFiles()?.forEach {
59
+ it.delete()
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,4 @@
1
+ package net.siteed.audiostream
2
+
3
+ class AudioFormatUtils {
4
+ }
@@ -0,0 +1,445 @@
1
+ package net.siteed.audiostream
2
+
3
+ import android.media.AudioFormat
4
+ import android.media.AudioRecord
5
+ import android.media.MediaRecorder
6
+ import android.os.Build
7
+ import android.os.Bundle
8
+ import android.os.Handler
9
+ import android.os.Looper
10
+ import android.os.SystemClock
11
+ import android.util.Log
12
+ import androidx.annotation.RequiresApi
13
+ import androidx.core.os.bundleOf
14
+ import expo.modules.kotlin.Promise
15
+ import java.io.ByteArrayOutputStream
16
+ import java.io.File
17
+ import java.io.FileOutputStream
18
+ import java.io.IOException
19
+ import java.util.concurrent.atomic.AtomicBoolean
20
+
21
+
22
+ class AudioRecorderManager(
23
+ private val filesDir: File,
24
+ private val permissionUtils: PermissionUtils,
25
+ private val audioDataEncoder: AudioDataEncoder,
26
+ private val eventSender: EventSender
27
+ ) {
28
+ private var audioRecord: AudioRecord? = null
29
+ private var sampleRateInHz = Constants.DEFAULT_SAMPLE_RATE
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)
34
+ private var isRecording = AtomicBoolean(false)
35
+ private val isPaused = AtomicBoolean(false)
36
+ private var streamUuid: String? = null
37
+ private var audioFile: File? = null
38
+ private var recordingThread: Thread? = null
39
+ private var recordingStartTime: Long = 0
40
+ private var totalRecordedTime: Long = 0
41
+ private var totalDataSize = 0
42
+ private var interval = 1000L // Emit data every 1000 milliseconds (1 second)
43
+ private var lastEmitTime = SystemClock.elapsedRealtime()
44
+ private var lastPauseTime = 0L
45
+ private var pausedDuration = 0L
46
+ private var lastEmittedSize = 0L
47
+ private var mimeType = "audio/wav"
48
+ private val mainHandler = Handler(Looper.getMainLooper())
49
+ private var bitDepth = 16
50
+ private var channels = 1
51
+ private val audioRecordLock = Any()
52
+ private var audioFileHandler: AudioFileHandler = AudioFileHandler(filesDir)
53
+
54
+ @RequiresApi(Build.VERSION_CODES.R)
55
+ fun startRecording(options: Map<String, Any?>, promise: Promise) {
56
+ if (!permissionUtils.checkRecordingPermission()) {
57
+ promise.reject("PERMISSION_DENIED", "Recording permission has not been granted", null)
58
+ return
59
+ }
60
+
61
+ if (isRecording.get() && !isPaused.get()) {
62
+ promise.reject("ALREADY_RECORDING", "Recording is already in progress", null)
63
+ return
64
+ }
65
+
66
+ // Extract and validate recording options
67
+ sampleRateInHz = options["sampleRate"] as? Int ?: Constants.DEFAULT_SAMPLE_RATE
68
+ if (sampleRateInHz !in listOf(16000, 44100, 48000)) {
69
+ promise.reject(
70
+ "INVALID_SAMPLE_RATE",
71
+ "Sample rate must be one of 16000, 44100, or 48000 Hz",
72
+ null
73
+ )
74
+ return
75
+ }
76
+
77
+ channels = options["channels"] as? Int ?: 1
78
+ if (channels !in 1..2) {
79
+ promise.reject(
80
+ "INVALID_CHANNELS",
81
+ "Channels must be either 1 (Mono) or 2 (Stereo)",
82
+ null
83
+ )
84
+ return
85
+ }
86
+
87
+ val encodingType = options["encoding"] as? String ?: "pcm_16bit"
88
+ if (encodingType !in listOf("pcm_16bit", "pcm_8bit", "aac", "opus")) {
89
+ promise.reject(
90
+ "INVALID_ENCODING",
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" -> {
102
+ fileExtension = "wav"
103
+ mimeType = "audio/wav" // WAV is typically used for PCM data.
104
+ AudioFormat.ENCODING_PCM_16BIT
105
+ }
106
+
107
+ "opus" -> {
108
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
109
+ // Handle the case where Opus is not supported by the device
110
+ promise.reject(
111
+ "UNSUPPORTED_FORMAT",
112
+ "Opus encoding not supported on this Android version.",
113
+ null
114
+ )
115
+ return
116
+ }
117
+ fileExtension = "opus"
118
+ mimeType = "audio/opus"
119
+ AudioFormat.ENCODING_OPUS
120
+ }
121
+
122
+ "aac_lc" -> {
123
+ fileExtension = "aac"
124
+ mimeType = "audio/aac"
125
+ AudioFormat.ENCODING_AAC_LC
126
+ }
127
+
128
+ else -> {
129
+ fileExtension = "wav"
130
+ mimeType = "audio/wav" // Default case or throw an error if unsupported
131
+ AudioFormat.ENCODING_DEFAULT
132
+ }
133
+ }
134
+
135
+ interval = options["interval"] as? Long ?: Constants.DEFAULT_INTERVAL
136
+ if (interval < Constants.MIN_INTERVAL) {
137
+ promise.reject(
138
+ "INVALID_INTERVAL",
139
+ "Interval must be at least ${Constants.MIN_INTERVAL} ms",
140
+ null
141
+ )
142
+ return
143
+ }
144
+
145
+ bufferSizeInBytes = AudioRecord.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat)
146
+
147
+ Log.d(
148
+ Constants.TAG,
149
+ "Starting recording with the following parameters: Sample Rate: $sampleRateInHz Hz, Channels: $channels, Encoding: $encodingType, File Extension: $fileExtension, MIME Type: $mimeType, Interval: $interval ms"
150
+ )
151
+
152
+ // Initialize the AudioRecord if it's a new recording or if it's not currently paused
153
+ if (audioRecord == null || !isPaused.get()) {
154
+ audioRecord = AudioRecord(
155
+ MediaRecorder.AudioSource.MIC,
156
+ sampleRateInHz,
157
+ channelConfig,
158
+ audioFormat,
159
+ bufferSizeInBytes
160
+ )
161
+ if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
162
+ promise.reject(
163
+ "INITIALIZATION_FAILED",
164
+ "Failed to initialize the audio recorder",
165
+ null
166
+ )
167
+ return
168
+ }
169
+ }
170
+
171
+ streamUuid = java.util.UUID.randomUUID().toString()
172
+ audioFile = File(filesDir, "audio_${streamUuid}.${fileExtension}")
173
+
174
+ try {
175
+ FileOutputStream(audioFile, true).use { fos ->
176
+ audioFileHandler.writeWavHeader(fos, sampleRateInHz, channels, bitDepth)
177
+ }
178
+ } catch (e: IOException) {
179
+ promise.reject("FILE_CREATION_FAILED", "Failed to create the audio file", e)
180
+ return
181
+ }
182
+
183
+ audioRecord?.startRecording()
184
+ isPaused.set(false)
185
+ isRecording.set(true)
186
+
187
+ if (!isPaused.get()) {
188
+ recordingStartTime =
189
+ System.currentTimeMillis() // Only reset start time if it's not a resume
190
+ }
191
+
192
+ recordingThread = Thread { recordingProcess() }.apply { start() }
193
+
194
+ val result = bundleOf(
195
+ "fileUri" to audioFile?.toURI().toString(),
196
+ "channels" to channels,
197
+ "bitDepth" to bitDepth,
198
+ "sampleRate" to sampleRateInHz,
199
+ "mimeType" to mimeType
200
+ )
201
+ promise.resolve(result)
202
+ }
203
+
204
+ fun stopRecording(promise: Promise) {
205
+ synchronized(audioRecordLock) {
206
+
207
+ if (!isRecording.get()) {
208
+ Log.e(Constants.TAG, "Recording is not active")
209
+ promise.reject("NOT_RECORDING", "Recording is not active", null)
210
+ return
211
+ }
212
+
213
+ try {
214
+ val audioData = ByteArray(bufferSizeInBytes)
215
+ val bytesRead = audioRecord?.read(audioData, 0, bufferSizeInBytes) ?: -1
216
+ Log.d(Constants.TAG, "Last Read $bytesRead bytes")
217
+ if (bytesRead > 0) {
218
+ emitAudioData(audioData, bytesRead)
219
+ }
220
+
221
+ Log.d(Constants.TAG, "Stopping recording state = ${audioRecord?.state}")
222
+ if (audioRecord != null && audioRecord!!.state == AudioRecord.STATE_INITIALIZED) {
223
+ Log.d(Constants.TAG, "Stopping AudioRecord");
224
+ audioRecord!!.stop()
225
+ }
226
+ } catch (e: IllegalStateException) {
227
+ Log.e(Constants.TAG, "Error reading from AudioRecord", e);
228
+ } finally {
229
+ audioRecord?.release()
230
+ }
231
+
232
+ try {
233
+ val fileSize = audioFile?.length() ?: 0
234
+ val dataFileSize = fileSize - 44 // Subtract header size
235
+ val byteRate = sampleRateInHz * channels * (bitDepth / 8)
236
+
237
+ // Calculate duration based on the data size and byte rate
238
+ val duration = if (byteRate > 0) (dataFileSize * 1000 / byteRate) else 0
239
+
240
+ // Create result bundle
241
+ val result = bundleOf(
242
+ "fileUri" to audioFile?.toURI().toString(),
243
+ "duration" to duration,
244
+ "channels" to channels,
245
+ "bitDepth" to bitDepth,
246
+ "sampleRate" to sampleRateInHz,
247
+ "size" to fileSize,
248
+ "mimeType" to mimeType
249
+ )
250
+ promise.resolve(result)
251
+
252
+ // Reset the timing variables
253
+ isRecording.set(false)
254
+ isPaused.set(false)
255
+ totalRecordedTime = 0
256
+ pausedDuration = 0
257
+ } catch (e: Exception) {
258
+ Log.d(Constants.TAG, "Failed to stop recording", e)
259
+ promise.reject("STOP_FAILED", "Failed to stop recording", e)
260
+ } finally {
261
+ audioRecord = null
262
+ }
263
+ }
264
+ }
265
+
266
+ fun pauseRecording(promise: Promise) {
267
+ if (isRecording.get() && !isPaused.get()) {
268
+ audioRecord?.stop()
269
+ lastPauseTime =
270
+ System.currentTimeMillis() // Record the time when the recording was paused
271
+ isPaused.set(true)
272
+ promise.resolve("Recording paused")
273
+ } else {
274
+ promise.reject(
275
+ "NOT_RECORDING_OR_ALREADY_PAUSED",
276
+ "Recording is either not active or already paused",
277
+ null
278
+ )
279
+ }
280
+ }
281
+
282
+ fun resumeRecording(promise: Promise) {
283
+ if (isRecording.get() && !isPaused.get()) {
284
+ promise.reject("NOT_PAUSED", "Recording is not paused", null)
285
+ return
286
+ } else if (audioRecord == null) {
287
+ promise.reject("NOT_RECORDING", "Recording is not active", null)
288
+ }
289
+
290
+ // Calculate the duration the recording was paused
291
+ pausedDuration += System.currentTimeMillis() - lastPauseTime
292
+ isPaused.set(false)
293
+ audioRecord?.startRecording()
294
+ promise.resolve("Recording resumed")
295
+ }
296
+
297
+ fun getStatus(): Bundle {
298
+ synchronized(audioRecordLock) {
299
+ if (!isRecording.get()) {
300
+ Log.d(Constants.TAG, "Not recording --- skip status with default values")
301
+
302
+ return bundleOf(
303
+ "isRecording" to false,
304
+ "isPaused" to false,
305
+ "mime" to mimeType,
306
+ "size" to 0,
307
+ "interval" to interval,
308
+ )
309
+ }
310
+
311
+ // Ensure you update this to check if audioFile is null or not
312
+ val fileSize = audioFile?.length() ?: 0
313
+
314
+ val duration = when (mimeType) {
315
+ "audio/wav" -> {
316
+ // WAV files store raw audio data, so we can calculate duration like this
317
+ val dataFileSize =
318
+ fileSize - Constants.WAV_HEADER_SIZE // Assuming header is always 44 bytes
319
+ val byteRate = sampleRateInHz * channels * (bitDepth / 8)
320
+ if (byteRate > 0) dataFileSize * 1000 / byteRate else 0
321
+ }
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
+
329
+ else -> 0
330
+ }
331
+ return bundleOf(
332
+ "duration" to duration,
333
+ "isRecording" to isRecording.get(),
334
+ "isPaused" to isPaused.get(),
335
+ "mime" to mimeType,
336
+ "size" to totalDataSize,
337
+ "interval" to interval,
338
+ )
339
+ }
340
+ }
341
+
342
+ fun listAudioFiles(promise: Promise) {
343
+ val fileList =
344
+ filesDir.list()?.filter { it.endsWith(".wav") }?.map { File(filesDir, it).absolutePath }
345
+ ?: listOf()
346
+ promise.resolve(fileList)
347
+ }
348
+
349
+ fun clearAudioStorage() {
350
+ audioFileHandler.clearAudioStorage()
351
+ }
352
+
353
+ private fun recordingProcess() {
354
+ Log.i(Constants.TAG, "Starting recording process...")
355
+ FileOutputStream(audioFile, true).use { fos ->
356
+ // Buffer to accumulate data
357
+ val accumulatedAudioData = ByteArrayOutputStream()
358
+ audioFileHandler.writeWavHeader(
359
+ accumulatedAudioData,
360
+ sampleRateInHz,
361
+ channels,
362
+ bitDepth
363
+ )
364
+
365
+ // Write audio data directly to the file
366
+ val audioData = ByteArray(bufferSizeInBytes)
367
+ Log.d(Constants.TAG, "Entering recording loop")
368
+ while (isRecording.get() && !Thread.currentThread().isInterrupted) {
369
+ if (isPaused.get()) {
370
+ // If recording is paused, skip reading from the microphone
371
+ continue
372
+ }
373
+
374
+ val bytesRead = synchronized(audioRecordLock) {
375
+ // Only synchronize the read operation and the check
376
+ audioRecord?.let {
377
+ if (it.state != AudioRecord.STATE_INITIALIZED) {
378
+ Log.e(Constants.TAG, "AudioRecord not initialized")
379
+ return@let -1
380
+ }
381
+ it.read(audioData, 0, bufferSizeInBytes)
382
+ } ?: -1 // Handle null case
383
+ }
384
+ if (bytesRead > 0) {
385
+ fos.write(audioData, 0, bytesRead)
386
+ totalDataSize += bytesRead
387
+ accumulatedAudioData.write(audioData, 0, bytesRead)
388
+
389
+ // Emit audio data at defined intervals
390
+ if (SystemClock.elapsedRealtime() - lastEmitTime >= interval) {
391
+ emitAudioData(
392
+ accumulatedAudioData.toByteArray(),
393
+ accumulatedAudioData.size()
394
+ )
395
+ lastEmitTime = SystemClock.elapsedRealtime() // Reset the timer
396
+ accumulatedAudioData.reset() // Clear the accumulator
397
+ }
398
+
399
+ Log.d(Constants.TAG, "Bytes written to file: $bytesRead")
400
+ }
401
+ }
402
+ }
403
+ // Update the WAV header to reflect the actual data size
404
+ audioFile?.let { file ->
405
+ audioFileHandler.updateWavHeader(file)
406
+ }
407
+ }
408
+
409
+ private fun emitAudioData(audioData: ByteArray, length: Int) {
410
+ val encodedBuffer = audioDataEncoder.encodeToBase64(audioData)
411
+
412
+ val fileSize = audioFile?.length() ?: 0
413
+ val from = lastEmittedSize
414
+ val deltaSize = fileSize - lastEmittedSize
415
+ lastEmittedSize = fileSize
416
+
417
+ // Calculate position in milliseconds
418
+ val positionInMs = (from * 1000) / (sampleRateInHz * channels * (bitDepth / 8))
419
+
420
+ mainHandler.post {
421
+ try {
422
+ eventSender.sendExpoEvent(
423
+ Constants.AUDIO_EVENT_NAME, bundleOf(
424
+ "fileUri" to audioFile?.toURI().toString(),
425
+ "lastEmittedSize" to from,
426
+ "encoded" to encodedBuffer,
427
+ "deltaSize" to length,
428
+ "position" to positionInMs,
429
+ "mimeType" to mimeType,
430
+ "totalSize" to fileSize,
431
+ "streamUuid" to streamUuid
432
+ )
433
+ )
434
+ } catch (e: Exception) {
435
+ Log.e(Constants.TAG, "Failed to send event", e)
436
+ }
437
+ }
438
+ }
439
+
440
+ private fun getCompressedAudioDuration(file: File?): Long {
441
+ // Placeholder function for fetching duration from a compressed audio file
442
+ // This would depend on how you store or can retrieve duration info for compressed formats
443
+ return 0L // Implement this based on your specific requirements
444
+ }
445
+ }
@@ -0,0 +1,12 @@
1
+ package net.siteed.audiostream
2
+
3
+ object Constants {
4
+ const val AUDIO_EVENT_NAME = "AudioData"
5
+ const val DEFAULT_SAMPLE_RATE = 16000 // Default sample rate for audio recording
6
+ const val DEFAULT_CHANNEL_CONFIG = 1 // Mono
7
+ const val DEFAULT_AUDIO_FORMAT = 16 // 16-bit PCM
8
+ const val DEFAULT_INTERVAL = 1000L
9
+ const val MIN_INTERVAL = 100L // Minimum interval in ms for emitting audio data
10
+ const val WAV_HEADER_SIZE = 44
11
+ const val TAG = "AudioRecorderModule"
12
+ }
@@ -0,0 +1,7 @@
1
+ package net.siteed.audiostream
2
+
3
+ import android.os.Bundle
4
+
5
+ interface EventSender {
6
+ fun sendExpoEvent(eventName: String, params: Bundle)
7
+ }