@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.
|
|
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
|
-
|
|
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
|
-
|
|
967
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1702
|
-
|
|
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.
|
|
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
|
|
1764
|
+
// Check for compressed output using cached size
|
|
1741
1765
|
var compression: CompressedRecordingInfo?
|
|
1742
|
-
if settings.output.compressed.enabled,
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
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 =
|
|
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
|
-
//
|
|
1814
|
-
let
|
|
1815
|
-
stopping = false
|
|
1822
|
+
// PERFORMANCE OPTIMIZATION: Create result immediately with cached values
|
|
1823
|
+
let durationMs = Int64(finalDuration * 1000)
|
|
1816
1824
|
|
|
1817
|
-
//
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
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
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
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
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
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.
|
|
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",
|