@edkimmel/expo-audio-stream 0.2.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.
Files changed (75) hide show
  1. package/.eslintrc.js +5 -0
  2. package/.yarnrc.yml +8 -0
  3. package/NATIVE_EVENTS.md +270 -0
  4. package/README.md +289 -0
  5. package/android/build.gradle +92 -0
  6. package/android/src/main/AndroidManifest.xml +4 -0
  7. package/android/src/main/java/expo/modules/audiostream/AudioDataEncoder.kt +178 -0
  8. package/android/src/main/java/expo/modules/audiostream/AudioEffectsManager.kt +107 -0
  9. package/android/src/main/java/expo/modules/audiostream/AudioPlaybackManager.kt +651 -0
  10. package/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt +509 -0
  11. package/android/src/main/java/expo/modules/audiostream/Constants.kt +21 -0
  12. package/android/src/main/java/expo/modules/audiostream/EventSender.kt +7 -0
  13. package/android/src/main/java/expo/modules/audiostream/ExpoAudioStreamView.kt +7 -0
  14. package/android/src/main/java/expo/modules/audiostream/ExpoPlayAudioStreamModule.kt +280 -0
  15. package/android/src/main/java/expo/modules/audiostream/PermissionUtils.kt +16 -0
  16. package/android/src/main/java/expo/modules/audiostream/RecordingConfig.kt +60 -0
  17. package/android/src/main/java/expo/modules/audiostream/SoundConfig.kt +46 -0
  18. package/android/src/main/java/expo/modules/audiostream/pipeline/AudioPipeline.kt +685 -0
  19. package/android/src/main/java/expo/modules/audiostream/pipeline/JitterBuffer.kt +227 -0
  20. package/android/src/main/java/expo/modules/audiostream/pipeline/PipelineIntegration.kt +315 -0
  21. package/app.plugin.js +1 -0
  22. package/build/ExpoPlayAudioStreamModule.d.ts +3 -0
  23. package/build/ExpoPlayAudioStreamModule.d.ts.map +1 -0
  24. package/build/ExpoPlayAudioStreamModule.js +5 -0
  25. package/build/ExpoPlayAudioStreamModule.js.map +1 -0
  26. package/build/events.d.ts +36 -0
  27. package/build/events.d.ts.map +1 -0
  28. package/build/events.js +25 -0
  29. package/build/events.js.map +1 -0
  30. package/build/index.d.ts +125 -0
  31. package/build/index.d.ts.map +1 -0
  32. package/build/index.js +222 -0
  33. package/build/index.js.map +1 -0
  34. package/build/pipeline/index.d.ts +81 -0
  35. package/build/pipeline/index.d.ts.map +1 -0
  36. package/build/pipeline/index.js +140 -0
  37. package/build/pipeline/index.js.map +1 -0
  38. package/build/pipeline/types.d.ts +132 -0
  39. package/build/pipeline/types.d.ts.map +1 -0
  40. package/build/pipeline/types.js +5 -0
  41. package/build/pipeline/types.js.map +1 -0
  42. package/build/types.d.ts +221 -0
  43. package/build/types.d.ts.map +1 -0
  44. package/build/types.js +10 -0
  45. package/build/types.js.map +1 -0
  46. package/expo-module.config.json +9 -0
  47. package/ios/AudioPipeline.swift +562 -0
  48. package/ios/AudioUtils.swift +356 -0
  49. package/ios/ExpoPlayAudioStream.podspec +27 -0
  50. package/ios/ExpoPlayAudioStreamModule.swift +436 -0
  51. package/ios/ExpoPlayAudioStreamView.swift +7 -0
  52. package/ios/JitterBuffer.swift +208 -0
  53. package/ios/Logger.swift +7 -0
  54. package/ios/Microphone.swift +221 -0
  55. package/ios/MicrophoneDataDelegate.swift +4 -0
  56. package/ios/PipelineIntegration.swift +214 -0
  57. package/ios/RecordingResult.swift +10 -0
  58. package/ios/RecordingSettings.swift +11 -0
  59. package/ios/SharedAudioEngine.swift +484 -0
  60. package/ios/SoundConfig.swift +45 -0
  61. package/ios/SoundPlayer.swift +408 -0
  62. package/ios/SoundPlayerDelegate.swift +7 -0
  63. package/package.json +49 -0
  64. package/plugin/build/index.d.ts +5 -0
  65. package/plugin/build/index.js +28 -0
  66. package/plugin/src/index.ts +53 -0
  67. package/plugin/tsconfig.json +9 -0
  68. package/plugin/tsconfig.tsbuildinfo +1 -0
  69. package/src/ExpoPlayAudioStreamModule.ts +5 -0
  70. package/src/events.ts +66 -0
  71. package/src/index.ts +359 -0
  72. package/src/pipeline/index.ts +216 -0
  73. package/src/pipeline/types.ts +169 -0
  74. package/src/types.ts +270 -0
  75. package/tsconfig.json +9 -0
@@ -0,0 +1,280 @@
1
+ package expo.modules.audiostream
2
+
3
+ import android.Manifest
4
+ import android.annotation.SuppressLint
5
+ import android.content.Context
6
+ import android.content.pm.PackageManager
7
+ import android.media.AudioDeviceCallback
8
+ import android.media.AudioDeviceInfo
9
+ import android.media.AudioManager
10
+ import android.os.Build
11
+ import android.os.Bundle
12
+ import android.os.Handler
13
+ import android.os.Looper
14
+ import android.util.Log
15
+ import androidx.annotation.RequiresApi
16
+ import androidx.core.app.ActivityCompat
17
+ import expo.modules.interfaces.permissions.Permissions
18
+ import expo.modules.kotlin.Promise
19
+ import expo.modules.kotlin.modules.Module
20
+ import expo.modules.kotlin.modules.ModuleDefinition
21
+ import expo.modules.audiostream.pipeline.PipelineIntegration
22
+
23
+
24
+
25
+ class ExpoPlayAudioStreamModule : Module(), EventSender {
26
+ private lateinit var audioRecorderManager: AudioRecorderManager
27
+ private lateinit var audioPlaybackManager: AudioPlaybackManager
28
+ private lateinit var audioManager: AudioManager
29
+ private lateinit var pipelineIntegration: PipelineIntegration
30
+
31
+ // Ensure callbacks are delivered on the main thread
32
+ private val mainHandler by lazy { Handler(Looper.getMainLooper()) }
33
+
34
+ private val reportedGroups = mutableSetOf<String>()
35
+
36
+ /** Map every device type to a logical group key */
37
+ private fun groupKey(type: Int): String = when (type) {
38
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
39
+ AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "BLUETOOTH"
40
+ AudioDeviceInfo.TYPE_WIRED_HEADSET,
41
+ AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
42
+ AudioDeviceInfo.TYPE_USB_HEADSET -> "WIRED"
43
+ else -> type.toString() // fallback, treats every other type separately
44
+ }
45
+
46
+ // We care about these types – includes both SCO and A2DP but we will collapse them into one group
47
+ private val interestingTypes = setOf(
48
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
49
+ AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
50
+ AudioDeviceInfo.TYPE_WIRED_HEADSET,
51
+ AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
52
+ AudioDeviceInfo.TYPE_USB_HEADSET
53
+ )
54
+
55
+ private val audioCallCallback = object : AudioDeviceCallback() {
56
+ override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
57
+ val descriptions = addedDevices?.map { "${it.productName} (type=${it.type})" } ?: emptyList()
58
+ Log.d("ExpoAudioCallback", "onAudioDevicesAdded: $descriptions")
59
+
60
+ val firstOfGroup = addedDevices?.filter { d ->
61
+ d.type in interestingTypes && reportedGroups.add(groupKey(d.type))
62
+ }
63
+ if (firstOfGroup?.isNotEmpty()==true) {
64
+ val matched = firstOfGroup.map { "${it.productName} (type=${it.type})" }
65
+ Log.d("ExpoAudioCallback", "AudioDeviceCallback ➜ ADDED (interesting): $matched")
66
+ pipelineIntegration.logAudioTrackHealth("device_added")
67
+ val params = Bundle()
68
+ params.putString("reason", "newDeviceAvailable")
69
+ sendExpoEvent(Constants.DEVICE_RECONNECTED_EVENT_NAME, params)
70
+ }
71
+ }
72
+
73
+ override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
74
+ val descriptions = removedDevices?.map { "${it.productName} (type=${it.type})" } ?: emptyList()
75
+ Log.d("ExpoAudioCallback", "onAudioDevicesRemoved: $descriptions")
76
+
77
+ val lastOfGroup = removedDevices?.filter { d ->
78
+ d.type in interestingTypes && reportedGroups.remove(groupKey(d.type))
79
+ }
80
+ if (lastOfGroup?.isNotEmpty() == true) {
81
+ val matched = lastOfGroup.map { "${it.productName} (type=${it.type})" }
82
+ Log.d("ExpoAudioCallback", "AudioDeviceCallback ➜ REMOVED (interesting): $matched")
83
+ pipelineIntegration.logAudioTrackHealth("device_removed")
84
+ audioPlaybackManager.stopPlayback(null)
85
+ val params = Bundle()
86
+ params.putString("reason", "oldDeviceUnavailable")
87
+ sendExpoEvent(Constants.DEVICE_RECONNECTED_EVENT_NAME, params)
88
+ }
89
+ }
90
+ }
91
+
92
+ @SuppressLint("MissingPermission")
93
+ @RequiresApi(Build.VERSION_CODES.R)
94
+ override fun definition() = ModuleDefinition {
95
+ Name("ExpoPlayAudioStream")
96
+
97
+ Events(
98
+ Constants.AUDIO_EVENT_NAME,
99
+ Constants.SOUND_CHUNK_PLAYED_EVENT_NAME,
100
+ Constants.SOUND_STARTED_EVENT_NAME,
101
+ Constants.DEVICE_RECONNECTED_EVENT_NAME,
102
+ PipelineIntegration.EVENT_STATE_CHANGED,
103
+ PipelineIntegration.EVENT_PLAYBACK_STARTED,
104
+ PipelineIntegration.EVENT_ERROR,
105
+ PipelineIntegration.EVENT_ZOMBIE_DETECTED,
106
+ PipelineIntegration.EVENT_UNDERRUN,
107
+ PipelineIntegration.EVENT_DRAINED,
108
+ PipelineIntegration.EVENT_AUDIO_FOCUS_LOST,
109
+ PipelineIntegration.EVENT_AUDIO_FOCUS_RESUMED
110
+ )
111
+
112
+ // Initialize managers for playback and for recording
113
+ initializeManager()
114
+ initializePlaybackManager()
115
+ initializePipeline()
116
+
117
+ OnCreate {
118
+ audioManager = appContext.reactContext?.getSystemService(Context.AUDIO_SERVICE) as AudioManager
119
+ audioManager.registerAudioDeviceCallback(audioCallCallback, mainHandler)
120
+ }
121
+
122
+ OnDestroy {
123
+ reportedGroups.clear()
124
+ audioManager.unregisterAudioDeviceCallback(audioCallCallback)
125
+ // Module is being destroyed (app shutdown)
126
+ // Just clean up resources without reinitialization
127
+ pipelineIntegration.destroy()
128
+ audioPlaybackManager.runOnDispose()
129
+ audioRecorderManager.release()
130
+ }
131
+
132
+ Function("destroy") {
133
+ // User explicitly called destroy - clean up and reinitialize for reuse
134
+ pipelineIntegration.destroy()
135
+ audioPlaybackManager.runOnDispose()
136
+ audioRecorderManager.release()
137
+
138
+ // Reinitialize all managers so the module can be used again
139
+ initializeManager()
140
+ initializePlaybackManager()
141
+ initializePipeline()
142
+ }
143
+
144
+ AsyncFunction("requestPermissionsAsync") { promise: Promise ->
145
+ Permissions.askForPermissionsWithPermissionsManager(
146
+ appContext.permissions,
147
+ promise,
148
+ Manifest.permission.RECORD_AUDIO
149
+ )
150
+ }
151
+
152
+ AsyncFunction("getPermissionsAsync") { promise: Promise ->
153
+ Permissions.getPermissionsWithPermissionsManager(
154
+ appContext.permissions,
155
+ promise,
156
+ Manifest.permission.RECORD_AUDIO
157
+ )
158
+ }
159
+
160
+ AsyncFunction("playSound") { chunk: String, turnId: String, encoding: String?, promise: Promise ->
161
+ val pcmEncoding = when (encoding) {
162
+ "pcm_f32le" -> PCMEncoding.PCM_F32LE
163
+ "pcm_s16le", null -> PCMEncoding.PCM_S16LE
164
+ else -> {
165
+ Log.d(Constants.TAG, "Unsupported encoding: $encoding, defaulting to PCM_S16LE")
166
+ PCMEncoding.PCM_S16LE
167
+ }
168
+ }
169
+ audioPlaybackManager.playAudio(chunk, turnId, promise, pcmEncoding)
170
+ }
171
+
172
+ AsyncFunction("stopSound") { promise: Promise -> audioPlaybackManager.stopPlayback(promise) }
173
+
174
+ AsyncFunction("clearSoundQueueByTurnId") { turnId: String, promise: Promise ->
175
+ audioPlaybackManager.setCurrentTurnId(turnId)
176
+ promise.resolve(null)
177
+ }
178
+
179
+ AsyncFunction("startMicrophone") { options: Map<String, Any?>, promise: Promise ->
180
+ audioRecorderManager.startRecording(options, promise)
181
+ }
182
+
183
+ AsyncFunction("stopMicrophone") { promise: Promise ->
184
+ audioRecorderManager.stopRecording(promise)
185
+ }
186
+
187
+ Function("toggleSilence") { isSilent: Boolean ->
188
+ // Just toggle silence without returning any value
189
+ audioRecorderManager.toggleSilence(isSilent)
190
+ }
191
+
192
+ AsyncFunction("setSoundConfig") { config: Map<String, Any?>, promise: Promise ->
193
+ val useDefault = config["useDefault"] as? Boolean ?: false
194
+
195
+ if (useDefault) {
196
+ // Reset to default configuration
197
+ Log.d(Constants.TAG, "Resetting sound configuration to default values")
198
+ audioPlaybackManager.resetConfigToDefault(promise)
199
+ } else {
200
+ // Extract configuration values
201
+ val sampleRate = (config["sampleRate"] as? Number)?.toInt() ?: 16000
202
+ val playbackModeString = config["playbackMode"] as? String ?: "regular"
203
+
204
+ // Convert string playback mode to enum
205
+ val playbackMode = when (playbackModeString) {
206
+ "voiceProcessing" -> PlaybackMode.VOICE_PROCESSING
207
+ "conversation" -> PlaybackMode.CONVERSATION
208
+ else -> PlaybackMode.REGULAR
209
+ }
210
+
211
+ // Create a new SoundConfig object
212
+ val soundConfig = SoundConfig(sampleRate = sampleRate, playbackMode = playbackMode)
213
+
214
+ // Update the sound player configuration
215
+ Log.d(Constants.TAG, "Setting sound configuration - sampleRate: $sampleRate, playbackMode: $playbackModeString")
216
+ audioPlaybackManager.updateConfig(soundConfig, promise)
217
+ }
218
+ }
219
+
220
+ // ── Native Audio Pipeline V3 ────────────────────────────────────
221
+
222
+ AsyncFunction("connectPipeline") { options: Map<String, Any?>, promise: Promise ->
223
+ pipelineIntegration.connect(options, promise)
224
+ }
225
+
226
+ AsyncFunction("pushPipelineAudio") { options: Map<String, Any?>, promise: Promise ->
227
+ pipelineIntegration.pushAudio(options, promise)
228
+ }
229
+
230
+ Function("pushPipelineAudioSync") { options: Map<String, Any?> ->
231
+ pipelineIntegration.pushAudioSync(options)
232
+ }
233
+
234
+ AsyncFunction("disconnectPipeline") { promise: Promise ->
235
+ pipelineIntegration.disconnect(promise)
236
+ }
237
+
238
+ AsyncFunction("invalidatePipelineTurn") { options: Map<String, Any?>, promise: Promise ->
239
+ pipelineIntegration.invalidateTurn(options, promise)
240
+ }
241
+
242
+ Function("getPipelineTelemetry") {
243
+ pipelineIntegration.getTelemetry()
244
+ }
245
+
246
+ Function("getPipelineState") {
247
+ pipelineIntegration.getState()
248
+ }
249
+
250
+ }
251
+ private fun initializeManager() {
252
+ val androidContext =
253
+ appContext.reactContext ?: throw IllegalStateException("Android context not available")
254
+ val permissionUtils = PermissionUtils(androidContext)
255
+ val audioEncoder = AudioDataEncoder()
256
+ val audioEffectsManager = AudioEffectsManager()
257
+ audioRecorderManager =
258
+ AudioRecorderManager(
259
+ permissionUtils,
260
+ audioEncoder,
261
+ this,
262
+ audioEffectsManager
263
+ )
264
+ }
265
+
266
+ private fun initializePlaybackManager() {
267
+ audioPlaybackManager = AudioPlaybackManager(this)
268
+ }
269
+
270
+ private fun initializePipeline() {
271
+ val ctx = appContext.reactContext
272
+ ?: throw IllegalStateException("Android context not available")
273
+ pipelineIntegration = PipelineIntegration(ctx, this)
274
+ }
275
+
276
+ override fun sendExpoEvent(eventName: String, params: Bundle) {
277
+ Log.d(Constants.TAG, "Sending event EXPO: $eventName")
278
+ this@ExpoPlayAudioStreamModule.sendEvent(eventName, params)
279
+ }
280
+ }
@@ -0,0 +1,16 @@
1
+ package expo.modules.audiostream
2
+
3
+ import android.content.Context
4
+ import android.content.pm.PackageManager
5
+ import androidx.core.content.ContextCompat
6
+
7
+ class PermissionUtils(private val context: Context) {
8
+
9
+ /**
10
+ * Checks if the recording permission has been granted.
11
+ * @return Boolean indicating whether the RECORD_AUDIO permission is granted.
12
+ */
13
+ fun checkRecordingPermission(): Boolean {
14
+ return ContextCompat.checkSelfPermission(context, android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
15
+ }
16
+ }
@@ -0,0 +1,60 @@
1
+ package expo.modules.audiostream
2
+
3
+ data class RecordingConfig(
4
+ val sampleRate: Int = Constants.DEFAULT_SAMPLE_RATE,
5
+ val channels: Int = 1,
6
+ val encoding: String = "pcm_16bit",
7
+ val interval: Long = Constants.DEFAULT_INTERVAL,
8
+ val pointsPerSecond: Double = 20.0
9
+ ) {
10
+ /**
11
+ * Validates the recording configuration
12
+ * @return Error information if invalid, null if valid
13
+ */
14
+ fun validate(): ValidationResult? {
15
+ // Check sample rate
16
+ if (sampleRate !in listOf(16000, 24000, 44100, 48000)) {
17
+ return ValidationResult(
18
+ "INVALID_SAMPLE_RATE",
19
+ "Sample rate must be one of 16000, 24000, 44100, or 48000 Hz"
20
+ )
21
+ }
22
+
23
+ // Check channels
24
+ if (channels !in 1..2) {
25
+ return ValidationResult(
26
+ "INVALID_CHANNELS",
27
+ "Channels must be either 1 (Mono) or 2 (Stereo)"
28
+ )
29
+ }
30
+
31
+ // All checks passed
32
+ return null
33
+ }
34
+
35
+ companion object {
36
+ /**
37
+ * Creates a RecordingConfig from options map
38
+ * @param options Map containing configuration options
39
+ * @return New RecordingConfig instance
40
+ */
41
+ fun fromOptions(options: Map<String, Any?>): RecordingConfig {
42
+ return RecordingConfig(
43
+ sampleRate = (options["sampleRate"] as? Number)?.toInt() ?: Constants.DEFAULT_SAMPLE_RATE,
44
+ channels = (options["channels"] as? Number)?.toInt() ?: 1,
45
+ encoding = options["encoding"] as? String ?: "pcm_16bit",
46
+ interval = (options["interval"] as? Number)?.toLong() ?: Constants.DEFAULT_INTERVAL,
47
+ pointsPerSecond = (options["pointsPerSecond"] as? Number)?.toDouble() ?: 20.0
48
+ )
49
+ }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Data class to hold validation error information
55
+ */
56
+ data class ValidationResult(
57
+ val code: String,
58
+ val message: String
59
+ )
60
+
@@ -0,0 +1,46 @@
1
+ package expo.modules.audiostream
2
+
3
+ /**
4
+ * Defines different playback modes for audio processing
5
+ */
6
+ enum class PlaybackMode {
7
+ /**
8
+ * Regular playback mode for standard audio playback
9
+ */
10
+ REGULAR,
11
+
12
+ /**
13
+ * Conversation mode optimized for speech
14
+ */
15
+ CONVERSATION,
16
+
17
+ /**
18
+ * Voice processing mode with enhanced voice quality
19
+ */
20
+ VOICE_PROCESSING
21
+ }
22
+
23
+ /**
24
+ * Configuration for audio playback settings
25
+ */
26
+ data class SoundConfig(
27
+ /**
28
+ * The sample rate for audio playback in Hz
29
+ */
30
+ val sampleRate: Int = 44100,
31
+
32
+ /**
33
+ * The playback mode (regular, conversation, or voiceProcessing)
34
+ */
35
+ val playbackMode: PlaybackMode = PlaybackMode.REGULAR
36
+ ) {
37
+ companion object {
38
+ /**
39
+ * Default configuration with standard settings
40
+ */
41
+ val DEFAULT = SoundConfig(
42
+ sampleRate = 44100,
43
+ playbackMode = PlaybackMode.REGULAR
44
+ )
45
+ }
46
+ }