@siteed/expo-audio-studio 2.13.2 → 2.14.1

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,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  ## [Unreleased]
9
9
 
10
10
 
11
+ ## [2.14.1] - 2025-06-11
12
+ ### Changed
13
+ - fix(android): Fix duration returning 0 when primary output is disabled (#244) ([38d6f50](https://github.com/deeeed/expo-audio-stream/commit/38d6f50c084a10329be33a0f1c123aa9f457c371))
14
+ ## [2.14.0] - 2025-06-11
15
+ ### Changed
16
+ - feat(expo-audio-studio): comprehensive cross-platform stop recording performance optimization ([b3ed474](https://github.com/deeeed/expo-audio-stream/commit/b3ed474d91994698fe082354621adc98e758557e))
11
17
  ## [2.13.2] - 2025-06-10
12
18
  ### Changed
13
19
  - fix: invalid type exports ([18340ea](https://github.com/deeeed/expo-audio-stream/commit/18340eac9cfef39691637400fc6af811d5b004df))
@@ -316,7 +322,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
316
322
  - Feature: Audio features extraction during recording.
317
323
  - Feature: Consistent WAV PCM recording format across all platforms.
318
324
 
319
- [unreleased]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.13.2...HEAD
325
+ [unreleased]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.14.1...HEAD
326
+ [2.14.1]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.14.0...@siteed/expo-audio-studio@2.14.1
327
+ [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
320
328
  [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
321
329
  [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
322
330
  [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
@@ -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)
@@ -920,6 +928,10 @@ class AudioRecorderManager(
920
928
  return
921
929
  }
922
930
 
931
+ // Declare variables at the synchronized block level to ensure they're accessible in both try blocks
932
+ var duration: Long = 0
933
+ var fileSize: Long = 0
934
+
923
935
  try {
924
936
  if (isPaused.get()) {
925
937
  val remainingData = ByteArray(bufferSizeInBytes)
@@ -936,7 +948,19 @@ class AudioRecorderManager(
936
948
 
937
949
  _isRecording.set(false)
938
950
  isPrepared = false // Reset preparation state
939
- recordingThread?.join(1000)
951
+
952
+ // Calculate adaptive timeout based on estimated file size
953
+ // Assume ~5MB per minute at 44.1kHz, 16-bit, mono
954
+ val recordingDurationMs = if (recordingStartTime > 0) {
955
+ System.currentTimeMillis() - recordingStartTime - pausedDuration
956
+ } else {
957
+ 0L
958
+ }
959
+ val estimatedFileSizeMB = (recordingDurationMs / 60000.0) * 5.0
960
+ val timeoutMs = maxOf(2000L, (estimatedFileSizeMB * 100).toLong()) // 100ms per MB, min 2 seconds
961
+
962
+ LogUtils.d(CLASS_NAME, "Waiting for recording thread to complete with timeout: ${timeoutMs}ms (estimated size: ${estimatedFileSizeMB}MB)")
963
+ recordingThread?.join(timeoutMs)
940
964
 
941
965
  val audioData = ByteArray(bufferSizeInBytes)
942
966
  val bytesRead = audioRecord?.read(audioData, 0, bufferSizeInBytes) ?: -1
@@ -951,23 +975,11 @@ class AudioRecorderManager(
951
975
  audioRecord!!.stop()
952
976
  }
953
977
 
954
- cleanup()
955
- } catch (e: IllegalStateException) {
956
- LogUtils.e(CLASS_NAME, "Error reading from AudioRecord", e)
957
- } finally {
958
- releaseWakeLock()
959
- audioRecord?.release()
960
- }
961
-
962
- try {
963
- AudioProcessor.resetUniqueIdCounter()
964
- audioProcessor.resetCumulativeAmplitudeRange()
965
-
966
- val fileSize = audioFile?.length() ?: 0
967
- LogUtils.d(CLASS_NAME, "WAV File validation - Size: $fileSize bytes, Path: ${audioFile?.absolutePath}")
978
+ // Calculate duration BEFORE cleanup (which resets recordingStartTime)
979
+ fileSize = if (recordingConfig.output.primary.enabled) cachedPrimaryFileSize else 0L
980
+ LogUtils.d(CLASS_NAME, "WAV File validation - Size: $fileSize bytes (cached), Path: ${audioFile?.absolutePath}")
968
981
 
969
- // Calculate duration based on context - use actual recording time for streaming-only mode
970
- val duration = if (!recordingConfig.output.primary.enabled) {
982
+ duration = if (!recordingConfig.output.primary.enabled) {
971
983
  // For streaming-only mode, calculate duration from actual recording time
972
984
  val actualRecordingTime = if (recordingStartTime > 0) {
973
985
  System.currentTimeMillis() - recordingStartTime - pausedDuration
@@ -991,15 +1003,30 @@ class AudioRecorderManager(
991
1003
  fileDuration
992
1004
  }
993
1005
 
1006
+ cleanup()
1007
+ } catch (e: IllegalStateException) {
1008
+ LogUtils.e(CLASS_NAME, "Error reading from AudioRecord", e)
1009
+ } finally {
1010
+ releaseWakeLock()
1011
+ audioRecord?.release()
1012
+ }
1013
+
1014
+ try {
1015
+ AudioProcessor.resetUniqueIdCounter()
1016
+ audioProcessor.resetCumulativeAmplitudeRange()
1017
+
994
1018
  compressedRecorder?.apply {
995
1019
  stop()
996
1020
  release()
997
1021
  }
998
1022
  compressedRecorder = null
999
1023
 
1000
- // Log compressed file status if enabled
1024
+ // Log compressed file status if enabled - use actual file size for validation
1001
1025
  if (recordingConfig.output.compressed.enabled) {
1026
+ // Note: For compressed files, we need to get actual size as MediaRecorder handles the writing
1027
+ // Use actual file size here for validation purposes only
1002
1028
  val compressedSize = compressedFile?.length() ?: 0
1029
+ cachedCompressedFileSize = compressedSize // Update cache with final size
1003
1030
  LogUtils.d(CLASS_NAME, "Compressed File validation - Size: $compressedSize bytes, Path: ${compressedFile?.absolutePath}")
1004
1031
  }
1005
1032
 
@@ -1008,7 +1035,7 @@ class AudioRecorderManager(
1008
1035
  val localCompressedFile = compressedFile // Create local copy to avoid smart cast issues
1009
1036
  val compressionBundle = if (recordingConfig.output.compressed.enabled && localCompressedFile != null) {
1010
1037
  bundleOf(
1011
- "size" to localCompressedFile.length(),
1038
+ "size" to cachedCompressedFileSize, // Use cached size
1012
1039
  "mimeType" to if (recordingConfig.output.compressed.format == "aac") "audio/aac" else "audio/opus",
1013
1040
  "bitrate" to recordingConfig.output.compressed.bitrate,
1014
1041
  "format" to recordingConfig.output.compressed.format,
@@ -1040,7 +1067,7 @@ class AudioRecorderManager(
1040
1067
  "mimeType" to mimeType,
1041
1068
  "createdAt" to System.currentTimeMillis(),
1042
1069
  "compression" to if (compressedFile != null) bundleOf(
1043
- "size" to compressedFile?.length(),
1070
+ "size" to cachedCompressedFileSize, // Use cached size
1044
1071
  "mimeType" to if (recordingConfig.output.compressed.format == "aac") "audio/aac" else "audio/opus",
1045
1072
  "bitrate" to recordingConfig.output.compressed.bitrate,
1046
1073
  "format" to recordingConfig.output.compressed.format,
@@ -1195,20 +1222,32 @@ class AudioRecorderManager(
1195
1222
  )
1196
1223
  }
1197
1224
 
1198
- val fileSize = audioFile?.length() ?: 0
1199
- val duration = when (mimeType) {
1200
- "audio/wav" -> {
1201
- val dataFileSize = fileSize - Constants.WAV_HEADER_SIZE
1202
- val byteRate = recordingConfig.sampleRate * recordingConfig.channels *
1203
- (if (recordingConfig.encoding == "pcm_8bit") 8 else 16) / 8
1204
- if (byteRate > 0) dataFileSize * 1000 / byteRate else 0
1225
+ // Use cached file size instead of file system call
1226
+ val fileSize = if (recordingConfig.output.primary.enabled) cachedPrimaryFileSize else 0L
1227
+ val duration = if (!recordingConfig.output.primary.enabled) {
1228
+ // For streaming-only mode, calculate duration from actual recording time
1229
+ val actualRecordingTime = if (recordingStartTime > 0) {
1230
+ System.currentTimeMillis() - recordingStartTime - pausedDuration
1231
+ } else {
1232
+ 0L
1233
+ }
1234
+ actualRecordingTime
1235
+ } else {
1236
+ // For file-based recording, calculate duration from file size
1237
+ when (mimeType) {
1238
+ "audio/wav" -> {
1239
+ val dataFileSize = fileSize - Constants.WAV_HEADER_SIZE
1240
+ val byteRate = recordingConfig.sampleRate * recordingConfig.channels *
1241
+ (if (recordingConfig.encoding == "pcm_8bit") 8 else 16) / 8
1242
+ if (byteRate > 0) dataFileSize * 1000 / byteRate else 0
1243
+ }
1244
+ else -> totalRecordedTime
1205
1245
  }
1206
- else -> totalRecordedTime
1207
1246
  }
1208
1247
 
1209
1248
  val compressionBundle = if (recordingConfig.output.compressed.enabled) {
1210
1249
  bundleOf(
1211
- "size" to (compressedFile?.length() ?: 0),
1250
+ "size" to cachedCompressedFileSize, // Use cached size
1212
1251
  "mimeType" to if (recordingConfig.output.compressed.format == "aac") "audio/aac" else "audio/opus",
1213
1252
  "bitrate" to recordingConfig.output.compressed.bitrate,
1214
1253
  "format" to recordingConfig.output.compressed.format
@@ -1380,6 +1419,7 @@ class AudioRecorderManager(
1380
1419
  // Only write to file if primary output is enabled
1381
1420
  if (fos != null) {
1382
1421
  fos.write(audioData, 0, bytesRead)
1422
+ cachedPrimaryFileSize += bytesRead // Update cached file size
1383
1423
  }
1384
1424
  totalDataSize += bytesRead
1385
1425
 
@@ -1440,16 +1480,17 @@ class AudioRecorderManager(
1440
1480
  }
1441
1481
  }
1442
1482
  } finally {
1443
- // Close the file output stream if it was opened
1483
+ // Flush and close the file output stream if it was opened
1484
+ try {
1485
+ fos?.flush()
1486
+ LogUtils.d(CLASS_NAME, "FileOutputStream flushed successfully")
1487
+ } catch (e: Exception) {
1488
+ LogUtils.e(CLASS_NAME, "Error flushing FileOutputStream", e)
1489
+ }
1444
1490
  fos?.close()
1445
1491
  }
1446
1492
 
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
- }
1493
+ // WAV header update is already handled in cleanup(), no need to duplicate here
1453
1494
 
1454
1495
  } catch (e: Exception) {
1455
1496
  // Ensure wake lock is released if the thread is interrupted
@@ -1463,7 +1504,8 @@ class AudioRecorderManager(
1463
1504
  private fun emitAudioData(audioData: ByteArray, length: Int) {
1464
1505
  val encodedBuffer = audioDataEncoder.encodeToBase64(audioData)
1465
1506
 
1466
- val fileSize = audioFile?.length() ?: 0
1507
+ // Use cached file size instead of file system call
1508
+ val fileSize = if (recordingConfig.output.primary.enabled) cachedPrimaryFileSize else 0L
1467
1509
  val from = lastEmittedSize
1468
1510
  lastEmittedSize = fileSize
1469
1511
 
@@ -1472,7 +1514,14 @@ class AudioRecorderManager(
1472
1514
  (from * 1000) / (recordingConfig.sampleRate * recordingConfig.channels * (if (recordingConfig.encoding == "pcm_8bit") 8 else 16) / 8)
1473
1515
 
1474
1516
  val compressionBundle = if (recordingConfig.output.compressed.enabled) {
1475
- val compressedSize = compressedFile?.length() ?: 0
1517
+ // For compressed files, we need to get actual size as MediaRecorder handles the writing
1518
+ // Only update cache periodically to avoid frequent file system calls
1519
+ val currentTime = System.currentTimeMillis()
1520
+ if (cachedCompressedFileSize == 0L || (currentTime - lastEmittedCompressedSize) > 5000) {
1521
+ cachedCompressedFileSize = compressedFile?.length() ?: 0
1522
+ }
1523
+
1524
+ val compressedSize = cachedCompressedFileSize
1476
1525
  val eventDataSize = compressedSize - lastEmittedCompressedSize
1477
1526
 
1478
1527
  // Read the new compressed data
@@ -112,6 +112,10 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
112
112
 
113
113
  // Add the stopping flag to the class properties
114
114
  private var stopping: Bool = false
115
+
116
+ // Performance optimization: Cache file sizes during recording
117
+ private var cachedWavFileSize: Int64 = 0
118
+ private var cachedCompressedFileSize: Int64 = 0
115
119
 
116
120
  /// Initializes the AudioStreamManager
117
121
  override init() {
@@ -819,6 +823,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
819
823
  let header = createWavHeader(dataSize: 0)
820
824
  self.fileHandle?.write(header)
821
825
  self.totalDataSize = Int64(WAV_HEADER_SIZE) // Initialize size with header size
826
+ self.cachedWavFileSize = Int64(WAV_HEADER_SIZE) // Initialize cached size
822
827
  Logger.debug("AudioStreamManager", "File handle opened and initial header written for \(url.path). Initial size: \(self.totalDataSize)")
823
828
  } catch {
824
829
  Logger.debug("AudioStreamManager", "Error creating/opening file handle: \(error.localizedDescription)")
@@ -1462,6 +1467,8 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1462
1467
  try handle.write(contentsOf: dataToWrite)
1463
1468
  // Update total size state
1464
1469
  self.totalDataSize += Int64(dataToWrite.count)
1470
+ // Cache WAV file size for performance
1471
+ self.cachedWavFileSize = self.totalDataSize
1465
1472
  } catch {
1466
1473
  Logger.debug("BG Write Error: Failed to seek/write: \(error.localizedDescription)")
1467
1474
  }
@@ -1698,35 +1705,52 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1698
1705
  return nil
1699
1706
  }
1700
1707
 
1701
- if recordingSettings?.showNotification == true {
1702
- // Stop and clean up timer safely
1708
+ // PERFORMANCE OPTIMIZATION: Capture current state for immediate return
1709
+ let capturedFileURL = recordingFileURL
1710
+ let capturedSettings = recordingSettings
1711
+ let capturedWavFileSize = cachedWavFileSize
1712
+ let capturedCompressedFileSize = cachedCompressedFileSize
1713
+ let capturedTotalDataSize = totalDataSize
1714
+ let capturedCompressedURL = compressedFileURL
1715
+
1716
+ // PERFORMANCE OPTIMIZATION: Move all slow operations to background
1717
+ let capturedShowNotification = recordingSettings?.showNotification == true
1718
+
1719
+ // Queue notification and audio session cleanup for background
1720
+ DispatchQueue.global(qos: .utility).async { [weak self] in
1721
+ guard let self = self else { return }
1722
+
1723
+ if capturedShowNotification {
1724
+ // Clean up notifications on main queue but don't wait
1725
+ DispatchQueue.main.async {
1726
+ self.mediaInfoUpdateTimer?.invalidate()
1727
+ self.mediaInfoUpdateTimer = nil
1728
+
1729
+ // Clean up notification manager
1730
+ self.notificationManager?.stopUpdates()
1731
+ self.notificationManager = nil
1732
+
1733
+ // Clean up media controls
1734
+ UIApplication.shared.endReceivingRemoteControlEvents()
1735
+ self.remoteCommandCenter?.pauseCommand.isEnabled = false
1736
+ self.remoteCommandCenter?.playCommand.isEnabled = false
1737
+ self.notificationView?.nowPlayingInfo = nil
1738
+ }
1739
+ }
1740
+
1741
+ // Reset audio session in background
1742
+ do {
1743
+ try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
1744
+ } catch {
1745
+ Logger.debug("Background: Error deactivating audio session: \(error)")
1746
+ }
1747
+
1748
+ // Reset audio engine in background
1703
1749
  DispatchQueue.main.async {
1704
- self.mediaInfoUpdateTimer?.invalidate()
1705
- self.mediaInfoUpdateTimer = nil
1706
-
1707
- // Clean up notification manager
1708
- self.notificationManager?.stopUpdates()
1709
- self.notificationManager = nil
1710
-
1711
- // Clean up media controls
1712
- UIApplication.shared.endReceivingRemoteControlEvents()
1713
- self.remoteCommandCenter?.pauseCommand.isEnabled = false
1714
- self.remoteCommandCenter?.playCommand.isEnabled = false
1715
- self.notificationView?.nowPlayingInfo = nil
1750
+ self.audioEngine.reset()
1716
1751
  }
1717
1752
  }
1718
1753
 
1719
- // Reset audio session safely
1720
- do {
1721
- try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
1722
- } catch {
1723
- Logger.debug("Error deactivating audio session: \(error)")
1724
- // Continue with cleanup despite session errors
1725
- }
1726
-
1727
- // Reset audio engine
1728
- audioEngine.reset()
1729
-
1730
1754
  guard let settings = recordingSettings else {
1731
1755
  Logger.debug("Recording settings is nil.")
1732
1756
  stopping = false // Reset stopping flag before returning nil
@@ -1737,40 +1761,25 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1737
1761
  if !settings.output.primary.enabled {
1738
1762
  let durationMs = Int64(finalDuration * 1000)
1739
1763
 
1740
- // Check for compressed output even when primary is disabled
1764
+ // Check for compressed output using cached size
1741
1765
  var compression: CompressedRecordingInfo?
1742
- if settings.output.compressed.enabled, let compressedURL = compressedFileURL {
1743
- let compressedPath = compressedURL.path
1744
- if FileManager.default.fileExists(atPath: compressedPath) {
1745
- do {
1746
- let compressedAttributes = try FileManager.default.attributesOfItem(atPath: compressedPath)
1747
- let compressedSize = compressedAttributes[FileAttributeKey.size] as? Int64 ?? 0
1748
-
1749
- Logger.debug("""
1750
- Compressed File validation (primary disabled):
1751
- - Path: \(compressedPath)
1752
- - Format: \(compressedFormat)
1753
- - Size: \(compressedSize) bytes
1754
- - Bitrate: \(compressedBitRate) bps
1755
- """)
1756
-
1757
- if compressedSize > 0 {
1758
- compression = CompressedRecordingInfo(
1759
- compressedFileUri: compressedURL.absoluteString,
1760
- mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
1761
- bitrate: compressedBitRate,
1762
- format: compressedFormat,
1763
- size: compressedSize
1764
- )
1765
- } else {
1766
- Logger.debug("Warning: Compressed file exists but is empty")
1767
- }
1768
- } catch {
1769
- Logger.debug("Failed to validate compressed file: \(error)")
1770
- }
1771
- } else {
1772
- Logger.debug("Warning: Compressed file not found at path: \(compressedPath)")
1773
- }
1766
+ if settings.output.compressed.enabled,
1767
+ let compressedURL = capturedCompressedURL,
1768
+ capturedCompressedFileSize > 0 {
1769
+ compression = CompressedRecordingInfo(
1770
+ compressedFileUri: compressedURL.absoluteString,
1771
+ mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
1772
+ bitrate: compressedBitRate,
1773
+ format: compressedFormat,
1774
+ size: capturedCompressedFileSize
1775
+ )
1776
+
1777
+ Logger.debug("""
1778
+ Compressed File (cached - primary disabled):
1779
+ - Format: \(compressedFormat)
1780
+ - Size: \(capturedCompressedFileSize) bytes
1781
+ - Bitrate: \(compressedBitRate) bps
1782
+ """)
1774
1783
  }
1775
1784
 
1776
1785
  let result = RecordingResult(
@@ -1804,140 +1813,79 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1804
1813
  return result
1805
1814
  }
1806
1815
 
1807
- guard let fileURL = recordingFileURL else {
1816
+ guard let fileURL = capturedFileURL else {
1808
1817
  Logger.debug("Recording file URL is nil.")
1809
1818
  stopping = false // Reset stopping flag before returning nil
1810
1819
  return nil
1811
1820
  }
1812
1821
 
1813
- // Reset stopping flag before returning
1814
- let result = createRecordingResult(fileURL: fileURL, settings: settings, finalDuration: finalDuration)
1815
- stopping = false
1822
+ // PERFORMANCE OPTIMIZATION: Create result immediately with cached values
1823
+ let durationMs = Int64(finalDuration * 1000)
1816
1824
 
1817
- // Return after all cleanup tasks are completed
1818
- return result
1819
- }
1820
-
1821
- /// Creates a RecordingResult from the finished recording
1822
- /// - Parameters:
1823
- /// - fileURL: The URL of the recording file
1824
- /// - settings: The settings used for recording
1825
- /// - finalDuration: The final duration of the recording
1826
- /// - Returns: A RecordingResult object or nil if validation fails
1827
- private func createRecordingResult(fileURL: URL, settings: RecordingSettings, finalDuration: TimeInterval) -> RecordingResult? {
1828
- // Validate WAV file
1829
- let wavPath = fileURL.path
1830
- do {
1831
- // Check if WAV file exists
1832
- let wavFileAttributes = try FileManager.default.attributesOfItem(atPath: wavPath)
1833
- let wavFileSize = wavFileAttributes[FileAttributeKey.size] as? Int64 ?? 0
1834
-
1835
- Logger.debug("""
1836
- WAV File validation:
1837
- - Path: \(wavPath)
1838
- - Exists: true
1839
- - Size: \(wavFileSize) bytes
1840
- - Duration: \(finalDuration) seconds
1841
- - Expected minimum size: \(WAV_HEADER_SIZE) bytes (WAV header)
1842
- """)
1843
-
1844
- // Use the final totalDataSize tracked by the background queue
1845
- let finalDataChunkSize = self.totalDataSize - Int64(WAV_HEADER_SIZE)
1846
- if finalDataChunkSize <= 0 {
1847
- Logger.debug("Recording file data chunk size is zero or negative (\(finalDataChunkSize) bytes), likely no audio data was recorded successfully after header")
1848
- // Optionally delete the empty file?
1849
- // try? FileManager.default.removeItem(at: fileURL)
1850
- return nil
1851
- }
1852
-
1853
- // Update the WAV header with the correct final file size
1854
- updateWavHeader(fileURL: fileURL, totalDataSize: finalDataChunkSize)
1855
- Logger.debug("Final WAV header updated. Data chunk size: \(finalDataChunkSize)")
1856
-
1857
- // Validate compressed file if enabled
1858
- var compression: CompressedRecordingInfo?
1859
- if let compressedURL = compressedFileURL {
1860
- let compressedPath = compressedURL.path
1861
- if FileManager.default.fileExists(atPath: compressedPath) {
1862
- let compressedAttributes = try FileManager.default.attributesOfItem(atPath: compressedPath)
1863
- let compressedSize = compressedAttributes[FileAttributeKey.size] as? Int64 ?? 0
1864
-
1865
- Logger.debug("""
1866
- Compressed File validation:
1867
- - Path: \(compressedPath)
1868
- - Format: \(compressedFormat)
1869
- - Size: \(compressedSize) bytes
1870
- - Bitrate: \(compressedBitRate) bps
1871
- """)
1872
-
1873
- if compressedSize > 0 {
1874
- compression = CompressedRecordingInfo(
1875
- compressedFileUri: compressedURL.absoluteString,
1876
- mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
1877
- bitrate: compressedBitRate,
1878
- format: compressedFormat,
1879
- size: compressedSize
1880
- )
1881
- } else {
1882
- Logger.debug("Warning: Compressed file exists but is empty")
1883
- }
1884
- } else {
1885
- Logger.debug("Warning: Compressed file not found at path: \(compressedPath)")
1886
- }
1887
- }
1888
-
1889
- let durationMs = Int64(finalDuration * 1000)
1890
-
1891
- let result = RecordingResult(
1892
- fileUri: fileURL.absoluteString,
1893
- filename: fileURL.lastPathComponent,
1894
- mimeType: mimeType,
1895
- duration: durationMs,
1896
- size: wavFileSize,
1897
- channels: settings.numberOfChannels,
1898
- bitDepth: settings.bitDepth,
1899
- sampleRate: settings.sampleRate,
1900
- compression: compression
1825
+ // Check compressed output
1826
+ var compression: CompressedRecordingInfo?
1827
+ if capturedSettings?.output.compressed.enabled == true,
1828
+ let compressedURL = capturedCompressedURL,
1829
+ capturedCompressedFileSize > 0 {
1830
+ compression = CompressedRecordingInfo(
1831
+ compressedFileUri: compressedURL.absoluteString,
1832
+ mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
1833
+ bitrate: compressedBitRate,
1834
+ format: compressedFormat,
1835
+ size: capturedCompressedFileSize
1901
1836
  )
1837
+ }
1838
+
1839
+ // Create result with cached values - no file system access
1840
+ let result = RecordingResult(
1841
+ fileUri: fileURL.absoluteString,
1842
+ filename: fileURL.lastPathComponent,
1843
+ mimeType: mimeType,
1844
+ duration: durationMs,
1845
+ size: capturedWavFileSize,
1846
+ channels: capturedSettings?.numberOfChannels ?? 1,
1847
+ bitDepth: capturedSettings?.bitDepth ?? 16,
1848
+ sampleRate: capturedSettings?.sampleRate ?? 44100,
1849
+ compression: compression
1850
+ )
1851
+
1852
+ // Perform file operations asynchronously after returning result
1853
+ DispatchQueue.global(qos: .utility).async { [weak self] in
1854
+ guard let self = self else { return }
1902
1855
 
1903
- Logger.debug("""
1904
- Recording completed successfully:
1905
- - WAV file: \(fileURL.lastPathComponent)
1906
- - Size: \(wavFileSize) bytes
1907
- - Duration: \(durationMs)ms
1908
- - Sample rate: \(settings.sampleRate)Hz
1909
- - Bit depth: \(settings.bitDepth)-bit
1910
- - Channels: \(settings.numberOfChannels)
1911
- - Compressed: \(compression != nil ? "yes" : "no")
1912
- """)
1913
-
1914
- // Additional cleanup
1915
- recordingFileURL = nil
1916
- lastBufferTime = nil
1917
- lastValidDuration = nil
1918
- compressedRecorder = nil
1919
- compressedFileURL = nil
1920
- recordingSettings = nil
1921
- startTime = nil
1922
- totalPausedDuration = 0
1923
- currentPauseStart = nil
1924
- lastEmissionTime = nil
1925
- lastEmissionTimeAnalysis = nil
1926
- lastEmittedSize = 0
1927
- lastEmittedSizeAnalysis = 0
1928
- lastEmittedCompressedSize = 0
1929
- accumulatedData.removeAll()
1930
- accumulatedAnalysisData.removeAll()
1931
- recordingUUID = nil
1932
-
1933
- return result
1856
+ // Update WAV header in background
1857
+ let finalDataChunkSize = capturedTotalDataSize - Int64(WAV_HEADER_SIZE)
1858
+ if finalDataChunkSize > 0 {
1859
+ self.updateWavHeader(fileURL: fileURL, totalDataSize: finalDataChunkSize)
1860
+ Logger.debug("Background: WAV header updated. Data chunk size: \(finalDataChunkSize)")
1861
+ }
1934
1862
 
1935
- } catch {
1936
- Logger.debug("Failed to validate recording files: \(error)")
1937
- return nil
1863
+ // Cleanup
1864
+ self.recordingSettings = nil
1865
+ self.startTime = nil
1866
+ self.totalPausedDuration = 0
1867
+ self.currentPauseStart = nil
1868
+ self.lastEmissionTime = nil
1869
+ self.lastEmissionTimeAnalysis = nil
1870
+ self.lastEmittedSize = 0
1871
+ self.lastEmittedSizeAnalysis = 0
1872
+ self.lastEmittedCompressedSize = 0
1873
+ self.accumulatedData.removeAll()
1874
+ self.accumulatedAnalysisData.removeAll()
1875
+ self.recordingUUID = nil
1876
+ self.totalDataSize = 0
1877
+ self.cachedWavFileSize = 0
1878
+ self.cachedCompressedFileSize = 0
1879
+ self.recordingFileURL = nil
1880
+ self.compressedFileURL = nil
1881
+ self.fileHandle = nil
1938
1882
  }
1883
+
1884
+ stopping = false
1885
+ return result
1939
1886
  }
1940
1887
 
1888
+
1941
1889
  // MARK: - AudioDeviceManagerDelegate Implementation
1942
1890
 
1943
1891
  func audioDeviceManager(_ manager: AudioDeviceManager, didDetectDisconnectionOfDevice disconnectedDeviceId: String) {
@@ -2297,6 +2245,19 @@ extension AudioStreamManager: AVAudioRecorderDelegate {
2297
2245
  Logger.debug("Compressed recording finished - success: \(flag)")
2298
2246
  if !flag {
2299
2247
  delegate?.audioStreamManager(self, didFailWithError: "Compressed recording failed to complete")
2248
+ } else {
2249
+ // Update cached compressed file size when recording finishes
2250
+ if let compressedURL = compressedFileURL {
2251
+ do {
2252
+ let attributes = try FileManager.default.attributesOfItem(atPath: compressedURL.path)
2253
+ if let size = attributes[.size] as? Int64 {
2254
+ cachedCompressedFileSize = size
2255
+ Logger.debug("Cached compressed file size: \(size) bytes")
2256
+ }
2257
+ } catch {
2258
+ Logger.debug("Failed to cache compressed file size: \(error)")
2259
+ }
2260
+ }
2300
2261
  }
2301
2262
  }
2302
2263
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siteed/expo-audio-studio",
3
- "version": "2.13.2",
3
+ "version": "2.14.1",
4
4
  "description": "Comprehensive audio processing library for React Native and Expo with recording, analysis, visualization, and streaming capabilities across iOS, Android, and web",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",