@siteed/expo-audio-stream 1.9.1 → 1.10.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 +41 -1
- package/android/src/main/java/net/siteed/audiostream/AudioNotificationsManager.kt +89 -33
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +7 -0
- package/ios/AudioStreamManager.swift +74 -42
- package/ios/RecordingSettings.swift +12 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,24 +8,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
## [1.10.0] - 2025-01-14
|
|
12
|
+
- add support for pausing and resuming compressed recordings ([bc3f629](https://github.com/deeeed/expo-audio-stream/commit/bc3f6295d060396325e0f008ff00b3be9c8722cd))
|
|
13
|
+
- optimize notification channel settings ([daa075e](https://github.com/deeeed/expo-audio-stream/commit/daa075e668f8faf0b8d2849e18c37384bdd293b8))
|
|
14
|
+
|
|
15
|
+
## [1.9.2] - 2025-01-12
|
|
16
|
+
- ios bitrate verification to prevent invalid values ([035a180](https://github.com/deeeed/expo-audio-stream/commit/035a1800833264edcc59724aaa8a2e12d5c78dc2))
|
|
17
|
+
|
|
18
|
+
|
|
11
19
|
## [1.9.1] - 2025-01-12
|
|
12
20
|
- ios potentially missing compressed file info ([88a628c](https://github.com/deeeed/expo-audio-stream/commit/88a628c35f2bfd626a2a5de1eb6950efd814619d))
|
|
13
21
|
|
|
22
|
+
|
|
23
|
+
|
|
14
24
|
## [1.9.0] - 2025-01-11
|
|
15
25
|
- feat(web-audio): optimize memory usage and streaming performance for web audio recording (#75) ([7b93e12](https://github.com/deeeed/expo-audio-stream/commit/7b93e12aae4bc0599b06b48ca34a60f65587fc75))
|
|
16
26
|
|
|
17
27
|
|
|
28
|
+
|
|
29
|
+
|
|
18
30
|
## [1.8.0] - 2025-01-10
|
|
19
31
|
- feat(audio): implement audio compression support ([ff4e060](https://github.com/deeeed/expo-audio-stream/commit/ff4e060fef1061804c1cc0126d4344d2d50daa9a))
|
|
20
32
|
|
|
21
33
|
|
|
22
34
|
|
|
35
|
+
|
|
36
|
+
|
|
23
37
|
## [1.7.2] - 2025-01-07
|
|
24
38
|
- fix(audio-stream): correct WAV header handling in web audio recording ([9ba7de5](https://github.com/deeeed/expo-audio-stream/commit/9ba7de5b96ca4cc937dea261c80d3fda9c99e8f4))
|
|
25
39
|
|
|
26
40
|
|
|
27
41
|
|
|
28
42
|
|
|
43
|
+
|
|
44
|
+
|
|
29
45
|
## [1.7.1] - 2025-01-07
|
|
30
46
|
- update notification to avoid triggering new alerts (#71) ([32dcfc5](https://github.com/deeeed/expo-audio-stream/commit/32dcfc55daf3236babefc17016f329c177d466fd))
|
|
31
47
|
|
|
@@ -33,6 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
33
49
|
|
|
34
50
|
|
|
35
51
|
|
|
52
|
+
|
|
53
|
+
|
|
36
54
|
## [1.7.0] - 2025-01-05
|
|
37
55
|
- feat(playground): enhance app configuration and build setup for production deployment (#58) ([929d443](https://github.com/deeeed/expo-audio-stream/commit/929d443145378b1430d215db5c00b13758420e2b))
|
|
38
56
|
- chore(expo-audio-stream): release @siteed/expo-audio-stream@1.6.1 ([084e8ad](https://github.com/deeeed/expo-audio-stream/commit/084e8adb91da7874c9e608b55d9c7b2ffd7a8327))
|
|
@@ -46,6 +64,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
46
64
|
|
|
47
65
|
|
|
48
66
|
|
|
67
|
+
|
|
68
|
+
|
|
49
69
|
## [1.6.1] - 2024-12-11
|
|
50
70
|
- chore(expo-audio-stream): remove git commit step from publish script ([4a772ce](https://github.com/deeeed/expo-audio-stream/commit/4a772ce93bb7405d9b8e981f46bdf8941a71ecfe))
|
|
51
71
|
- chore: more publishing automation ([3693021](https://github.com/deeeed/expo-audio-stream/commit/369302107f9dca9dddd8ae68e6214481a39976ac))
|
|
@@ -63,6 +83,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
63
83
|
|
|
64
84
|
|
|
65
85
|
|
|
86
|
+
|
|
87
|
+
|
|
66
88
|
## [1.5.0] - 2024-12-10
|
|
67
89
|
- UNPUBLISHED because of a bug in the build system
|
|
68
90
|
|
|
@@ -73,6 +95,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
73
95
|
|
|
74
96
|
|
|
75
97
|
|
|
98
|
+
|
|
99
|
+
|
|
76
100
|
## [1.4.0] - 2024-12-05
|
|
77
101
|
- chore: remove unusded dependencies ([ad81dd5](https://github.com/deeeed/expo-audio-stream/commit/ad81dd560c93dd1d04995a323a4ae72d4de20f3e))
|
|
78
102
|
|
|
@@ -83,6 +107,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
83
107
|
|
|
84
108
|
|
|
85
109
|
|
|
110
|
+
|
|
111
|
+
|
|
86
112
|
## [1.3.1] - 2024-12-05
|
|
87
113
|
- feat(web): implement throttling and optimize event processing (#49) ([da28765](https://github.com/deeeed/expo-audio-stream/commit/da2876524c2c9d6e0a980fde40a0197b929d8a7f))
|
|
88
114
|
|
|
@@ -93,6 +119,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
93
119
|
|
|
94
120
|
|
|
95
121
|
|
|
122
|
+
|
|
123
|
+
|
|
96
124
|
## [1.3.0] - 2024-11-28
|
|
97
125
|
### Added
|
|
98
126
|
- refactor(permissions): standardize permission status response structure across platforms (#44) ([7c9c800](https://github.com/deeeed/expo-audio-stream/commit/7c9c800d83b7cea3516643371484d5e1f3b99e4c))
|
|
@@ -108,6 +136,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
108
136
|
|
|
109
137
|
|
|
110
138
|
|
|
139
|
+
|
|
140
|
+
|
|
111
141
|
## [1.2.5] - 2024-11-12
|
|
112
142
|
### Added
|
|
113
143
|
- docs(license): add MIT license to all packages (6 files changed)
|
|
@@ -120,6 +150,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
120
150
|
|
|
121
151
|
|
|
122
152
|
|
|
153
|
+
|
|
154
|
+
|
|
123
155
|
## [1.2.4] - 2024-11-05
|
|
124
156
|
### Changed
|
|
125
157
|
- Android minimum audio interval set to 10ms.
|
|
@@ -135,6 +167,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
135
167
|
|
|
136
168
|
|
|
137
169
|
|
|
170
|
+
|
|
171
|
+
|
|
138
172
|
## [1.2.0] - 2024-10-24
|
|
139
173
|
### Added
|
|
140
174
|
- Feature: Keep device awake during recording with `keepAwake` option
|
|
@@ -150,6 +184,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
150
184
|
|
|
151
185
|
|
|
152
186
|
|
|
187
|
+
|
|
188
|
+
|
|
153
189
|
## [1.1.17] - 2024-10-21
|
|
154
190
|
### Added
|
|
155
191
|
- Support bluetooth headset on ios
|
|
@@ -162,6 +198,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
162
198
|
|
|
163
199
|
|
|
164
200
|
|
|
201
|
+
|
|
202
|
+
|
|
165
203
|
## [1.0.0] - 2024-04-01
|
|
166
204
|
### Added
|
|
167
205
|
- Initial release of @siteed/expo-audio-stream.
|
|
@@ -172,7 +210,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
172
210
|
- Feature: Audio features extraction during recording.
|
|
173
211
|
- Feature: Consistent WAV PCM recording format across all platforms.
|
|
174
212
|
|
|
175
|
-
[unreleased]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.
|
|
213
|
+
[unreleased]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.10.0...HEAD
|
|
214
|
+
[1.10.0]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.9.2...@siteed/expo-audio-stream@1.10.0
|
|
215
|
+
[1.9.2]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.9.1...@siteed/expo-audio-stream@1.9.2
|
|
176
216
|
[1.9.1]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.9.0...@siteed/expo-audio-stream@1.9.1
|
|
177
217
|
[1.9.0]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.8.0...@siteed/expo-audio-stream@1.9.0
|
|
178
218
|
[1.8.0]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.7.2...@siteed/expo-audio-stream@1.8.0
|
|
@@ -18,6 +18,7 @@ import androidx.core.app.NotificationCompat
|
|
|
18
18
|
import java.lang.ref.WeakReference
|
|
19
19
|
import java.util.Locale
|
|
20
20
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
21
|
+
import java.util.Objects
|
|
21
22
|
|
|
22
23
|
class AudioNotificationManager private constructor(context: Context) {
|
|
23
24
|
private val contextRef = WeakReference(context.applicationContext)
|
|
@@ -40,6 +41,7 @@ class AudioNotificationManager private constructor(context: Context) {
|
|
|
40
41
|
private var lastPauseTime: Long = 0
|
|
41
42
|
private var lastWaveformUpdate: Long = 0
|
|
42
43
|
private val waveformRenderer = WaveformRenderer()
|
|
44
|
+
private var lastNotificationHash: Int? = null
|
|
43
45
|
|
|
44
46
|
companion object {
|
|
45
47
|
private const val WAVEFORM_UPDATE_INTERVAL = 100L
|
|
@@ -66,21 +68,16 @@ class AudioNotificationManager private constructor(context: Context) {
|
|
|
66
68
|
val channel = NotificationChannel(
|
|
67
69
|
recordingConfig.notification.channelId,
|
|
68
70
|
recordingConfig.notification.channelName,
|
|
69
|
-
NotificationManager.IMPORTANCE_LOW
|
|
71
|
+
NotificationManager.IMPORTANCE_LOW
|
|
70
72
|
).apply {
|
|
71
73
|
description = recordingConfig.notification.channelDescription
|
|
72
|
-
enableLights(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
setShowBadge(
|
|
74
|
+
enableLights(false)
|
|
75
|
+
enableVibration(false)
|
|
76
|
+
setSound(null, null)
|
|
77
|
+
setShowBadge(false)
|
|
78
|
+
vibrationPattern = null
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
// Set description, disable lights, vibration, and sound.
|
|
79
|
-
channel.enableLights(false);
|
|
80
|
-
channel.enableVibration(false);
|
|
81
|
-
channel.setSound(null, null);
|
|
82
|
-
channel.setShowBadge(false);
|
|
83
|
-
|
|
84
81
|
notificationManager.createNotificationChannel(channel)
|
|
85
82
|
}
|
|
86
83
|
}
|
|
@@ -134,6 +131,9 @@ class AudioNotificationManager private constructor(context: Context) {
|
|
|
134
131
|
}
|
|
135
132
|
|
|
136
133
|
private fun addNotificationActions(context: Context) {
|
|
134
|
+
// Clear existing actions first
|
|
135
|
+
notificationBuilder.clearActions()
|
|
136
|
+
|
|
137
137
|
// Create pause action
|
|
138
138
|
val pauseIntent = Intent(context, RecordingActionReceiver::class.java).apply {
|
|
139
139
|
action = RecordingActionReceiver.ACTION_PAUSE_RECORDING
|
|
@@ -156,7 +156,7 @@ class AudioNotificationManager private constructor(context: Context) {
|
|
|
156
156
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
157
157
|
)
|
|
158
158
|
|
|
159
|
-
// Add
|
|
159
|
+
// Add only one pause/resume action based on current state
|
|
160
160
|
if (isPaused.get()) {
|
|
161
161
|
notificationBuilder.addAction(
|
|
162
162
|
R.drawable.ic_play,
|
|
@@ -171,20 +171,23 @@ class AudioNotificationManager private constructor(context: Context) {
|
|
|
171
171
|
)
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
// Add configured custom actions
|
|
174
|
+
// Add configured custom actions (only if they don't already exist)
|
|
175
|
+
val existingActions = mutableSetOf<String>()
|
|
175
176
|
recordingConfig.notification.actions.forEach { action ->
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
if (existingActions.add(action.intentAction)) { // Only add if action is unique
|
|
178
|
+
val intent = Intent(context, RecordingActionReceiver::class.java).apply {
|
|
179
|
+
this.action = action.intentAction
|
|
180
|
+
}
|
|
181
|
+
val pendingIntent = PendingIntent.getBroadcast(
|
|
182
|
+
context,
|
|
183
|
+
action.intentAction.hashCode(),
|
|
184
|
+
intent,
|
|
185
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
186
|
+
)
|
|
187
|
+
val actionIconResId = action.icon?.let { getResourceIdByName(it) }
|
|
188
|
+
?: R.drawable.ic_default_action_icon
|
|
189
|
+
notificationBuilder.addAction(actionIconResId, action.title, pendingIntent)
|
|
178
190
|
}
|
|
179
|
-
val pendingIntent = PendingIntent.getBroadcast(
|
|
180
|
-
context,
|
|
181
|
-
action.intentAction.hashCode(),
|
|
182
|
-
intent,
|
|
183
|
-
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
184
|
-
)
|
|
185
|
-
val actionIconResId = action.icon?.let { getResourceIdByName(it) }
|
|
186
|
-
?: R.drawable.ic_default_action_icon
|
|
187
|
-
notificationBuilder.addAction(actionIconResId, action.title, pendingIntent)
|
|
188
191
|
}
|
|
189
192
|
}
|
|
190
193
|
|
|
@@ -232,22 +235,43 @@ class AudioNotificationManager private constructor(context: Context) {
|
|
|
232
235
|
|
|
233
236
|
try {
|
|
234
237
|
val currentTime = SystemClock.elapsedRealtime()
|
|
238
|
+
|
|
239
|
+
// Calculate current notification state
|
|
240
|
+
val recordingDuration = if (isPaused.get()) {
|
|
241
|
+
lastPauseTime - recordingStartTime - pausedDuration
|
|
242
|
+
} else {
|
|
243
|
+
System.currentTimeMillis() - recordingStartTime - pausedDuration
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Create a hash of the current notification state
|
|
247
|
+
val currentHash = Objects.hash(
|
|
248
|
+
recordingConfig.notification.title,
|
|
249
|
+
recordingConfig.notification.text,
|
|
250
|
+
formatDuration(recordingDuration),
|
|
251
|
+
isPaused.get()
|
|
252
|
+
)
|
|
253
|
+
|
|
235
254
|
val needsRemoteViewsRefresh = currentTime - lastRemoteViewsUpdate >= remoteViewsRefreshInterval ||
|
|
236
255
|
consecutiveUpdateFailures >= maxUpdateFailures
|
|
237
256
|
|
|
257
|
+
// Only update if content changed or refresh needed
|
|
258
|
+
if (currentHash == lastNotificationHash && !needsRemoteViewsRefresh) {
|
|
259
|
+
// Update waveform only if needed
|
|
260
|
+
if (shouldUpdateWaveform(audioData, currentTime)) {
|
|
261
|
+
updateWaveformOnly(audioData)
|
|
262
|
+
}
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
lastNotificationHash = currentHash
|
|
267
|
+
|
|
268
|
+
// Only recreate RemoteViews periodically or after failures
|
|
238
269
|
if (needsRemoteViewsRefresh) {
|
|
239
|
-
// Only recreate RemoteViews periodically or after failures
|
|
240
270
|
remoteViews = RemoteViews(context.packageName, R.layout.notification_recording)
|
|
241
271
|
lastRemoteViewsUpdate = currentTime
|
|
242
272
|
consecutiveUpdateFailures = 0
|
|
243
273
|
}
|
|
244
274
|
|
|
245
|
-
val recordingDuration = if (isPaused.get()) {
|
|
246
|
-
lastPauseTime - recordingStartTime - pausedDuration
|
|
247
|
-
} else {
|
|
248
|
-
System.currentTimeMillis() - recordingStartTime - pausedDuration
|
|
249
|
-
}
|
|
250
|
-
|
|
251
275
|
// Update RemoteViews content
|
|
252
276
|
remoteViews.apply {
|
|
253
277
|
setTextViewText(R.id.notification_title, recordingConfig.notification.title)
|
|
@@ -280,11 +304,13 @@ class AudioNotificationManager private constructor(context: Context) {
|
|
|
280
304
|
addNotificationActions(context)
|
|
281
305
|
}
|
|
282
306
|
|
|
283
|
-
// Update the notification
|
|
307
|
+
// Update the notification with disabled alerts
|
|
284
308
|
notificationManager.notify(
|
|
285
309
|
recordingConfig.notification.notificationId,
|
|
286
310
|
notificationBuilder
|
|
287
311
|
.setOnlyAlertOnce(true)
|
|
312
|
+
.setVibrate(null)
|
|
313
|
+
.setDefaults(0)
|
|
288
314
|
.build()
|
|
289
315
|
)
|
|
290
316
|
|
|
@@ -300,6 +326,37 @@ class AudioNotificationManager private constructor(context: Context) {
|
|
|
300
326
|
}
|
|
301
327
|
}
|
|
302
328
|
}
|
|
329
|
+
|
|
330
|
+
private fun shouldUpdateWaveform(audioData: FloatArray?, currentTime: Long): Boolean {
|
|
331
|
+
return recordingConfig.showWaveformInNotification &&
|
|
332
|
+
audioData != null &&
|
|
333
|
+
audioData.isNotEmpty() &&
|
|
334
|
+
currentTime - lastWaveformUpdate >= WAVEFORM_UPDATE_INTERVAL
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private fun updateWaveformOnly(audioData: FloatArray?) {
|
|
338
|
+
if (audioData == null) return
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
val waveformBitmap = waveformRenderer.generateWaveform(audioData, recordingConfig.notification.waveform)
|
|
342
|
+
remoteViews.setImageViewBitmap(R.id.notification_waveform, waveformBitmap)
|
|
343
|
+
lastWaveformUpdate = SystemClock.elapsedRealtime()
|
|
344
|
+
|
|
345
|
+
notificationManager.notify(
|
|
346
|
+
recordingConfig.notification.notificationId,
|
|
347
|
+
notificationBuilder
|
|
348
|
+
.setCustomContentView(remoteViews)
|
|
349
|
+
.setCustomBigContentView(remoteViews)
|
|
350
|
+
.setOnlyAlertOnce(true)
|
|
351
|
+
.setVibrate(null)
|
|
352
|
+
.setDefaults(0)
|
|
353
|
+
.build()
|
|
354
|
+
)
|
|
355
|
+
} catch (e: Exception) {
|
|
356
|
+
Log.e(Constants.TAG, "Error updating waveform", e)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
303
360
|
private fun reinitializeNotification() {
|
|
304
361
|
try {
|
|
305
362
|
val context = contextRef.get() ?: return
|
|
@@ -322,7 +379,6 @@ class AudioNotificationManager private constructor(context: Context) {
|
|
|
322
379
|
}
|
|
323
380
|
}
|
|
324
381
|
|
|
325
|
-
|
|
326
382
|
fun getNotification(): Notification = notificationBuilder.build()
|
|
327
383
|
|
|
328
384
|
fun pauseUpdates() {
|
|
@@ -539,7 +539,11 @@ class AudioRecorderManager(
|
|
|
539
539
|
acquireWakeLock()
|
|
540
540
|
pausedDuration += System.currentTimeMillis() - lastPauseTime
|
|
541
541
|
isPaused.set(false)
|
|
542
|
+
|
|
543
|
+
// Add these lines to resume both recordings
|
|
542
544
|
audioRecord?.startRecording()
|
|
545
|
+
compressedRecorder?.resume()
|
|
546
|
+
|
|
543
547
|
promise.resolve("Recording resumed")
|
|
544
548
|
} catch (e: Exception) {
|
|
545
549
|
releaseWakeLock()
|
|
@@ -549,7 +553,10 @@ class AudioRecorderManager(
|
|
|
549
553
|
|
|
550
554
|
fun pauseRecording(promise: Promise) {
|
|
551
555
|
if (isRecording.get() && !isPaused.get()) {
|
|
556
|
+
// Add these lines to pause both recordings
|
|
552
557
|
audioRecord?.stop()
|
|
558
|
+
compressedRecorder?.pause()
|
|
559
|
+
|
|
553
560
|
lastPauseTime = System.currentTimeMillis()
|
|
554
561
|
isPaused.set(true)
|
|
555
562
|
|
|
@@ -410,13 +410,14 @@ class AudioStreamManager: NSObject {
|
|
|
410
410
|
"interval": emissionInterval
|
|
411
411
|
]
|
|
412
412
|
|
|
413
|
-
// Add compression info if enabled
|
|
413
|
+
// Add compression info if enabled
|
|
414
414
|
if settings.enableCompressedOutput,
|
|
415
415
|
let compressedURL = compressedFileURL,
|
|
416
416
|
FileManager.default.fileExists(atPath: compressedURL.path) {
|
|
417
417
|
do {
|
|
418
418
|
let compressedAttributes = try FileManager.default.attributesOfItem(atPath: compressedURL.path)
|
|
419
419
|
if let compressedSize = compressedAttributes[.size] as? Int64 {
|
|
420
|
+
Logger.debug("Compressed file status - Size: \(compressedSize)")
|
|
420
421
|
let compressionBundle: [String: Any] = [
|
|
421
422
|
"fileUri": compressedURL.absoluteString,
|
|
422
423
|
"mimeType": compressedFormat == "aac" ? "audio/aac" : "audio/opus",
|
|
@@ -558,34 +559,47 @@ class AudioStreamManager: NSObject {
|
|
|
558
559
|
AVSampleRateKey: settings.sampleRate,
|
|
559
560
|
AVNumberOfChannelsKey: settings.numberOfChannels,
|
|
560
561
|
AVEncoderBitRateKey: settings.compressedBitRate,
|
|
561
|
-
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
|
|
562
|
+
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
|
|
563
|
+
AVEncoderBitDepthHintKey: 16
|
|
562
564
|
]
|
|
563
565
|
|
|
564
|
-
|
|
566
|
+
Logger.debug("Initializing compressed recording with settings: \(compressedSettings)")
|
|
567
|
+
|
|
565
568
|
let tempDirectory = FileManager.default.temporaryDirectory
|
|
566
569
|
try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
|
|
567
570
|
|
|
568
|
-
//
|
|
569
|
-
|
|
570
|
-
.
|
|
571
|
-
|
|
572
|
-
if let url = compressedFileURL {
|
|
573
|
-
// Create empty file first
|
|
574
|
-
FileManager.default.createFile(atPath: url.path, contents: nil)
|
|
571
|
+
// Use the same UUID as the main recording
|
|
572
|
+
if let recordingUUID = recordingUUID {
|
|
573
|
+
compressedFileURL = tempDirectory.appendingPathComponent(recordingUUID.uuidString)
|
|
574
|
+
.appendingPathExtension(settings.compressedFormat)
|
|
575
575
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
576
|
+
if let url = compressedFileURL {
|
|
577
|
+
// Create empty file first
|
|
578
|
+
if FileManager.default.createFile(atPath: url.path, contents: nil) {
|
|
579
|
+
Logger.debug("Created empty file at: \(url.path)")
|
|
580
|
+
} else {
|
|
581
|
+
Logger.debug("Failed to create empty file at: \(url.path)")
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Then initialize recorder
|
|
585
|
+
compressedRecorder = try AVAudioRecorder(url: url, settings: compressedSettings)
|
|
586
|
+
if let recorder = compressedRecorder {
|
|
587
|
+
let prepared = recorder.prepareToRecord()
|
|
588
|
+
Logger.debug("Recorder prepared: \(prepared)")
|
|
589
|
+
|
|
590
|
+
let started = recorder.record()
|
|
591
|
+
Logger.debug("Recorder started: \(started)")
|
|
592
|
+
|
|
593
|
+
Logger.debug("Recorder current time: \(recorder.currentTime)")
|
|
594
|
+
|
|
595
|
+
compressedFormat = settings.compressedFormat
|
|
596
|
+
compressedBitRate = settings.compressedBitRate
|
|
597
|
+
Logger.debug("Compressed recording initialized - Format: \(compressedFormat), Bitrate: \(compressedBitRate)")
|
|
598
|
+
}
|
|
584
599
|
}
|
|
585
600
|
}
|
|
586
601
|
} catch {
|
|
587
602
|
Logger.debug("Failed to setup compressed recording: \(error)")
|
|
588
|
-
// Don't fail the entire recording if compression fails
|
|
589
603
|
compressedFileURL = nil
|
|
590
604
|
compressedRecorder = nil
|
|
591
605
|
}
|
|
@@ -1213,32 +1227,50 @@ class AudioStreamManager: NSObject {
|
|
|
1213
1227
|
var compressionInfo: [String: Any]? = nil
|
|
1214
1228
|
if settings.enableCompressedOutput, let compressedURL = compressedFileURL {
|
|
1215
1229
|
do {
|
|
1216
|
-
|
|
1217
|
-
if
|
|
1218
|
-
let
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1230
|
+
// Ensure file exists and has data
|
|
1231
|
+
if FileManager.default.fileExists(atPath: compressedURL.path) {
|
|
1232
|
+
let compressedAttributes = try FileManager.default.attributesOfItem(atPath: compressedURL.path)
|
|
1233
|
+
if let compressedSize = compressedAttributes[.size] as? Int64 {
|
|
1234
|
+
let eventDataSize = compressedSize - lastEmittedCompressedSize
|
|
1235
|
+
|
|
1236
|
+
Logger.debug("Compressed file status - Total size: \(compressedSize), New data size: \(eventDataSize)")
|
|
1237
|
+
|
|
1238
|
+
// Read the new compressed data if there's new data
|
|
1239
|
+
var compressedData: String? = nil
|
|
1240
|
+
if eventDataSize > 0 {
|
|
1241
|
+
do {
|
|
1242
|
+
let fileHandle = try FileHandle(forReadingFrom: compressedURL)
|
|
1243
|
+
defer { fileHandle.closeFile() }
|
|
1244
|
+
|
|
1245
|
+
fileHandle.seek(toFileOffset: UInt64(lastEmittedCompressedSize))
|
|
1246
|
+
let data = fileHandle.readData(ofLength: Int(eventDataSize))
|
|
1247
|
+
compressedData = data.base64EncodedString()
|
|
1248
|
+
|
|
1249
|
+
Logger.debug("Read compressed data of size: \(data.count)")
|
|
1250
|
+
} catch {
|
|
1251
|
+
Logger.debug("Error reading compressed data: \(error)")
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
lastEmittedCompressedSize = compressedSize
|
|
1256
|
+
|
|
1257
|
+
compressionInfo = [
|
|
1258
|
+
"position": recordingTime * 1000, // Convert to milliseconds
|
|
1259
|
+
"fileUri": compressedURL.absoluteString,
|
|
1260
|
+
"eventDataSize": eventDataSize,
|
|
1261
|
+
"totalSize": compressedSize,
|
|
1262
|
+
"data": compressedData ?? ""
|
|
1263
|
+
]
|
|
1264
|
+
|
|
1265
|
+
Logger.debug("Compression info prepared: \(String(describing: compressionInfo))")
|
|
1266
|
+
} else {
|
|
1267
|
+
Logger.debug("Could not get compressed file size")
|
|
1228
1268
|
}
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
compressionInfo = [
|
|
1233
|
-
"position": recordingTime * 1000, // Convert to milliseconds
|
|
1234
|
-
"fileUri": compressedURL.absoluteString,
|
|
1235
|
-
"eventDataSize": eventDataSize,
|
|
1236
|
-
"totalSize": compressedSize,
|
|
1237
|
-
"data": compressedData ?? ""
|
|
1238
|
-
]
|
|
1269
|
+
} else {
|
|
1270
|
+
Logger.debug("Compressed file does not exist at path: \(compressedURL.path)")
|
|
1239
1271
|
}
|
|
1240
1272
|
} catch {
|
|
1241
|
-
Logger.debug("
|
|
1273
|
+
Logger.debug("Error preparing compression info: \(error)")
|
|
1242
1274
|
}
|
|
1243
1275
|
}
|
|
1244
1276
|
|
|
@@ -23,18 +23,25 @@ struct CompressedRecordingInfo {
|
|
|
23
23
|
var bitrate: Int
|
|
24
24
|
var format: String
|
|
25
25
|
|
|
26
|
-
static func validate(format: String, bitrate: Int) -> Result<
|
|
26
|
+
static func validate(format: String, bitrate: Int) -> Result<(String, Int), Error> {
|
|
27
27
|
// Validate format
|
|
28
28
|
guard ["aac", "opus"].contains(format.lowercased()) else {
|
|
29
29
|
return .failure(RecordingError.unsupportedFormat(format))
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
// Adjust bitrate based on format
|
|
33
|
+
let adjustedBitrate: Int
|
|
34
|
+
if format.lowercased() == "aac" {
|
|
35
|
+
// Standard AAC bitrates (bps)
|
|
36
|
+
let standardAACBitrates = [32000, 48000, 64000, 96000, 128000, 160000, 192000, 256000, 320000]
|
|
37
|
+
adjustedBitrate = standardAACBitrates.min(by: { abs($0 - bitrate) < abs($1 - bitrate) }) ?? 128000
|
|
38
|
+
} else {
|
|
39
|
+
// For Opus, allow lower bitrates (especially good for voice)
|
|
40
|
+
// Typical Opus voice bitrates: 8-24 kbps, music: 32-128 kbps
|
|
41
|
+
adjustedBitrate = min(max(bitrate, 8000), 320000)
|
|
35
42
|
}
|
|
36
43
|
|
|
37
|
-
return .success(())
|
|
44
|
+
return .success((format, adjustedBitrate))
|
|
38
45
|
}
|
|
39
46
|
}
|
|
40
47
|
|