@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.
- package/.eslintrc.js +5 -0
- package/.yarnrc.yml +8 -0
- package/NATIVE_EVENTS.md +270 -0
- package/README.md +289 -0
- package/android/build.gradle +92 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/expo/modules/audiostream/AudioDataEncoder.kt +178 -0
- package/android/src/main/java/expo/modules/audiostream/AudioEffectsManager.kt +107 -0
- package/android/src/main/java/expo/modules/audiostream/AudioPlaybackManager.kt +651 -0
- package/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt +509 -0
- package/android/src/main/java/expo/modules/audiostream/Constants.kt +21 -0
- package/android/src/main/java/expo/modules/audiostream/EventSender.kt +7 -0
- package/android/src/main/java/expo/modules/audiostream/ExpoAudioStreamView.kt +7 -0
- package/android/src/main/java/expo/modules/audiostream/ExpoPlayAudioStreamModule.kt +280 -0
- package/android/src/main/java/expo/modules/audiostream/PermissionUtils.kt +16 -0
- package/android/src/main/java/expo/modules/audiostream/RecordingConfig.kt +60 -0
- package/android/src/main/java/expo/modules/audiostream/SoundConfig.kt +46 -0
- package/android/src/main/java/expo/modules/audiostream/pipeline/AudioPipeline.kt +685 -0
- package/android/src/main/java/expo/modules/audiostream/pipeline/JitterBuffer.kt +227 -0
- package/android/src/main/java/expo/modules/audiostream/pipeline/PipelineIntegration.kt +315 -0
- package/app.plugin.js +1 -0
- package/build/ExpoPlayAudioStreamModule.d.ts +3 -0
- package/build/ExpoPlayAudioStreamModule.d.ts.map +1 -0
- package/build/ExpoPlayAudioStreamModule.js +5 -0
- package/build/ExpoPlayAudioStreamModule.js.map +1 -0
- package/build/events.d.ts +36 -0
- package/build/events.d.ts.map +1 -0
- package/build/events.js +25 -0
- package/build/events.js.map +1 -0
- package/build/index.d.ts +125 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +222 -0
- package/build/index.js.map +1 -0
- package/build/pipeline/index.d.ts +81 -0
- package/build/pipeline/index.d.ts.map +1 -0
- package/build/pipeline/index.js +140 -0
- package/build/pipeline/index.js.map +1 -0
- package/build/pipeline/types.d.ts +132 -0
- package/build/pipeline/types.d.ts.map +1 -0
- package/build/pipeline/types.js +5 -0
- package/build/pipeline/types.js.map +1 -0
- package/build/types.d.ts +221 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +10 -0
- package/build/types.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AudioPipeline.swift +562 -0
- package/ios/AudioUtils.swift +356 -0
- package/ios/ExpoPlayAudioStream.podspec +27 -0
- package/ios/ExpoPlayAudioStreamModule.swift +436 -0
- package/ios/ExpoPlayAudioStreamView.swift +7 -0
- package/ios/JitterBuffer.swift +208 -0
- package/ios/Logger.swift +7 -0
- package/ios/Microphone.swift +221 -0
- package/ios/MicrophoneDataDelegate.swift +4 -0
- package/ios/PipelineIntegration.swift +214 -0
- package/ios/RecordingResult.swift +10 -0
- package/ios/RecordingSettings.swift +11 -0
- package/ios/SharedAudioEngine.swift +484 -0
- package/ios/SoundConfig.swift +45 -0
- package/ios/SoundPlayer.swift +408 -0
- package/ios/SoundPlayerDelegate.swift +7 -0
- package/package.json +49 -0
- package/plugin/build/index.d.ts +5 -0
- package/plugin/build/index.js +28 -0
- package/plugin/src/index.ts +53 -0
- package/plugin/tsconfig.json +9 -0
- package/plugin/tsconfig.tsbuildinfo +1 -0
- package/src/ExpoPlayAudioStreamModule.ts +5 -0
- package/src/events.ts +66 -0
- package/src/index.ts +359 -0
- package/src/pipeline/index.ts +216 -0
- package/src/pipeline/types.ts +169 -0
- package/src/types.ts +270 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
package expo.modules.audiostream
|
|
2
|
+
|
|
3
|
+
import android.media.AudioFormat
|
|
4
|
+
import android.media.AudioRecord
|
|
5
|
+
import android.media.MediaRecorder
|
|
6
|
+
import android.os.Build
|
|
7
|
+
import android.util.Base64
|
|
8
|
+
import android.util.Log
|
|
9
|
+
import java.nio.ByteBuffer
|
|
10
|
+
import java.nio.ByteOrder
|
|
11
|
+
import kotlin.math.log10
|
|
12
|
+
import kotlin.math.sqrt
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Data class to hold audio format configuration
|
|
16
|
+
*/
|
|
17
|
+
data class AudioFormatConfig(
|
|
18
|
+
val audioFormat: Int,
|
|
19
|
+
val fileExtension: String,
|
|
20
|
+
val mimeType: String,
|
|
21
|
+
val error: String? = null
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
class AudioDataEncoder {
|
|
25
|
+
public fun encodeToBase64(rawData: ByteArray): String {
|
|
26
|
+
return Base64.encodeToString(rawData, Base64.NO_WRAP)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Calculates the power level (dBFS) of the audio data.
|
|
31
|
+
* Assumes PCM 16-bit encoding.
|
|
32
|
+
*
|
|
33
|
+
* @param audioData The byte array containing audio data.
|
|
34
|
+
* @param bytesRead The number of bytes read into the audioData buffer.
|
|
35
|
+
* @return The power level in dBFS (typically -160.0 to 0.0). Returns -160.0 for silence.
|
|
36
|
+
*/
|
|
37
|
+
public fun calculatePowerLevel(audioData: ByteArray, bytesRead: Int): Float {
|
|
38
|
+
if (bytesRead <= 0 || audioData.isEmpty()) {
|
|
39
|
+
return -160.0f // Represent silence or no data
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Assuming PCM 16-bit, so 2 bytes per sample
|
|
43
|
+
val shorts = ShortArray(bytesRead / 2)
|
|
44
|
+
// Ensure we only process the valid portion of the audioData buffer
|
|
45
|
+
val byteBuffer = ByteBuffer.wrap(audioData, 0, bytesRead).order(ByteOrder.LITTLE_ENDIAN)
|
|
46
|
+
byteBuffer.asShortBuffer().get(shorts)
|
|
47
|
+
|
|
48
|
+
if (shorts.isEmpty()) {
|
|
49
|
+
return -160.0f // Represent silence
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
var sumOfSquares: Double = 0.0
|
|
53
|
+
for (sample in shorts) {
|
|
54
|
+
val normalizedSample = sample / 32767.0 // Normalize sample to -1.0 to 1.0
|
|
55
|
+
sumOfSquares += normalizedSample * normalizedSample
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
val rms = sqrt(sumOfSquares / shorts.size)
|
|
59
|
+
|
|
60
|
+
// Handle RMS of 0 (silence) to avoid log10(0)
|
|
61
|
+
if (rms < 1e-9) { // Use a small epsilon to check for effective silence
|
|
62
|
+
return -160.0f
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Convert RMS to dBFS (dB relative to full scale)
|
|
66
|
+
val dbfs = 20.0 * log10(rms)
|
|
67
|
+
|
|
68
|
+
// Clamp the value to a minimum of -160 dBFS, maximum of 0 dBFS
|
|
69
|
+
return dbfs.toFloat().coerceIn(-160.0f, 0.0f)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Gets audio format configuration based on the encoding string
|
|
74
|
+
*
|
|
75
|
+
* @param encoding The encoding string (e.g., "pcm_16bit", "opus", etc.)
|
|
76
|
+
* @return AudioFormatConfig containing audioFormat, fileExtension, and mimeType
|
|
77
|
+
*/
|
|
78
|
+
fun getAudioFormatConfig(encoding: String): AudioFormatConfig {
|
|
79
|
+
return when (encoding) {
|
|
80
|
+
"pcm_8bit" -> AudioFormatConfig(
|
|
81
|
+
audioFormat = AudioFormat.ENCODING_PCM_8BIT,
|
|
82
|
+
fileExtension = "wav",
|
|
83
|
+
mimeType = "audio/wav"
|
|
84
|
+
)
|
|
85
|
+
"pcm_16bit" -> AudioFormatConfig(
|
|
86
|
+
audioFormat = AudioFormat.ENCODING_PCM_16BIT,
|
|
87
|
+
fileExtension = "wav",
|
|
88
|
+
mimeType = "audio/wav"
|
|
89
|
+
)
|
|
90
|
+
"pcm_32bit" -> AudioFormatConfig(
|
|
91
|
+
audioFormat = AudioFormat.ENCODING_PCM_FLOAT,
|
|
92
|
+
fileExtension = "wav",
|
|
93
|
+
mimeType = "audio/wav"
|
|
94
|
+
)
|
|
95
|
+
"opus" -> {
|
|
96
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
|
97
|
+
return AudioFormatConfig(
|
|
98
|
+
audioFormat = AudioFormat.ENCODING_DEFAULT,
|
|
99
|
+
fileExtension = "wav",
|
|
100
|
+
mimeType = "audio/wav",
|
|
101
|
+
error = "Opus encoding not supported on this Android version."
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
AudioFormatConfig(
|
|
105
|
+
audioFormat = AudioFormat.ENCODING_OPUS,
|
|
106
|
+
fileExtension = "opus",
|
|
107
|
+
mimeType = "audio/opus"
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
"aac_lc" -> AudioFormatConfig(
|
|
111
|
+
audioFormat = AudioFormat.ENCODING_AAC_LC,
|
|
112
|
+
fileExtension = "aac",
|
|
113
|
+
mimeType = "audio/aac"
|
|
114
|
+
)
|
|
115
|
+
else -> {
|
|
116
|
+
Log.d(Constants.TAG, "Unknown encoding: $encoding, defaulting to PCM_16BIT")
|
|
117
|
+
AudioFormatConfig(
|
|
118
|
+
audioFormat = AudioFormat.ENCODING_DEFAULT,
|
|
119
|
+
fileExtension = "wav",
|
|
120
|
+
mimeType = "audio/wav"
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Checks if a specific audio format configuration is supported by the device
|
|
128
|
+
*
|
|
129
|
+
* @param sampleRate The sample rate in Hz
|
|
130
|
+
* @param channels The number of channels (1 for mono, 2 for stereo)
|
|
131
|
+
* @param format The audio format constant from AudioFormat
|
|
132
|
+
* @param permissionUtils The PermissionUtils instance to check recording permissions
|
|
133
|
+
* @return True if the format is supported, false otherwise
|
|
134
|
+
* @throws SecurityException if recording permission is not granted
|
|
135
|
+
*/
|
|
136
|
+
fun isAudioFormatSupported(
|
|
137
|
+
sampleRate: Int,
|
|
138
|
+
channels: Int,
|
|
139
|
+
format: Int,
|
|
140
|
+
permissionUtils: PermissionUtils
|
|
141
|
+
): Boolean {
|
|
142
|
+
if (!permissionUtils.checkRecordingPermission()) {
|
|
143
|
+
throw SecurityException("Recording permission has not been granted")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
val channelConfig = if (channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO
|
|
147
|
+
val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, format)
|
|
148
|
+
|
|
149
|
+
if (bufferSize <= 0) {
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Always use VOICE_COMMUNICATION for better echo cancellation
|
|
154
|
+
val audioSource = MediaRecorder.AudioSource.VOICE_COMMUNICATION
|
|
155
|
+
|
|
156
|
+
val audioRecord = AudioRecord(
|
|
157
|
+
audioSource, // Using VOICE_COMMUNICATION source
|
|
158
|
+
sampleRate,
|
|
159
|
+
channelConfig,
|
|
160
|
+
format,
|
|
161
|
+
bufferSize
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
val isSupported = audioRecord.state == AudioRecord.STATE_INITIALIZED
|
|
165
|
+
if (isSupported) {
|
|
166
|
+
val testBuffer = ByteArray(bufferSize)
|
|
167
|
+
audioRecord.startRecording()
|
|
168
|
+
val testRead = audioRecord.read(testBuffer, 0, bufferSize)
|
|
169
|
+
audioRecord.stop()
|
|
170
|
+
if (testRead < 0) {
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
audioRecord.release()
|
|
176
|
+
return isSupported
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
package expo.modules.audiostream
|
|
2
|
+
|
|
3
|
+
import android.media.AudioRecord
|
|
4
|
+
import android.media.audiofx.AcousticEchoCanceler
|
|
5
|
+
import android.media.audiofx.AutomaticGainControl
|
|
6
|
+
import android.media.audiofx.NoiseSuppressor
|
|
7
|
+
import android.util.Log
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Manages audio effects for voice recording, including:
|
|
11
|
+
* - Acoustic Echo Cancellation (AEC)
|
|
12
|
+
* - Noise Suppression (NS)
|
|
13
|
+
* - Automatic Gain Control (AGC)
|
|
14
|
+
*/
|
|
15
|
+
class AudioEffectsManager {
|
|
16
|
+
// Audio effects
|
|
17
|
+
private var acousticEchoCanceler: AcousticEchoCanceler? = null
|
|
18
|
+
private var noiseSuppressor: NoiseSuppressor? = null
|
|
19
|
+
private var automaticGainControl: AutomaticGainControl? = null
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Sets up audio effects for the provided AudioRecord instance
|
|
23
|
+
* @param audioRecord The AudioRecord instance to apply effects to
|
|
24
|
+
*/
|
|
25
|
+
fun setupAudioEffects(audioRecord: AudioRecord) {
|
|
26
|
+
val audioSessionId = audioRecord.audioSessionId
|
|
27
|
+
|
|
28
|
+
// Release any existing effects first
|
|
29
|
+
releaseAudioEffects()
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// Log availability of audio effects
|
|
33
|
+
Log.d(Constants.TAG, "AEC available: ${AcousticEchoCanceler.isAvailable()}")
|
|
34
|
+
Log.d(Constants.TAG, "NS available: ${NoiseSuppressor.isAvailable()}")
|
|
35
|
+
Log.d(Constants.TAG, "AGC available: ${AutomaticGainControl.isAvailable()}")
|
|
36
|
+
|
|
37
|
+
// Apply echo cancellation if available
|
|
38
|
+
if (AcousticEchoCanceler.isAvailable()) {
|
|
39
|
+
acousticEchoCanceler = AcousticEchoCanceler.create(audioSessionId)
|
|
40
|
+
acousticEchoCanceler?.enabled = true
|
|
41
|
+
Log.d(Constants.TAG, "Acoustic Echo Canceler enabled: ${acousticEchoCanceler?.enabled}")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Apply noise suppression
|
|
45
|
+
enableNoiseSuppression(audioSessionId)
|
|
46
|
+
|
|
47
|
+
// Apply automatic gain control
|
|
48
|
+
enableAutomaticGainControl(audioSessionId)
|
|
49
|
+
|
|
50
|
+
} catch (e: Exception) {
|
|
51
|
+
Log.e(Constants.TAG, "Error setting up audio effects", e)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Enables Noise Suppression if available for the given audio session.
|
|
57
|
+
* @param audioSessionId The audio session ID to apply the effect to.
|
|
58
|
+
*/
|
|
59
|
+
private fun enableNoiseSuppression(audioSessionId: Int) {
|
|
60
|
+
// Apply noise suppression if available
|
|
61
|
+
if (NoiseSuppressor.isAvailable()) {
|
|
62
|
+
noiseSuppressor = NoiseSuppressor.create(audioSessionId)
|
|
63
|
+
noiseSuppressor?.enabled = true
|
|
64
|
+
Log.d(Constants.TAG, "Noise Suppressor enabled: ${noiseSuppressor?.enabled}")
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Enables Automatic Gain Control if available for the given audio session.
|
|
70
|
+
* @param audioSessionId The audio session ID to apply the effect to.
|
|
71
|
+
*/
|
|
72
|
+
private fun enableAutomaticGainControl(audioSessionId: Int) {
|
|
73
|
+
// Apply automatic gain control if available
|
|
74
|
+
if (AutomaticGainControl.isAvailable()) {
|
|
75
|
+
automaticGainControl = AutomaticGainControl.create(audioSessionId)
|
|
76
|
+
automaticGainControl?.enabled = true
|
|
77
|
+
Log.d(Constants.TAG, "Automatic Gain Control enabled: ${automaticGainControl?.enabled}")
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Releases all audio effects
|
|
83
|
+
*/
|
|
84
|
+
fun releaseAudioEffects() {
|
|
85
|
+
try {
|
|
86
|
+
acousticEchoCanceler?.let {
|
|
87
|
+
if (it.enabled) it.enabled = false
|
|
88
|
+
it.release()
|
|
89
|
+
acousticEchoCanceler = null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
noiseSuppressor?.let {
|
|
93
|
+
if (it.enabled) it.enabled = false
|
|
94
|
+
it.release()
|
|
95
|
+
noiseSuppressor = null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
automaticGainControl?.let {
|
|
99
|
+
if (it.enabled) it.enabled = false
|
|
100
|
+
it.release()
|
|
101
|
+
automaticGainControl = null
|
|
102
|
+
}
|
|
103
|
+
} catch (e: Exception) {
|
|
104
|
+
Log.e(Constants.TAG, "Error releasing audio effects", e)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|