@siteed/expo-audio-studio 2.13.1 → 2.14.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/CHANGELOG.md CHANGED
@@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  ## [Unreleased]
9
9
 
10
10
 
11
+ ## [2.14.0] - 2025-06-11
12
+ ### Changed
13
+ - feat(expo-audio-studio): comprehensive cross-platform stop recording performance optimization ([b3ed474](https://github.com/deeeed/expo-audio-stream/commit/b3ed474d91994698fe082354621adc98e758557e))
14
+ ## [2.13.2] - 2025-06-10
15
+ ### Changed
16
+ - fix: invalid type exports ([18340ea](https://github.com/deeeed/expo-audio-stream/commit/18340eac9cfef39691637400fc6af811d5b004df))
17
+ - chore(expo-audio-studio): release @siteed/expo-audio-studio@2.13.1 ([9ccce85](https://github.com/deeeed/expo-audio-stream/commit/9ccce858174254387aac44d30853c908707d8254))
11
18
  ## [2.13.1] - 2025-06-09
12
19
  ### Changed
13
20
  - feat(investigation): resolve Issue #251 - comprehensive sub-100ms audio events analysis (#270) ([4813f1e](https://github.com/deeeed/expo-audio-stream/commit/4813f1ef05f3856b58ec8fde95b7b8909feb513d))
@@ -312,7 +319,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
312
319
  - Feature: Audio features extraction during recording.
313
320
  - Feature: Consistent WAV PCM recording format across all platforms.
314
321
 
315
- [unreleased]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.13.1...HEAD
322
+ [unreleased]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.14.0...HEAD
323
+ [2.14.0]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.13.2...@siteed/expo-audio-studio@2.14.0
324
+ [2.13.2]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.13.1...@siteed/expo-audio-studio@2.13.2
316
325
  [2.13.1]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.13.0...@siteed/expo-audio-studio@2.13.1
317
326
  [2.13.0]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.12.3...@siteed/expo-audio-studio@2.13.0
318
327
  [2.12.3]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.12.2...@siteed/expo-audio-studio@2.12.3
@@ -0,0 +1,234 @@
1
+ package net.siteed.audiostream
2
+
3
+ import android.Manifest
4
+ import android.content.Context
5
+ import android.util.Log
6
+ import androidx.test.ext.junit.runners.AndroidJUnit4
7
+ import androidx.test.platform.app.InstrumentationRegistry
8
+ import androidx.test.rule.GrantPermissionRule
9
+ import expo.modules.kotlin.Promise
10
+ import org.junit.After
11
+ import org.junit.Assert.*
12
+ import org.junit.Before
13
+ import org.junit.Rule
14
+ import org.junit.Test
15
+ import org.junit.runner.RunWith
16
+ import java.io.File
17
+ import java.util.concurrent.CountDownLatch
18
+ import java.util.concurrent.TimeUnit
19
+ import kotlin.system.measureTimeMillis
20
+
21
+ /**
22
+ * Performance tests for measuring stop recording times.
23
+ */
24
+ @RunWith(AndroidJUnit4::class)
25
+ class AudioRecorderPerformanceInstrumentedTest {
26
+
27
+ @get:Rule
28
+ val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
29
+ Manifest.permission.RECORD_AUDIO
30
+ )
31
+
32
+ private lateinit var context: Context
33
+ private lateinit var filesDir: File
34
+ private lateinit var audioRecorderManager: AudioRecorderManager
35
+ private lateinit var testEventSender: TestEventSender
36
+ private lateinit var permissionUtils: PermissionUtils
37
+ private lateinit var audioDataEncoder: AudioDataEncoder
38
+
39
+ companion object {
40
+ private const val TAG = "PerformanceTest"
41
+ }
42
+
43
+ // Test event sender to capture events
44
+ private class TestEventSender : EventSender {
45
+ override fun sendExpoEvent(eventName: String, params: android.os.Bundle) {
46
+ // No-op for performance tests
47
+ }
48
+ }
49
+
50
+ @Before
51
+ fun setUp() {
52
+ context = InstrumentationRegistry.getInstrumentation().targetContext
53
+ filesDir = context.filesDir
54
+ testEventSender = TestEventSender()
55
+ permissionUtils = PermissionUtils(context)
56
+ audioDataEncoder = AudioDataEncoder()
57
+
58
+ // Initialize AudioRecorderManager
59
+ audioRecorderManager = AudioRecorderManager.initialize(
60
+ context = context,
61
+ filesDir = filesDir,
62
+ permissionUtils = permissionUtils,
63
+ audioDataEncoder = audioDataEncoder,
64
+ eventSender = testEventSender,
65
+ enablePhoneStateHandling = false,
66
+ enableBackgroundAudio = false
67
+ )
68
+
69
+ // Clean up any existing audio files
70
+ cleanupAudioFiles()
71
+ }
72
+
73
+ @After
74
+ fun tearDown() {
75
+ // Stop any ongoing recording
76
+ if (audioRecorderManager.isRecording) {
77
+ val promise = object : Promise {
78
+ override fun resolve(value: Any?) {}
79
+ override fun reject(code: String, message: String?, cause: Throwable?) {}
80
+ }
81
+ audioRecorderManager.stopRecording(promise)
82
+ }
83
+
84
+ // Clean up
85
+ AudioRecorderManager.destroy()
86
+ cleanupAudioFiles()
87
+ }
88
+
89
+ private fun cleanupAudioFiles() {
90
+ filesDir.listFiles()?.forEach { file ->
91
+ if (file.name.endsWith(".wav") || file.name.endsWith(".aac") || file.name.endsWith(".opus")) {
92
+ file.delete()
93
+ }
94
+ }
95
+ }
96
+
97
+ @Test
98
+ fun measureStopTime_5seconds() {
99
+ runPerformanceTest(5_000L, "5 second recording")
100
+ }
101
+
102
+ @Test
103
+ fun measureStopTime_30seconds() {
104
+ runPerformanceTest(30_000L, "30 second recording")
105
+ }
106
+
107
+ @Test
108
+ fun measureStopTime_1minute() {
109
+ runPerformanceTest(60_000L, "1 minute recording")
110
+ }
111
+
112
+ @Test
113
+ fun measureStopTime_2minutes() {
114
+ runPerformanceTest(120_000L, "2 minute recording")
115
+ }
116
+
117
+ @Test
118
+ fun measureStopTime_5minutes() {
119
+ runPerformanceTest(300_000L, "5 minute recording")
120
+ }
121
+
122
+ @Test
123
+ fun measureStopTime_10minutes() {
124
+ runPerformanceTest(600_000L, "10 minute recording")
125
+ }
126
+
127
+ @Test
128
+ fun measureStopTime_15minutes() {
129
+ runPerformanceTest(900_000L, "15 minute recording")
130
+ }
131
+
132
+ private fun runPerformanceTest(recordingDurationMs: Long, testName: String) {
133
+ val recordingOptions = mapOf(
134
+ "sampleRate" to 44100,
135
+ "channels" to 1,
136
+ "encoding" to "pcm_16bit",
137
+ "interval" to 1000,
138
+ "enableProcessing" to false,
139
+ "showNotification" to false,
140
+ "output" to mapOf(
141
+ "primary" to mapOf("enabled" to true),
142
+ "compressed" to mapOf("enabled" to false)
143
+ )
144
+ )
145
+
146
+ // Start recording
147
+ val startLatch = CountDownLatch(1)
148
+ audioRecorderManager.startRecording(recordingOptions, object : Promise {
149
+ override fun resolve(value: Any?) {
150
+ startLatch.countDown()
151
+ }
152
+ override fun reject(code: String, message: String?, cause: Throwable?) {
153
+ fail("Start recording failed: $message")
154
+ }
155
+ })
156
+
157
+ assertTrue("Recording should start", startLatch.await(5, TimeUnit.SECONDS))
158
+ assertTrue("Recording should be active", audioRecorderManager.isRecording)
159
+
160
+ // Record for specified duration
161
+ Thread.sleep(recordingDurationMs)
162
+
163
+ // Measure stop time
164
+ val stopLatch = CountDownLatch(1)
165
+ var fileSize = 0L
166
+ var stopResult: Map<String, Any>? = null
167
+
168
+ val stopDuration = measureTimeMillis {
169
+ audioRecorderManager.stopRecording(object : Promise {
170
+ override fun resolve(value: Any?) {
171
+ when (value) {
172
+ is android.os.Bundle -> {
173
+ fileSize = value.getLong("size", 0)
174
+ stopResult = bundleToMap(value)
175
+ }
176
+ is Map<*, *> -> {
177
+ @Suppress("UNCHECKED_CAST")
178
+ stopResult = value as? Map<String, Any>
179
+ fileSize = (stopResult?.get("size") as? Long) ?: 0
180
+ }
181
+ }
182
+ stopLatch.countDown()
183
+ }
184
+ override fun reject(code: String, message: String?, cause: Throwable?) {
185
+ fail("Stop recording failed: $message")
186
+ }
187
+ })
188
+
189
+ assertTrue("Stop should complete", stopLatch.await(10, TimeUnit.SECONDS))
190
+ }
191
+
192
+ // Log results
193
+ val fileSizeMB = fileSize / (1024.0 * 1024.0)
194
+ Log.i(TAG, """
195
+ Performance Test: $testName
196
+ - Recording Duration: ${recordingDurationMs}ms
197
+ - Stop Duration: ${stopDuration}ms
198
+ - File Size: ${"%.2f".format(fileSizeMB)}MB
199
+ - Performance: ${if (stopDuration < getTargetTime(recordingDurationMs)) "PASS" else "FAIL"}
200
+ """.trimIndent())
201
+
202
+ println("""
203
+ Performance Test: $testName
204
+ - Recording Duration: ${recordingDurationMs}ms
205
+ - Stop Duration: ${stopDuration}ms
206
+ - File Size: ${"%.2f".format(fileSizeMB)}MB
207
+ - Performance: ${if (stopDuration < getTargetTime(recordingDurationMs)) "PASS" else "FAIL"}
208
+ """.trimIndent())
209
+
210
+ assertFalse("Recording should not be active", audioRecorderManager.isRecording)
211
+ }
212
+
213
+ private fun getTargetTime(recordingDurationMs: Long): Long {
214
+ return when {
215
+ recordingDurationMs <= 5_000 -> 100
216
+ recordingDurationMs <= 30_000 -> 150
217
+ recordingDurationMs <= 60_000 -> 200
218
+ recordingDurationMs <= 300_000 -> 500
219
+ else -> 750
220
+ }
221
+ }
222
+
223
+ private fun bundleToMap(bundle: android.os.Bundle): Map<String, Any> {
224
+ val map = mutableMapOf<String, Any>()
225
+ for (key in bundle.keySet()) {
226
+ val value = bundle.get(key)
227
+ when (value) {
228
+ is android.os.Bundle -> map[key] = bundleToMap(value)
229
+ else -> value?.let { map[key] = it }
230
+ }
231
+ }
232
+ return map
233
+ }
234
+ }
@@ -137,6 +137,10 @@ class AudioRecorderManager(
137
137
  var isPrepared = false
138
138
  private var selectedDeviceId: String? = null
139
139
  private var deviceDisconnectionBehavior: String? = null
140
+
141
+ // Cache file sizes to avoid file system calls during stop
142
+ private var cachedPrimaryFileSize: Long = 44L // Start with WAV header size
143
+ private var cachedCompressedFileSize: Long = 0L
140
144
 
141
145
  // Add a method to handle device changes
142
146
  fun handleDeviceChange() {
@@ -819,6 +823,10 @@ class AudioRecorderManager(
819
823
  streamUuid = java.util.UUID.randomUUID().toString()
820
824
  totalDataSize = 0
821
825
 
826
+ // Reset cached file sizes
827
+ cachedPrimaryFileSize = 44L // WAV header size
828
+ cachedCompressedFileSize = 0L
829
+
822
830
  // Only create file if primary output is enabled
823
831
  if (recordingConfig.output.primary.enabled) {
824
832
  audioFile = createRecordingFile(recordingConfig)
@@ -936,7 +944,19 @@ class AudioRecorderManager(
936
944
 
937
945
  _isRecording.set(false)
938
946
  isPrepared = false // Reset preparation state
939
- recordingThread?.join(1000)
947
+
948
+ // Calculate adaptive timeout based on estimated file size
949
+ // Assume ~5MB per minute at 44.1kHz, 16-bit, mono
950
+ val recordingDurationMs = if (recordingStartTime > 0) {
951
+ System.currentTimeMillis() - recordingStartTime - pausedDuration
952
+ } else {
953
+ 0L
954
+ }
955
+ val estimatedFileSizeMB = (recordingDurationMs / 60000.0) * 5.0
956
+ val timeoutMs = maxOf(2000L, (estimatedFileSizeMB * 100).toLong()) // 100ms per MB, min 2 seconds
957
+
958
+ LogUtils.d(CLASS_NAME, "Waiting for recording thread to complete with timeout: ${timeoutMs}ms (estimated size: ${estimatedFileSizeMB}MB)")
959
+ recordingThread?.join(timeoutMs)
940
960
 
941
961
  val audioData = ByteArray(bufferSizeInBytes)
942
962
  val bytesRead = audioRecord?.read(audioData, 0, bufferSizeInBytes) ?: -1
@@ -963,8 +983,9 @@ class AudioRecorderManager(
963
983
  AudioProcessor.resetUniqueIdCounter()
964
984
  audioProcessor.resetCumulativeAmplitudeRange()
965
985
 
966
- val fileSize = audioFile?.length() ?: 0
967
- LogUtils.d(CLASS_NAME, "WAV File validation - Size: $fileSize bytes, Path: ${audioFile?.absolutePath}")
986
+ // Use cached file size to avoid file system call
987
+ val fileSize = if (recordingConfig.output.primary.enabled) cachedPrimaryFileSize else 0L
988
+ LogUtils.d(CLASS_NAME, "WAV File validation - Size: $fileSize bytes (cached), Path: ${audioFile?.absolutePath}")
968
989
 
969
990
  // Calculate duration based on context - use actual recording time for streaming-only mode
970
991
  val duration = if (!recordingConfig.output.primary.enabled) {
@@ -997,9 +1018,12 @@ class AudioRecorderManager(
997
1018
  }
998
1019
  compressedRecorder = null
999
1020
 
1000
- // Log compressed file status if enabled
1021
+ // Log compressed file status if enabled - use actual file size for validation
1001
1022
  if (recordingConfig.output.compressed.enabled) {
1023
+ // Note: For compressed files, we need to get actual size as MediaRecorder handles the writing
1024
+ // Use actual file size here for validation purposes only
1002
1025
  val compressedSize = compressedFile?.length() ?: 0
1026
+ cachedCompressedFileSize = compressedSize // Update cache with final size
1003
1027
  LogUtils.d(CLASS_NAME, "Compressed File validation - Size: $compressedSize bytes, Path: ${compressedFile?.absolutePath}")
1004
1028
  }
1005
1029
 
@@ -1008,7 +1032,7 @@ class AudioRecorderManager(
1008
1032
  val localCompressedFile = compressedFile // Create local copy to avoid smart cast issues
1009
1033
  val compressionBundle = if (recordingConfig.output.compressed.enabled && localCompressedFile != null) {
1010
1034
  bundleOf(
1011
- "size" to localCompressedFile.length(),
1035
+ "size" to cachedCompressedFileSize, // Use cached size
1012
1036
  "mimeType" to if (recordingConfig.output.compressed.format == "aac") "audio/aac" else "audio/opus",
1013
1037
  "bitrate" to recordingConfig.output.compressed.bitrate,
1014
1038
  "format" to recordingConfig.output.compressed.format,
@@ -1040,7 +1064,7 @@ class AudioRecorderManager(
1040
1064
  "mimeType" to mimeType,
1041
1065
  "createdAt" to System.currentTimeMillis(),
1042
1066
  "compression" to if (compressedFile != null) bundleOf(
1043
- "size" to compressedFile?.length(),
1067
+ "size" to cachedCompressedFileSize, // Use cached size
1044
1068
  "mimeType" to if (recordingConfig.output.compressed.format == "aac") "audio/aac" else "audio/opus",
1045
1069
  "bitrate" to recordingConfig.output.compressed.bitrate,
1046
1070
  "format" to recordingConfig.output.compressed.format,
@@ -1195,7 +1219,8 @@ class AudioRecorderManager(
1195
1219
  )
1196
1220
  }
1197
1221
 
1198
- val fileSize = audioFile?.length() ?: 0
1222
+ // Use cached file size instead of file system call
1223
+ val fileSize = if (recordingConfig.output.primary.enabled) cachedPrimaryFileSize else 0L
1199
1224
  val duration = when (mimeType) {
1200
1225
  "audio/wav" -> {
1201
1226
  val dataFileSize = fileSize - Constants.WAV_HEADER_SIZE
@@ -1208,7 +1233,7 @@ class AudioRecorderManager(
1208
1233
 
1209
1234
  val compressionBundle = if (recordingConfig.output.compressed.enabled) {
1210
1235
  bundleOf(
1211
- "size" to (compressedFile?.length() ?: 0),
1236
+ "size" to cachedCompressedFileSize, // Use cached size
1212
1237
  "mimeType" to if (recordingConfig.output.compressed.format == "aac") "audio/aac" else "audio/opus",
1213
1238
  "bitrate" to recordingConfig.output.compressed.bitrate,
1214
1239
  "format" to recordingConfig.output.compressed.format
@@ -1380,6 +1405,7 @@ class AudioRecorderManager(
1380
1405
  // Only write to file if primary output is enabled
1381
1406
  if (fos != null) {
1382
1407
  fos.write(audioData, 0, bytesRead)
1408
+ cachedPrimaryFileSize += bytesRead // Update cached file size
1383
1409
  }
1384
1410
  totalDataSize += bytesRead
1385
1411
 
@@ -1440,16 +1466,17 @@ class AudioRecorderManager(
1440
1466
  }
1441
1467
  }
1442
1468
  } finally {
1443
- // Close the file output stream if it was opened
1469
+ // Flush and close the file output stream if it was opened
1470
+ try {
1471
+ fos?.flush()
1472
+ LogUtils.d(CLASS_NAME, "FileOutputStream flushed successfully")
1473
+ } catch (e: Exception) {
1474
+ LogUtils.e(CLASS_NAME, "Error flushing FileOutputStream", e)
1475
+ }
1444
1476
  fos?.close()
1445
1477
  }
1446
1478
 
1447
- // Update the WAV header to reflect the actual data size (only if file was created)
1448
- if (recordingConfig.output.primary.enabled) {
1449
- audioFile?.let { file ->
1450
- audioFileHandler.updateWavHeader(file)
1451
- }
1452
- }
1479
+ // WAV header update is already handled in cleanup(), no need to duplicate here
1453
1480
 
1454
1481
  } catch (e: Exception) {
1455
1482
  // Ensure wake lock is released if the thread is interrupted
@@ -1463,7 +1490,8 @@ class AudioRecorderManager(
1463
1490
  private fun emitAudioData(audioData: ByteArray, length: Int) {
1464
1491
  val encodedBuffer = audioDataEncoder.encodeToBase64(audioData)
1465
1492
 
1466
- val fileSize = audioFile?.length() ?: 0
1493
+ // Use cached file size instead of file system call
1494
+ val fileSize = if (recordingConfig.output.primary.enabled) cachedPrimaryFileSize else 0L
1467
1495
  val from = lastEmittedSize
1468
1496
  lastEmittedSize = fileSize
1469
1497
 
@@ -1472,7 +1500,14 @@ class AudioRecorderManager(
1472
1500
  (from * 1000) / (recordingConfig.sampleRate * recordingConfig.channels * (if (recordingConfig.encoding == "pcm_8bit") 8 else 16) / 8)
1473
1501
 
1474
1502
  val compressionBundle = if (recordingConfig.output.compressed.enabled) {
1475
- val compressedSize = compressedFile?.length() ?: 0
1503
+ // For compressed files, we need to get actual size as MediaRecorder handles the writing
1504
+ // Only update cache periodically to avoid frequent file system calls
1505
+ val currentTime = System.currentTimeMillis()
1506
+ if (cachedCompressedFileSize == 0L || (currentTime - lastEmittedCompressedSize) > 5000) {
1507
+ cachedCompressedFileSize = compressedFile?.length() ?: 0
1508
+ }
1509
+
1510
+ val compressedSize = cachedCompressedFileSize
1476
1511
  val eventDataSize = compressedSize - lastEmittedCompressedSize
1477
1512
 
1478
1513
  // Read the new compressed data