@siteed/expo-audio-stream 1.9.2 → 1.11.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 +43 -1
- package/README.md +4 -0
- package/android/src/main/java/net/siteed/audiostream/AudioNotificationsManager.kt +89 -33
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +213 -1
- package/android/src/main/java/net/siteed/audiostream/Constants.kt +1 -0
- package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +5 -1
- package/android/src/main/java/net/siteed/audiostream/PermissionUtils.kt +8 -0
- package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +3 -1
- package/build/AudioAnalysis/extractAudioAnalysis.d.ts.map +1 -1
- package/build/AudioAnalysis/extractWaveform.d.ts.map +1 -1
- package/build/ExpoAudioStream.types.d.ts +8 -0
- package/build/ExpoAudioStream.types.d.ts.map +1 -1
- package/build/ExpoAudioStream.types.js.map +1 -1
- package/build/utils/BlobFix.d.ts.map +1 -1
- package/build/utils/convertPCMToFloat32.d.ts +2 -2
- package/build/utils/convertPCMToFloat32.d.ts.map +1 -1
- package/build/utils/encodingToBitDepth.d.ts.map +1 -1
- package/build/utils/getWavFileInfo.d.ts.map +1 -1
- package/ios/AudioStreamManager.swift +55 -14
- package/ios/AudioStreamManagerDelegate.swift +1 -0
- package/ios/ExpoAudioStreamModule.swift +16 -4
- package/ios/RecordingSettings.swift +4 -1
- package/package.json +2 -1
- package/plugin/build/index.d.ts +6 -1
- package/plugin/build/index.js +43 -38
- package/plugin/src/index.ts +75 -50
- package/src/ExpoAudioStream.types.ts +20 -0
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.11.0] - 2025-01-22
|
|
12
|
+
- feat(audio): add intelligent call interruption handling & compression improvements ([f8f6187](https://github.com/deeeed/expo-audio-stream/pull/78))
|
|
13
|
+
|
|
14
|
+
## [1.10.0] - 2025-01-14
|
|
15
|
+
- add support for pausing and resuming compressed recordings ([bc3f629](https://github.com/deeeed/expo-audio-stream/commit/bc3f6295d060396325e0f008ff00b3be9c8722cd))
|
|
16
|
+
- optimize notification channel settings ([daa075e](https://github.com/deeeed/expo-audio-stream/commit/daa075e668f8faf0b8d2849e18c37384bdd293b8))
|
|
17
|
+
|
|
18
|
+
|
|
11
19
|
## [1.9.2] - 2025-01-12
|
|
12
20
|
- ios bitrate verification to prevent invalid values ([035a180](https://github.com/deeeed/expo-audio-stream/commit/035a1800833264edcc59724aaa8a2e12d5c78dc2))
|
|
13
21
|
|
|
22
|
+
|
|
23
|
+
|
|
14
24
|
## [1.9.1] - 2025-01-12
|
|
15
25
|
- ios potentially missing compressed file info ([88a628c](https://github.com/deeeed/expo-audio-stream/commit/88a628c35f2bfd626a2a5de1eb6950efd814619d))
|
|
16
26
|
|
|
17
27
|
|
|
28
|
+
|
|
29
|
+
|
|
18
30
|
## [1.9.0] - 2025-01-11
|
|
19
31
|
- feat(web-audio): optimize memory usage and streaming performance for web audio recording (#75) ([7b93e12](https://github.com/deeeed/expo-audio-stream/commit/7b93e12aae4bc0599b06b48ca34a60f65587fc75))
|
|
20
32
|
|
|
21
33
|
|
|
22
34
|
|
|
35
|
+
|
|
36
|
+
|
|
23
37
|
## [1.8.0] - 2025-01-10
|
|
24
38
|
- feat(audio): implement audio compression support ([ff4e060](https://github.com/deeeed/expo-audio-stream/commit/ff4e060fef1061804c1cc0126d4344d2d50daa9a))
|
|
25
39
|
|
|
26
40
|
|
|
27
41
|
|
|
28
42
|
|
|
43
|
+
|
|
44
|
+
|
|
29
45
|
## [1.7.2] - 2025-01-07
|
|
30
46
|
- fix(audio-stream): correct WAV header handling in web audio recording ([9ba7de5](https://github.com/deeeed/expo-audio-stream/commit/9ba7de5b96ca4cc937dea261c80d3fda9c99e8f4))
|
|
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.1] - 2025-01-07
|
|
37
55
|
- update notification to avoid triggering new alerts (#71) ([32dcfc5](https://github.com/deeeed/expo-audio-stream/commit/32dcfc55daf3236babefc17016f329c177d466fd))
|
|
38
56
|
|
|
@@ -41,6 +59,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
41
59
|
|
|
42
60
|
|
|
43
61
|
|
|
62
|
+
|
|
63
|
+
|
|
44
64
|
## [1.7.0] - 2025-01-05
|
|
45
65
|
- feat(playground): enhance app configuration and build setup for production deployment (#58) ([929d443](https://github.com/deeeed/expo-audio-stream/commit/929d443145378b1430d215db5c00b13758420e2b))
|
|
46
66
|
- chore(expo-audio-stream): release @siteed/expo-audio-stream@1.6.1 ([084e8ad](https://github.com/deeeed/expo-audio-stream/commit/084e8adb91da7874c9e608b55d9c7b2ffd7a8327))
|
|
@@ -55,6 +75,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
55
75
|
|
|
56
76
|
|
|
57
77
|
|
|
78
|
+
|
|
79
|
+
|
|
58
80
|
## [1.6.1] - 2024-12-11
|
|
59
81
|
- chore(expo-audio-stream): remove git commit step from publish script ([4a772ce](https://github.com/deeeed/expo-audio-stream/commit/4a772ce93bb7405d9b8e981f46bdf8941a71ecfe))
|
|
60
82
|
- chore: more publishing automation ([3693021](https://github.com/deeeed/expo-audio-stream/commit/369302107f9dca9dddd8ae68e6214481a39976ac))
|
|
@@ -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.5.0] - 2024-12-10
|
|
77
101
|
- UNPUBLISHED because of a bug in the build system
|
|
78
102
|
|
|
@@ -84,6 +108,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
84
108
|
|
|
85
109
|
|
|
86
110
|
|
|
111
|
+
|
|
112
|
+
|
|
87
113
|
## [1.4.0] - 2024-12-05
|
|
88
114
|
- chore: remove unusded dependencies ([ad81dd5](https://github.com/deeeed/expo-audio-stream/commit/ad81dd560c93dd1d04995a323a4ae72d4de20f3e))
|
|
89
115
|
|
|
@@ -95,6 +121,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
95
121
|
|
|
96
122
|
|
|
97
123
|
|
|
124
|
+
|
|
125
|
+
|
|
98
126
|
## [1.3.1] - 2024-12-05
|
|
99
127
|
- feat(web): implement throttling and optimize event processing (#49) ([da28765](https://github.com/deeeed/expo-audio-stream/commit/da2876524c2c9d6e0a980fde40a0197b929d8a7f))
|
|
100
128
|
|
|
@@ -106,6 +134,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
106
134
|
|
|
107
135
|
|
|
108
136
|
|
|
137
|
+
|
|
138
|
+
|
|
109
139
|
## [1.3.0] - 2024-11-28
|
|
110
140
|
### Added
|
|
111
141
|
- refactor(permissions): standardize permission status response structure across platforms (#44) ([7c9c800](https://github.com/deeeed/expo-audio-stream/commit/7c9c800d83b7cea3516643371484d5e1f3b99e4c))
|
|
@@ -122,6 +152,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
122
152
|
|
|
123
153
|
|
|
124
154
|
|
|
155
|
+
|
|
156
|
+
|
|
125
157
|
## [1.2.5] - 2024-11-12
|
|
126
158
|
### Added
|
|
127
159
|
- docs(license): add MIT license to all packages (6 files changed)
|
|
@@ -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.4] - 2024-11-05
|
|
139
173
|
### Changed
|
|
140
174
|
- Android minimum audio interval set to 10ms.
|
|
@@ -151,6 +185,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
151
185
|
|
|
152
186
|
|
|
153
187
|
|
|
188
|
+
|
|
189
|
+
|
|
154
190
|
## [1.2.0] - 2024-10-24
|
|
155
191
|
### Added
|
|
156
192
|
- Feature: Keep device awake during recording with `keepAwake` option
|
|
@@ -167,6 +203,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
167
203
|
|
|
168
204
|
|
|
169
205
|
|
|
206
|
+
|
|
207
|
+
|
|
170
208
|
## [1.1.17] - 2024-10-21
|
|
171
209
|
### Added
|
|
172
210
|
- Support bluetooth headset on ios
|
|
@@ -180,6 +218,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
180
218
|
|
|
181
219
|
|
|
182
220
|
|
|
221
|
+
|
|
222
|
+
|
|
183
223
|
## [1.0.0] - 2024-04-01
|
|
184
224
|
### Added
|
|
185
225
|
- Initial release of @siteed/expo-audio-stream.
|
|
@@ -190,7 +230,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
190
230
|
- Feature: Audio features extraction during recording.
|
|
191
231
|
- Feature: Consistent WAV PCM recording format across all platforms.
|
|
192
232
|
|
|
193
|
-
[unreleased]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.
|
|
233
|
+
[unreleased]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.11.0...HEAD
|
|
234
|
+
[1.11.0]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.10.0...@siteed/expo-audio-stream@1.11.0
|
|
235
|
+
[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
|
|
194
236
|
[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
|
|
195
237
|
[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
|
|
196
238
|
[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
|
package/README.md
CHANGED
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
- Compression formats: OPUS or AAC
|
|
38
38
|
- Configurable bitrate for compressed audio
|
|
39
39
|
- Optimized storage for both high-quality and compressed formats
|
|
40
|
+
- Intelligent interruption handling:
|
|
41
|
+
- Automatic pause/resume during phone calls
|
|
42
|
+
- Configurable automatic resumption
|
|
43
|
+
- Detailed interruption event callbacks
|
|
40
44
|
- Configurable intervals for audio buffer receipt.
|
|
41
45
|
- Automated microphone permissions setup in managed Expo projects.
|
|
42
46
|
- Background audio recording on iOS.
|
|
@@ -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() {
|
|
@@ -23,6 +23,11 @@ import android.os.PowerManager
|
|
|
23
23
|
import android.content.Context
|
|
24
24
|
import java.nio.ByteBuffer
|
|
25
25
|
import java.nio.ByteOrder
|
|
26
|
+
import android.media.AudioManager
|
|
27
|
+
import android.media.AudioAttributes
|
|
28
|
+
import android.media.AudioFocusRequest
|
|
29
|
+
import android.telephony.PhoneStateListener
|
|
30
|
+
import android.telephony.TelephonyManager
|
|
26
31
|
|
|
27
32
|
class AudioRecorderManager(
|
|
28
33
|
private val context: Context,
|
|
@@ -63,6 +68,55 @@ class AudioRecorderManager(
|
|
|
63
68
|
private var compressedRecorder: MediaRecorder? = null
|
|
64
69
|
private var compressedFile: File? = null
|
|
65
70
|
|
|
71
|
+
private var audioManager: AudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
72
|
+
private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
|
|
73
|
+
private var audioFocusRequest: Any? = null // Type Any to handle both old and new APIs
|
|
74
|
+
private var phoneStateListener: PhoneStateListener? = null
|
|
75
|
+
private var telephonyManager: TelephonyManager? = null
|
|
76
|
+
|
|
77
|
+
@RequiresApi(Build.VERSION_CODES.O)
|
|
78
|
+
private val audioFocusCallback = object : AudioManager.OnAudioFocusChangeListener {
|
|
79
|
+
override fun onAudioFocusChange(focusChange: Int) {
|
|
80
|
+
when (focusChange) {
|
|
81
|
+
AudioManager.AUDIOFOCUS_LOSS,
|
|
82
|
+
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
|
83
|
+
if (isRecording.get() && !isPaused.get()) {
|
|
84
|
+
mainHandler.post {
|
|
85
|
+
pauseRecording(object : Promise {
|
|
86
|
+
override fun resolve(value: Any?) {
|
|
87
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
88
|
+
"reason" to "audioFocusLoss",
|
|
89
|
+
"isPaused" to true
|
|
90
|
+
))
|
|
91
|
+
}
|
|
92
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
93
|
+
Log.e(Constants.TAG, "Failed to pause recording on audio focus loss")
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
AudioManager.AUDIOFOCUS_GAIN -> {
|
|
100
|
+
if (isRecording.get() && isPaused.get()) {
|
|
101
|
+
mainHandler.post {
|
|
102
|
+
resumeRecording(object : Promise {
|
|
103
|
+
override fun resolve(value: Any?) {
|
|
104
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
105
|
+
"reason" to "audioFocusGain",
|
|
106
|
+
"isPaused" to false
|
|
107
|
+
))
|
|
108
|
+
}
|
|
109
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
110
|
+
Log.e(Constants.TAG, "Failed to resume recording on audio focus gain")
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
66
120
|
companion object {
|
|
67
121
|
@SuppressLint("StaticFieldLeak")
|
|
68
122
|
@Volatile
|
|
@@ -88,6 +142,15 @@ class AudioRecorderManager(
|
|
|
88
142
|
@RequiresApi(Build.VERSION_CODES.R)
|
|
89
143
|
fun startRecording(options: Map<String, Any?>, promise: Promise) {
|
|
90
144
|
try {
|
|
145
|
+
// Initialize phone state listener
|
|
146
|
+
initializePhoneStateListener()
|
|
147
|
+
|
|
148
|
+
// Request audio focus
|
|
149
|
+
if (!requestAudioFocus()) {
|
|
150
|
+
promise.reject("AUDIO_FOCUS_ERROR", "Failed to obtain audio focus", null)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
91
154
|
Log.d(Constants.TAG, "Starting recording with options: $options")
|
|
92
155
|
|
|
93
156
|
// Check permissions
|
|
@@ -158,7 +221,8 @@ class AudioRecorderManager(
|
|
|
158
221
|
promise.resolve(result)
|
|
159
222
|
|
|
160
223
|
} catch (e: Exception) {
|
|
161
|
-
|
|
224
|
+
releaseAudioFocus()
|
|
225
|
+
telephonyManager?.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
|
|
162
226
|
promise.reject("UNEXPECTED_ERROR", "Unexpected error: ${e.message}", e)
|
|
163
227
|
}
|
|
164
228
|
}
|
|
@@ -539,7 +603,11 @@ class AudioRecorderManager(
|
|
|
539
603
|
acquireWakeLock()
|
|
540
604
|
pausedDuration += System.currentTimeMillis() - lastPauseTime
|
|
541
605
|
isPaused.set(false)
|
|
606
|
+
|
|
607
|
+
// Add these lines to resume both recordings
|
|
542
608
|
audioRecord?.startRecording()
|
|
609
|
+
compressedRecorder?.resume()
|
|
610
|
+
|
|
543
611
|
promise.resolve("Recording resumed")
|
|
544
612
|
} catch (e: Exception) {
|
|
545
613
|
releaseWakeLock()
|
|
@@ -549,7 +617,10 @@ class AudioRecorderManager(
|
|
|
549
617
|
|
|
550
618
|
fun pauseRecording(promise: Promise) {
|
|
551
619
|
if (isRecording.get() && !isPaused.get()) {
|
|
620
|
+
// Add these lines to pause both recordings
|
|
552
621
|
audioRecord?.stop()
|
|
622
|
+
compressedRecorder?.pause()
|
|
623
|
+
|
|
553
624
|
lastPauseTime = System.currentTimeMillis()
|
|
554
625
|
isPaused.set(true)
|
|
555
626
|
|
|
@@ -899,4 +970,145 @@ class AudioRecorderManager(
|
|
|
899
970
|
return false
|
|
900
971
|
}
|
|
901
972
|
}
|
|
973
|
+
|
|
974
|
+
private fun initializePhoneStateListener() {
|
|
975
|
+
try {
|
|
976
|
+
// Check for READ_PHONE_STATE permission before initializing phone state listener
|
|
977
|
+
if (permissionUtils.checkPhoneStatePermission()) {
|
|
978
|
+
phoneStateListener = object : PhoneStateListener() {
|
|
979
|
+
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
|
|
980
|
+
when (state) {
|
|
981
|
+
TelephonyManager.CALL_STATE_RINGING,
|
|
982
|
+
TelephonyManager.CALL_STATE_OFFHOOK -> {
|
|
983
|
+
if (isRecording.get() && !isPaused.get()) {
|
|
984
|
+
mainHandler.post {
|
|
985
|
+
pauseRecording(object : Promise {
|
|
986
|
+
override fun resolve(value: Any?) {
|
|
987
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
988
|
+
"reason" to "phoneCall",
|
|
989
|
+
"isPaused" to true
|
|
990
|
+
))
|
|
991
|
+
}
|
|
992
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
993
|
+
Log.e(Constants.TAG, "Failed to pause recording on phone call")
|
|
994
|
+
}
|
|
995
|
+
})
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
TelephonyManager.CALL_STATE_IDLE -> {
|
|
1000
|
+
if (isRecording.get() && isPaused.get()) {
|
|
1001
|
+
mainHandler.post {
|
|
1002
|
+
resumeRecording(object : Promise {
|
|
1003
|
+
override fun resolve(value: Any?) {
|
|
1004
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
1005
|
+
"reason" to "phoneCallEnded",
|
|
1006
|
+
"isPaused" to false
|
|
1007
|
+
))
|
|
1008
|
+
}
|
|
1009
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
1010
|
+
Log.e(Constants.TAG, "Failed to resume recording after phone call")
|
|
1011
|
+
}
|
|
1012
|
+
})
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
telephonyManager = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
|
|
1021
|
+
if (telephonyManager != null) {
|
|
1022
|
+
try {
|
|
1023
|
+
telephonyManager?.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE)
|
|
1024
|
+
} catch (e: Exception) {
|
|
1025
|
+
Log.e(Constants.TAG, "Failed to register phone state listener", e)
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
} else {
|
|
1029
|
+
Log.w(Constants.TAG, "READ_PHONE_STATE permission not granted, phone call interruption handling disabled")
|
|
1030
|
+
}
|
|
1031
|
+
} catch (e: Exception) {
|
|
1032
|
+
Log.e(Constants.TAG, "Failed to initialize phone state listener", e)
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
@SuppressLint("NewApi")
|
|
1037
|
+
private fun requestAudioFocus(): Boolean {
|
|
1038
|
+
audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
|
|
1039
|
+
when (focusChange) {
|
|
1040
|
+
AudioManager.AUDIOFOCUS_LOSS,
|
|
1041
|
+
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
|
1042
|
+
if (isRecording.get() && !isPaused.get()) {
|
|
1043
|
+
mainHandler.post {
|
|
1044
|
+
pauseRecording(object : Promise {
|
|
1045
|
+
override fun resolve(value: Any?) {
|
|
1046
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
1047
|
+
"reason" to "audioFocusLoss",
|
|
1048
|
+
"isPaused" to true
|
|
1049
|
+
))
|
|
1050
|
+
}
|
|
1051
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
1052
|
+
Log.e(Constants.TAG, "Failed to pause recording on audio focus loss")
|
|
1053
|
+
}
|
|
1054
|
+
})
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
AudioManager.AUDIOFOCUS_GAIN -> {
|
|
1059
|
+
if (isRecording.get() && isPaused.get()) {
|
|
1060
|
+
mainHandler.post {
|
|
1061
|
+
resumeRecording(object : Promise {
|
|
1062
|
+
override fun resolve(value: Any?) {
|
|
1063
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
1064
|
+
"reason" to "audioFocusGain",
|
|
1065
|
+
"isPaused" to false
|
|
1066
|
+
))
|
|
1067
|
+
}
|
|
1068
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
1069
|
+
Log.e(Constants.TAG, "Failed to resume recording on audio focus gain")
|
|
1070
|
+
}
|
|
1071
|
+
})
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
1079
|
+
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
|
1080
|
+
.setAudioAttributes(AudioAttributes.Builder()
|
|
1081
|
+
.setUsage(AudioAttributes.USAGE_MEDIA)
|
|
1082
|
+
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
|
1083
|
+
.build())
|
|
1084
|
+
.setOnAudioFocusChangeListener(audioFocusChangeListener!!)
|
|
1085
|
+
.build()
|
|
1086
|
+
audioFocusRequest = focusRequest
|
|
1087
|
+
audioManager.requestAudioFocus(focusRequest)
|
|
1088
|
+
} else {
|
|
1089
|
+
@Suppress("DEPRECATION")
|
|
1090
|
+
audioManager.requestAudioFocus(
|
|
1091
|
+
audioFocusChangeListener,
|
|
1092
|
+
AudioManager.STREAM_MUSIC,
|
|
1093
|
+
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
|
|
1094
|
+
)
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
private fun releaseAudioFocus() {
|
|
1101
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
1102
|
+
(audioFocusRequest as? AudioFocusRequest)?.let { request ->
|
|
1103
|
+
audioManager.abandonAudioFocusRequest(request)
|
|
1104
|
+
}
|
|
1105
|
+
} else {
|
|
1106
|
+
@Suppress("DEPRECATION")
|
|
1107
|
+
audioFocusChangeListener?.let { listener ->
|
|
1108
|
+
audioManager.abandonAudioFocus(listener)
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
audioFocusRequest = null
|
|
1112
|
+
audioFocusChangeListener = null
|
|
1113
|
+
}
|
|
902
1114
|
}
|
|
@@ -3,6 +3,7 @@ package net.siteed.audiostream
|
|
|
3
3
|
object Constants {
|
|
4
4
|
const val AUDIO_EVENT_NAME = "AudioData"
|
|
5
5
|
const val AUDIO_ANALYSIS_EVENT_NAME = "AudioAnalysis"
|
|
6
|
+
const val RECORDING_INTERRUPTED_EVENT_NAME = "onRecordingInterrupted"
|
|
6
7
|
const val DEFAULT_SAMPLE_RATE = 16000 // Default sample rate for audio recording
|
|
7
8
|
const val DEFAULT_CHANNEL_CONFIG = 1 // Mono
|
|
8
9
|
const val DEFAULT_AUDIO_FORMAT = 16 // 16-bit PCM
|
|
@@ -20,7 +20,11 @@ class ExpoAudioStreamModule : Module(), EventSender {
|
|
|
20
20
|
// The module will be accessible from `requireNativeModule('ExpoAudioStream')` in JavaScript.
|
|
21
21
|
Name("ExpoAudioStream")
|
|
22
22
|
|
|
23
|
-
Events(
|
|
23
|
+
Events(
|
|
24
|
+
Constants.AUDIO_EVENT_NAME,
|
|
25
|
+
Constants.AUDIO_ANALYSIS_EVENT_NAME,
|
|
26
|
+
Constants.RECORDING_INTERRUPTED_EVENT_NAME
|
|
27
|
+
)
|
|
24
28
|
|
|
25
29
|
// Initialize AudioRecorderManager
|
|
26
30
|
initializeManager()
|
|
@@ -48,4 +48,12 @@ class PermissionUtils(private val context: Context) {
|
|
|
48
48
|
}
|
|
49
49
|
return result
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
fun checkPhoneStatePermission(): Boolean {
|
|
53
|
+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
54
|
+
context.checkSelfPermission(android.Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED
|
|
55
|
+
} else {
|
|
56
|
+
true
|
|
57
|
+
}
|
|
58
|
+
}
|
|
51
59
|
}
|