@siteed/expo-audio-studio 2.13.2 → 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,9 @@ 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))
11
14
  ## [2.13.2] - 2025-06-10
12
15
  ### Changed
13
16
  - fix: invalid type exports ([18340ea](https://github.com/deeeed/expo-audio-stream/commit/18340eac9cfef39691637400fc6af811d5b004df))
@@ -316,7 +319,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
316
319
  - Feature: Audio features extraction during recording.
317
320
  - Feature: Consistent WAV PCM recording format across all platforms.
318
321
 
319
- [unreleased]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.13.2...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
320
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
321
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
322
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
@@ -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
@@ -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.0",
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",