@cloudfort/callum-voice 0.0.3 → 0.0.4
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/README.md +5 -5
- package/android/build.gradle +1 -1
- package/android/src/main/AndroidManifest.xml +1 -1
- package/android/src/main/java/{com/callumvoice → ir/cloudfort/callum}/CallumVoiceModule.kt +289 -289
- package/android/src/main/java/{com/callumvoice → ir/cloudfort/callum}/CallumVoicePackage.kt +32 -32
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ callum-react-native/
|
|
|
70
70
|
│ ├── build.gradle # Android build config
|
|
71
71
|
│ └── src/main/
|
|
72
72
|
│ ├── AndroidManifest.xml # Permissions
|
|
73
|
-
│ └── java/
|
|
73
|
+
│ └── java/ir/cloudfort/callum/
|
|
74
74
|
│ ├── CallumVoiceModule.kt # Native audio module
|
|
75
75
|
│ └── CallumVoicePackage.kt # Package registration
|
|
76
76
|
├── ios/
|
|
@@ -136,7 +136,7 @@ cd ios && pod install && cd ..
|
|
|
136
136
|
In `MainApplication.kt`:
|
|
137
137
|
|
|
138
138
|
```kotlin
|
|
139
|
-
import
|
|
139
|
+
import ir.cloudfort.callum.CallumVoicePackage
|
|
140
140
|
|
|
141
141
|
override fun getPackages(): List<ReactPackage> {
|
|
142
142
|
return listOf(
|
|
@@ -173,7 +173,7 @@ import { VoiceClient, createVoiceConfig } from 'callum-voice';
|
|
|
173
173
|
// 1. Create config
|
|
174
174
|
const config = createVoiceConfig({
|
|
175
175
|
server: 'callem.cloudfort.ir',
|
|
176
|
-
apiKey: '
|
|
176
|
+
apiKey: 'vc_token',
|
|
177
177
|
peerId: 'user_123',
|
|
178
178
|
});
|
|
179
179
|
|
|
@@ -236,7 +236,7 @@ export default function VoiceChatScreen() {
|
|
|
236
236
|
useEffect(() => {
|
|
237
237
|
const config = createVoiceConfig({
|
|
238
238
|
server: 'callem.cloudfort.ir',
|
|
239
|
-
apiKey: '
|
|
239
|
+
apiKey: 'vc_token',
|
|
240
240
|
peerId: `user_${Date.now()}`,
|
|
241
241
|
});
|
|
242
242
|
|
|
@@ -550,7 +550,7 @@ interface VoiceEventListener {
|
|
|
550
550
|
|
|
551
551
|
### Android (Kotlin)
|
|
552
552
|
|
|
553
|
-
The Android native module at `android/src/main/java/
|
|
553
|
+
The Android native module at `android/src/main/java/ir/cloudfort/callum/CallumVoiceModule.kt` provides:
|
|
554
554
|
|
|
555
555
|
| Method | Description |
|
|
556
556
|
|--------|-------------|
|
package/android/build.gradle
CHANGED
|
@@ -1,289 +1,289 @@
|
|
|
1
|
-
// Copyright Cloudfort. All Rights Reserved.
|
|
2
|
-
|
|
3
|
-
package
|
|
4
|
-
|
|
5
|
-
import android.media.AudioFormat
|
|
6
|
-
import android.media.AudioManager
|
|
7
|
-
import android.media.AudioRecord
|
|
8
|
-
import android.media.AudioTrack
|
|
9
|
-
import android.media.MediaRecorder
|
|
10
|
-
import android.os.Build
|
|
11
|
-
import com.facebook.react.bridge.*
|
|
12
|
-
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* React Native native module for Android audio capture and playback.
|
|
16
|
-
*
|
|
17
|
-
* Handles microphone recording (AudioRecord) and audio playback (AudioTrack)
|
|
18
|
-
* using PCM 16-bit mono at 48000 Hz. Communicates audio data to/from the
|
|
19
|
-
* JavaScript layer via DeviceEventManagerModule events.
|
|
20
|
-
*/
|
|
21
|
-
class CallumVoiceModule(reactContext: ReactApplicationContext)
|
|
22
|
-
: ReactContextBaseJavaModule(reactContext) {
|
|
23
|
-
|
|
24
|
-
companion object {
|
|
25
|
-
private const val MODULE_NAME = "CallumVoice"
|
|
26
|
-
private const val SAMPLE_RATE = 48000
|
|
27
|
-
private const val FRAME_SIZE = 960 // 20ms at 48kHz
|
|
28
|
-
|
|
29
|
-
// JS event names
|
|
30
|
-
private const val EVENT_AUDIO_CAPTURED = "onAudioCaptured"
|
|
31
|
-
private const val EVENT_AUDIO_ERROR = "onAudioError"
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
private var audioRecord: AudioRecord? = null
|
|
35
|
-
private var audioTrack: AudioTrack? = null
|
|
36
|
-
private var captureThread: Thread? = null
|
|
37
|
-
private var playbackThread: Thread? = null
|
|
38
|
-
private var captureRunning = false
|
|
39
|
-
private var playbackRunning = false
|
|
40
|
-
|
|
41
|
-
override fun getName(): String = MODULE_NAME
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Exported constants accessible from JavaScript via NativeModules.CallumVoice.
|
|
45
|
-
*/
|
|
46
|
-
override fun getConstants(): Map<String, Any> {
|
|
47
|
-
return hashMapOf(
|
|
48
|
-
"SAMPLE_RATE" to SAMPLE_RATE,
|
|
49
|
-
"FRAME_SIZE" to FRAME_SIZE
|
|
50
|
-
)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ── Microphone Capture ─────────────────────────────────────────
|
|
54
|
-
|
|
55
|
-
@ReactMethod
|
|
56
|
-
fun startAudioCapture(promise: Promise) {
|
|
57
|
-
try {
|
|
58
|
-
val bufferSize = AudioRecord.getMinBufferSize(
|
|
59
|
-
SAMPLE_RATE,
|
|
60
|
-
AudioFormat.CHANNEL_IN_MONO,
|
|
61
|
-
AudioFormat.ENCODING_PCM_16BIT
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
if (bufferSize == AudioRecord.ERROR || bufferSize == AudioRecord.ERROR_BAD_VALUE) {
|
|
65
|
-
promise.reject("AUDIO_ERROR", "Cannot get minimum buffer size")
|
|
66
|
-
return
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
val audioSource = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
70
|
-
MediaRecorder.AudioSource.VOICE_COMMUNICATION
|
|
71
|
-
} else {
|
|
72
|
-
MediaRecorder.AudioSource.VOICE_COMMUNICATION
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
audioRecord = AudioRecord(
|
|
76
|
-
audioSource,
|
|
77
|
-
SAMPLE_RATE,
|
|
78
|
-
AudioFormat.CHANNEL_IN_MONO,
|
|
79
|
-
AudioFormat.ENCODING_PCM_16BIT,
|
|
80
|
-
bufferSize * 2
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
|
|
84
|
-
promise.reject("AUDIO_ERROR", "AudioRecord initialization failed")
|
|
85
|
-
return
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Configure audio processing
|
|
89
|
-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
90
|
-
val audioSessionId = audioRecord!!.audioSessionId
|
|
91
|
-
// TODO: Attach AcousticEchoCanceler, NoiseSuppressor, AutomaticGainController
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
audioRecord!!.startRecording()
|
|
95
|
-
captureRunning = true
|
|
96
|
-
|
|
97
|
-
captureThread = Thread {
|
|
98
|
-
val buffer = ShortArray(FRAME_SIZE)
|
|
99
|
-
while (captureRunning) {
|
|
100
|
-
val read = audioRecord?.read(buffer, 0, FRAME_SIZE) ?: -1
|
|
101
|
-
if (read > 0) {
|
|
102
|
-
// Send captured PCM samples to JavaScript
|
|
103
|
-
val writableArray = Arguments.createArray()
|
|
104
|
-
for (i in 0 until read) {
|
|
105
|
-
writableArray.pushInt(buffer[i].toInt())
|
|
106
|
-
}
|
|
107
|
-
reactApplicationContext
|
|
108
|
-
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
109
|
-
.emit(EVENT_AUDIO_CAPTURED, writableArray)
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}.apply {
|
|
113
|
-
name = "CallumVoice-Capture"
|
|
114
|
-
start()
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
promise.resolve(null)
|
|
118
|
-
} catch (e: SecurityException) {
|
|
119
|
-
promise.reject("PERMISSION_DENIED", "Microphone permission not granted", e)
|
|
120
|
-
} catch (e: Exception) {
|
|
121
|
-
promise.reject("AUDIO_ERROR", "Failed to start audio capture: ${e.message}", e)
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
@ReactMethod
|
|
126
|
-
fun stopAudioCapture(promise: Promise) {
|
|
127
|
-
captureRunning = false
|
|
128
|
-
try {
|
|
129
|
-
captureThread?.join(1000)
|
|
130
|
-
audioRecord?.stop()
|
|
131
|
-
audioRecord?.release()
|
|
132
|
-
} catch (e: Exception) {
|
|
133
|
-
// ignore cleanup errors
|
|
134
|
-
}
|
|
135
|
-
audioRecord = null
|
|
136
|
-
captureThread = null
|
|
137
|
-
promise.resolve(null)
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// ── Audio Playback ─────────────────────────────────────────────
|
|
141
|
-
|
|
142
|
-
@ReactMethod
|
|
143
|
-
fun startAudioPlayback(promise: Promise) {
|
|
144
|
-
try {
|
|
145
|
-
val bufferSize = AudioTrack.getMinBufferSize(
|
|
146
|
-
SAMPLE_RATE,
|
|
147
|
-
AudioFormat.CHANNEL_OUT_MONO,
|
|
148
|
-
AudioFormat.ENCODING_PCM_16BIT
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
if (bufferSize == AudioTrack.ERROR || bufferSize == AudioTrack.ERROR_BAD_VALUE) {
|
|
152
|
-
promise.reject("AUDIO_ERROR", "Cannot get minimum buffer size")
|
|
153
|
-
return
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
val streamType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
157
|
-
AudioManager.STREAM_VOICE_CALL
|
|
158
|
-
} else {
|
|
159
|
-
AudioManager.STREAM_VOICE_CALL
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
audioTrack = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
163
|
-
AudioTrack.Builder()
|
|
164
|
-
.setAudioAttributes(
|
|
165
|
-
android.media.AudioAttributes.Builder()
|
|
166
|
-
.setUsage(android.media.AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
|
167
|
-
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SPEECH)
|
|
168
|
-
.build()
|
|
169
|
-
)
|
|
170
|
-
.setAudioFormat(
|
|
171
|
-
AudioFormat.Builder()
|
|
172
|
-
.setSampleRate(SAMPLE_RATE)
|
|
173
|
-
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
|
174
|
-
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
|
175
|
-
.build()
|
|
176
|
-
)
|
|
177
|
-
.setBufferSizeInBytes(bufferSize * 2)
|
|
178
|
-
.setTransferMode(AudioTrack.MODE_STREAM)
|
|
179
|
-
.build()
|
|
180
|
-
} else {
|
|
181
|
-
@Suppress("DEPRECATION")
|
|
182
|
-
AudioTrack(
|
|
183
|
-
streamType,
|
|
184
|
-
SAMPLE_RATE,
|
|
185
|
-
AudioFormat.CHANNEL_OUT_MONO,
|
|
186
|
-
AudioFormat.ENCODING_PCM_16BIT,
|
|
187
|
-
bufferSize * 2,
|
|
188
|
-
AudioTrack.MODE_STREAM
|
|
189
|
-
)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if (audioTrack?.state != AudioTrack.STATE_INITIALIZED) {
|
|
193
|
-
promise.reject("AUDIO_ERROR", "AudioTrack initialization failed")
|
|
194
|
-
return
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
audioTrack!!.play()
|
|
198
|
-
playbackRunning = true
|
|
199
|
-
|
|
200
|
-
// TODO: Implement playback loop that receives audio from JS and writes to AudioTrack
|
|
201
|
-
// For now, audio playback is handled via enqueuePeerAudio method
|
|
202
|
-
|
|
203
|
-
promise.resolve(null)
|
|
204
|
-
} catch (e: Exception) {
|
|
205
|
-
promise.reject("AUDIO_ERROR", "Failed to start audio playback: ${e.message}", e)
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
@ReactMethod
|
|
210
|
-
fun stopAudioPlayback(promise: Promise) {
|
|
211
|
-
playbackRunning = false
|
|
212
|
-
try {
|
|
213
|
-
playbackThread?.join(1000)
|
|
214
|
-
audioTrack?.stop()
|
|
215
|
-
audioTrack?.release()
|
|
216
|
-
} catch (e: Exception) {
|
|
217
|
-
// ignore cleanup errors
|
|
218
|
-
}
|
|
219
|
-
audioTrack = null
|
|
220
|
-
playbackThread = null
|
|
221
|
-
promise.resolve(null)
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Enqueue PCM audio samples for a specific peer.
|
|
226
|
-
* Called from JavaScript when binary audio is received from the server.
|
|
227
|
-
*/
|
|
228
|
-
@ReactMethod
|
|
229
|
-
fun enqueuePeerAudio(peerId: String, samples: ReadableArray) {
|
|
230
|
-
if (audioTrack == null || !playbackRunning) return
|
|
231
|
-
|
|
232
|
-
val shortArray = ShortArray(samples.size())
|
|
233
|
-
for (i in 0 until samples.size()) {
|
|
234
|
-
shortArray[i] = samples.getInt(i).toShort()
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
audioTrack?.write(shortArray, 0, shortArray.size)
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// ── Speaker Routing ────────────────────────────────────────────
|
|
241
|
-
|
|
242
|
-
@ReactMethod
|
|
243
|
-
fun setSpeakerphoneOn(on: Boolean, promise: Promise) {
|
|
244
|
-
try {
|
|
245
|
-
val audioManager = reactApplicationContext
|
|
246
|
-
.getSystemService(ReactApplicationContext.AUDIO_SERVICE) as AudioManager
|
|
247
|
-
|
|
248
|
-
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
|
249
|
-
audioManager.isSpeakerphoneOn = on
|
|
250
|
-
|
|
251
|
-
promise.resolve(null)
|
|
252
|
-
} catch (e: Exception) {
|
|
253
|
-
promise.reject("AUDIO_ERROR", "Failed to set speakerphone: ${e.message}", e)
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// ── Audio Session ──────────────────────────────────────────────
|
|
258
|
-
|
|
259
|
-
@ReactMethod
|
|
260
|
-
fun configureAudioSession(promise: Promise) {
|
|
261
|
-
try {
|
|
262
|
-
val audioManager = reactApplicationContext
|
|
263
|
-
.getSystemService(ReactApplicationContext.AUDIO_SERVICE) as AudioManager
|
|
264
|
-
|
|
265
|
-
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
|
266
|
-
audioManager.isMusicActive = false
|
|
267
|
-
|
|
268
|
-
promise.resolve(null)
|
|
269
|
-
} catch (e: Exception) {
|
|
270
|
-
promise.reject("AUDIO_ERROR", "Failed to configure audio session: ${e.message}", e)
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
override fun onCatalystInstanceDestroy() {
|
|
275
|
-
super.onCatalystInstanceDestroy()
|
|
276
|
-
captureRunning = false
|
|
277
|
-
playbackRunning = false
|
|
278
|
-
try {
|
|
279
|
-
audioRecord?.stop()
|
|
280
|
-
audioRecord?.release()
|
|
281
|
-
} catch (e: Exception) { }
|
|
282
|
-
try {
|
|
283
|
-
audioTrack?.stop()
|
|
284
|
-
audioTrack?.release()
|
|
285
|
-
} catch (e: Exception) { }
|
|
286
|
-
audioRecord = null
|
|
287
|
-
audioTrack = null
|
|
288
|
-
}
|
|
289
|
-
}
|
|
1
|
+
// Copyright Cloudfort. All Rights Reserved.
|
|
2
|
+
|
|
3
|
+
package ir.cloudfort.callum
|
|
4
|
+
|
|
5
|
+
import android.media.AudioFormat
|
|
6
|
+
import android.media.AudioManager
|
|
7
|
+
import android.media.AudioRecord
|
|
8
|
+
import android.media.AudioTrack
|
|
9
|
+
import android.media.MediaRecorder
|
|
10
|
+
import android.os.Build
|
|
11
|
+
import com.facebook.react.bridge.*
|
|
12
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* React Native native module for Android audio capture and playback.
|
|
16
|
+
*
|
|
17
|
+
* Handles microphone recording (AudioRecord) and audio playback (AudioTrack)
|
|
18
|
+
* using PCM 16-bit mono at 48000 Hz. Communicates audio data to/from the
|
|
19
|
+
* JavaScript layer via DeviceEventManagerModule events.
|
|
20
|
+
*/
|
|
21
|
+
class CallumVoiceModule(reactContext: ReactApplicationContext)
|
|
22
|
+
: ReactContextBaseJavaModule(reactContext) {
|
|
23
|
+
|
|
24
|
+
companion object {
|
|
25
|
+
private const val MODULE_NAME = "CallumVoice"
|
|
26
|
+
private const val SAMPLE_RATE = 48000
|
|
27
|
+
private const val FRAME_SIZE = 960 // 20ms at 48kHz
|
|
28
|
+
|
|
29
|
+
// JS event names
|
|
30
|
+
private const val EVENT_AUDIO_CAPTURED = "onAudioCaptured"
|
|
31
|
+
private const val EVENT_AUDIO_ERROR = "onAudioError"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private var audioRecord: AudioRecord? = null
|
|
35
|
+
private var audioTrack: AudioTrack? = null
|
|
36
|
+
private var captureThread: Thread? = null
|
|
37
|
+
private var playbackThread: Thread? = null
|
|
38
|
+
private var captureRunning = false
|
|
39
|
+
private var playbackRunning = false
|
|
40
|
+
|
|
41
|
+
override fun getName(): String = MODULE_NAME
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Exported constants accessible from JavaScript via NativeModules.CallumVoice.
|
|
45
|
+
*/
|
|
46
|
+
override fun getConstants(): Map<String, Any> {
|
|
47
|
+
return hashMapOf(
|
|
48
|
+
"SAMPLE_RATE" to SAMPLE_RATE,
|
|
49
|
+
"FRAME_SIZE" to FRAME_SIZE
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Microphone Capture ─────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
@ReactMethod
|
|
56
|
+
fun startAudioCapture(promise: Promise) {
|
|
57
|
+
try {
|
|
58
|
+
val bufferSize = AudioRecord.getMinBufferSize(
|
|
59
|
+
SAMPLE_RATE,
|
|
60
|
+
AudioFormat.CHANNEL_IN_MONO,
|
|
61
|
+
AudioFormat.ENCODING_PCM_16BIT
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if (bufferSize == AudioRecord.ERROR || bufferSize == AudioRecord.ERROR_BAD_VALUE) {
|
|
65
|
+
promise.reject("AUDIO_ERROR", "Cannot get minimum buffer size")
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
val audioSource = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
70
|
+
MediaRecorder.AudioSource.VOICE_COMMUNICATION
|
|
71
|
+
} else {
|
|
72
|
+
MediaRecorder.AudioSource.VOICE_COMMUNICATION
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
audioRecord = AudioRecord(
|
|
76
|
+
audioSource,
|
|
77
|
+
SAMPLE_RATE,
|
|
78
|
+
AudioFormat.CHANNEL_IN_MONO,
|
|
79
|
+
AudioFormat.ENCODING_PCM_16BIT,
|
|
80
|
+
bufferSize * 2
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
|
|
84
|
+
promise.reject("AUDIO_ERROR", "AudioRecord initialization failed")
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Configure audio processing
|
|
89
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
90
|
+
val audioSessionId = audioRecord!!.audioSessionId
|
|
91
|
+
// TODO: Attach AcousticEchoCanceler, NoiseSuppressor, AutomaticGainController
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
audioRecord!!.startRecording()
|
|
95
|
+
captureRunning = true
|
|
96
|
+
|
|
97
|
+
captureThread = Thread {
|
|
98
|
+
val buffer = ShortArray(FRAME_SIZE)
|
|
99
|
+
while (captureRunning) {
|
|
100
|
+
val read = audioRecord?.read(buffer, 0, FRAME_SIZE) ?: -1
|
|
101
|
+
if (read > 0) {
|
|
102
|
+
// Send captured PCM samples to JavaScript
|
|
103
|
+
val writableArray = Arguments.createArray()
|
|
104
|
+
for (i in 0 until read) {
|
|
105
|
+
writableArray.pushInt(buffer[i].toInt())
|
|
106
|
+
}
|
|
107
|
+
reactApplicationContext
|
|
108
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
109
|
+
.emit(EVENT_AUDIO_CAPTURED, writableArray)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}.apply {
|
|
113
|
+
name = "CallumVoice-Capture"
|
|
114
|
+
start()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
promise.resolve(null)
|
|
118
|
+
} catch (e: SecurityException) {
|
|
119
|
+
promise.reject("PERMISSION_DENIED", "Microphone permission not granted", e)
|
|
120
|
+
} catch (e: Exception) {
|
|
121
|
+
promise.reject("AUDIO_ERROR", "Failed to start audio capture: ${e.message}", e)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@ReactMethod
|
|
126
|
+
fun stopAudioCapture(promise: Promise) {
|
|
127
|
+
captureRunning = false
|
|
128
|
+
try {
|
|
129
|
+
captureThread?.join(1000)
|
|
130
|
+
audioRecord?.stop()
|
|
131
|
+
audioRecord?.release()
|
|
132
|
+
} catch (e: Exception) {
|
|
133
|
+
// ignore cleanup errors
|
|
134
|
+
}
|
|
135
|
+
audioRecord = null
|
|
136
|
+
captureThread = null
|
|
137
|
+
promise.resolve(null)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Audio Playback ─────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
@ReactMethod
|
|
143
|
+
fun startAudioPlayback(promise: Promise) {
|
|
144
|
+
try {
|
|
145
|
+
val bufferSize = AudioTrack.getMinBufferSize(
|
|
146
|
+
SAMPLE_RATE,
|
|
147
|
+
AudioFormat.CHANNEL_OUT_MONO,
|
|
148
|
+
AudioFormat.ENCODING_PCM_16BIT
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if (bufferSize == AudioTrack.ERROR || bufferSize == AudioTrack.ERROR_BAD_VALUE) {
|
|
152
|
+
promise.reject("AUDIO_ERROR", "Cannot get minimum buffer size")
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
val streamType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
157
|
+
AudioManager.STREAM_VOICE_CALL
|
|
158
|
+
} else {
|
|
159
|
+
AudioManager.STREAM_VOICE_CALL
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
audioTrack = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
163
|
+
AudioTrack.Builder()
|
|
164
|
+
.setAudioAttributes(
|
|
165
|
+
android.media.AudioAttributes.Builder()
|
|
166
|
+
.setUsage(android.media.AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
|
167
|
+
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SPEECH)
|
|
168
|
+
.build()
|
|
169
|
+
)
|
|
170
|
+
.setAudioFormat(
|
|
171
|
+
AudioFormat.Builder()
|
|
172
|
+
.setSampleRate(SAMPLE_RATE)
|
|
173
|
+
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
|
174
|
+
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
|
175
|
+
.build()
|
|
176
|
+
)
|
|
177
|
+
.setBufferSizeInBytes(bufferSize * 2)
|
|
178
|
+
.setTransferMode(AudioTrack.MODE_STREAM)
|
|
179
|
+
.build()
|
|
180
|
+
} else {
|
|
181
|
+
@Suppress("DEPRECATION")
|
|
182
|
+
AudioTrack(
|
|
183
|
+
streamType,
|
|
184
|
+
SAMPLE_RATE,
|
|
185
|
+
AudioFormat.CHANNEL_OUT_MONO,
|
|
186
|
+
AudioFormat.ENCODING_PCM_16BIT,
|
|
187
|
+
bufferSize * 2,
|
|
188
|
+
AudioTrack.MODE_STREAM
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (audioTrack?.state != AudioTrack.STATE_INITIALIZED) {
|
|
193
|
+
promise.reject("AUDIO_ERROR", "AudioTrack initialization failed")
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
audioTrack!!.play()
|
|
198
|
+
playbackRunning = true
|
|
199
|
+
|
|
200
|
+
// TODO: Implement playback loop that receives audio from JS and writes to AudioTrack
|
|
201
|
+
// For now, audio playback is handled via enqueuePeerAudio method
|
|
202
|
+
|
|
203
|
+
promise.resolve(null)
|
|
204
|
+
} catch (e: Exception) {
|
|
205
|
+
promise.reject("AUDIO_ERROR", "Failed to start audio playback: ${e.message}", e)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
@ReactMethod
|
|
210
|
+
fun stopAudioPlayback(promise: Promise) {
|
|
211
|
+
playbackRunning = false
|
|
212
|
+
try {
|
|
213
|
+
playbackThread?.join(1000)
|
|
214
|
+
audioTrack?.stop()
|
|
215
|
+
audioTrack?.release()
|
|
216
|
+
} catch (e: Exception) {
|
|
217
|
+
// ignore cleanup errors
|
|
218
|
+
}
|
|
219
|
+
audioTrack = null
|
|
220
|
+
playbackThread = null
|
|
221
|
+
promise.resolve(null)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Enqueue PCM audio samples for a specific peer.
|
|
226
|
+
* Called from JavaScript when binary audio is received from the server.
|
|
227
|
+
*/
|
|
228
|
+
@ReactMethod
|
|
229
|
+
fun enqueuePeerAudio(peerId: String, samples: ReadableArray) {
|
|
230
|
+
if (audioTrack == null || !playbackRunning) return
|
|
231
|
+
|
|
232
|
+
val shortArray = ShortArray(samples.size())
|
|
233
|
+
for (i in 0 until samples.size()) {
|
|
234
|
+
shortArray[i] = samples.getInt(i).toShort()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
audioTrack?.write(shortArray, 0, shortArray.size)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Speaker Routing ────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
@ReactMethod
|
|
243
|
+
fun setSpeakerphoneOn(on: Boolean, promise: Promise) {
|
|
244
|
+
try {
|
|
245
|
+
val audioManager = reactApplicationContext
|
|
246
|
+
.getSystemService(ReactApplicationContext.AUDIO_SERVICE) as AudioManager
|
|
247
|
+
|
|
248
|
+
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
|
249
|
+
audioManager.isSpeakerphoneOn = on
|
|
250
|
+
|
|
251
|
+
promise.resolve(null)
|
|
252
|
+
} catch (e: Exception) {
|
|
253
|
+
promise.reject("AUDIO_ERROR", "Failed to set speakerphone: ${e.message}", e)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Audio Session ──────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
@ReactMethod
|
|
260
|
+
fun configureAudioSession(promise: Promise) {
|
|
261
|
+
try {
|
|
262
|
+
val audioManager = reactApplicationContext
|
|
263
|
+
.getSystemService(ReactApplicationContext.AUDIO_SERVICE) as AudioManager
|
|
264
|
+
|
|
265
|
+
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
|
266
|
+
audioManager.isMusicActive = false
|
|
267
|
+
|
|
268
|
+
promise.resolve(null)
|
|
269
|
+
} catch (e: Exception) {
|
|
270
|
+
promise.reject("AUDIO_ERROR", "Failed to configure audio session: ${e.message}", e)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
override fun onCatalystInstanceDestroy() {
|
|
275
|
+
super.onCatalystInstanceDestroy()
|
|
276
|
+
captureRunning = false
|
|
277
|
+
playbackRunning = false
|
|
278
|
+
try {
|
|
279
|
+
audioRecord?.stop()
|
|
280
|
+
audioRecord?.release()
|
|
281
|
+
} catch (e: Exception) { }
|
|
282
|
+
try {
|
|
283
|
+
audioTrack?.stop()
|
|
284
|
+
audioTrack?.release()
|
|
285
|
+
} catch (e: Exception) { }
|
|
286
|
+
audioRecord = null
|
|
287
|
+
audioTrack = null
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
// Copyright Cloudfort. All Rights Reserved.
|
|
2
|
-
|
|
3
|
-
package
|
|
4
|
-
|
|
5
|
-
import com.facebook.react.ReactPackage
|
|
6
|
-
import com.facebook.react.bridge.NativeModule
|
|
7
|
-
import com.facebook.react.bridge.ReactApplicationContext
|
|
8
|
-
import com.facebook.react.uimanager.ViewManager
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* React Native package registration for CallumVoice native module.
|
|
12
|
-
*
|
|
13
|
-
* Add this to your MainApplication.java/kt:
|
|
14
|
-
* ```
|
|
15
|
-
* override fun getPackages(): List<ReactPackage> {
|
|
16
|
-
* return listOf(
|
|
17
|
-
* MainReactPackage(),
|
|
18
|
-
* CallumVoicePackage()
|
|
19
|
-
* )
|
|
20
|
-
* }
|
|
21
|
-
* ```
|
|
22
|
-
*/
|
|
23
|
-
class CallumVoicePackage : ReactPackage {
|
|
24
|
-
|
|
25
|
-
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
|
26
|
-
return listOf(CallumVoiceModule(reactContext))
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
|
30
|
-
return emptyList()
|
|
31
|
-
}
|
|
32
|
-
}
|
|
1
|
+
// Copyright Cloudfort. All Rights Reserved.
|
|
2
|
+
|
|
3
|
+
package ir.cloudfort.callum
|
|
4
|
+
|
|
5
|
+
import com.facebook.react.ReactPackage
|
|
6
|
+
import com.facebook.react.bridge.NativeModule
|
|
7
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
8
|
+
import com.facebook.react.uimanager.ViewManager
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* React Native package registration for CallumVoice native module.
|
|
12
|
+
*
|
|
13
|
+
* Add this to your MainApplication.java/kt:
|
|
14
|
+
* ```
|
|
15
|
+
* override fun getPackages(): List<ReactPackage> {
|
|
16
|
+
* return listOf(
|
|
17
|
+
* MainReactPackage(),
|
|
18
|
+
* CallumVoicePackage()
|
|
19
|
+
* )
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
class CallumVoicePackage : ReactPackage {
|
|
24
|
+
|
|
25
|
+
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
|
26
|
+
return listOf(CallumVoiceModule(reactContext))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
|
30
|
+
return emptyList()
|
|
31
|
+
}
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudfort/callum-voice",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "Real-time voice chat SDK for React Native apps",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
@@ -46,3 +46,4 @@
|
|
|
46
46
|
"source": "src/index.ts"
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
|