@cloudfort/callum-voice 0.0.3
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 +672 -0
- package/android/build.gradle +38 -0
- package/android/src/main/AndroidManifest.xml +9 -0
- package/android/src/main/java/com/callumvoice/CallumVoiceModule.kt +289 -0
- package/android/src/main/java/com/callumvoice/CallumVoicePackage.kt +32 -0
- package/callum-voice.podspec +21 -0
- package/ios/CallumVoiceBridge.m +7 -0
- package/ios/CallumVoiceModule-Bridging-Header.h +7 -0
- package/ios/CallumVoiceModule.swift +198 -0
- package/lib/VoiceClient.d.ts +104 -0
- package/lib/VoiceClient.d.ts.map +1 -0
- package/lib/VoiceClient.js +493 -0
- package/lib/VoiceClient.js.map +1 -0
- package/lib/VoiceClientState.d.ts +16 -0
- package/lib/VoiceClientState.d.ts.map +1 -0
- package/lib/VoiceClientState.js +21 -0
- package/lib/VoiceClientState.js.map +1 -0
- package/lib/VoiceConfig.d.ts +32 -0
- package/lib/VoiceConfig.d.ts.map +1 -0
- package/lib/VoiceConfig.js +11 -0
- package/lib/VoiceConfig.js.map +1 -0
- package/lib/VoiceEventListener.d.ts +49 -0
- package/lib/VoiceEventListener.d.ts.map +1 -0
- package/lib/VoiceEventListener.js +4 -0
- package/lib/VoiceEventListener.js.map +1 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +11 -0
- package/lib/index.js.map +1 -0
- package/package.json +48 -0
- package/src/VoiceClient.ts +568 -0
- package/src/VoiceClientState.ts +21 -0
- package/src/VoiceConfig.ts +58 -0
- package/src/VoiceEventListener.ts +66 -0
- package/src/index.ts +6 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
2
|
+
package="com.callumvoice">
|
|
3
|
+
|
|
4
|
+
<uses-permission android:name="android.permission.INTERNET" />
|
|
5
|
+
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
6
|
+
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
|
7
|
+
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
8
|
+
|
|
9
|
+
</manifest>
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// Copyright Cloudfort. All Rights Reserved.
|
|
2
|
+
|
|
3
|
+
package com.callumvoice
|
|
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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Copyright Cloudfort. All Rights Reserved.
|
|
2
|
+
|
|
3
|
+
package com.callumvoice
|
|
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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require_relative '../node_modules/react-native/scripts/react_native_pods'
|
|
2
|
+
require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
|
|
3
|
+
|
|
4
|
+
platform :ios, '13.0'
|
|
5
|
+
|
|
6
|
+
Pod::Spec.new do |s|
|
|
7
|
+
s.name = "callum-voice"
|
|
8
|
+
s.version = "1.0.0"
|
|
9
|
+
s.summary = "Real-time voice chat SDK for React Native"
|
|
10
|
+
s.homepage = "https://cloudfort.ir"
|
|
11
|
+
s.license = "MIT"
|
|
12
|
+
s.author = "Cloudfort"
|
|
13
|
+
s.source = { :git => "https://github.com/cloudfort/callum-voice.git", :tag => s.version.to_s }
|
|
14
|
+
s.platforms = { :ios => "13.0" }
|
|
15
|
+
|
|
16
|
+
s.source_files = "ios/**/*.{h,m,swift}"
|
|
17
|
+
|
|
18
|
+
s.dependency "React-Core"
|
|
19
|
+
|
|
20
|
+
s.frameworks = "AVFoundation", "AudioToolbox"
|
|
21
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Copyright Cloudfort. All Rights Reserved.
|
|
2
|
+
|
|
3
|
+
#import "CallumVoiceModule-Bridging-Header.h"
|
|
4
|
+
|
|
5
|
+
// This file is needed for React Native to discover the native module.
|
|
6
|
+
// The actual implementation is in CallumVoiceModule.swift.
|
|
7
|
+
// The bridging header exposes the Swift class to Objective-C.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Copyright Cloudfort. All Rights Reserved.
|
|
2
|
+
|
|
3
|
+
import Foundation
|
|
4
|
+
import AVFoundation
|
|
5
|
+
import React
|
|
6
|
+
|
|
7
|
+
/// React Native native module for iOS audio capture and playback.
|
|
8
|
+
///
|
|
9
|
+
/// Uses AVAudioEngine for microphone capture and audio playback.
|
|
10
|
+
/// Communicates audio data to/from the JavaScript layer via RCTEventEmitter.
|
|
11
|
+
@objc(CallumVoiceModule)
|
|
12
|
+
class CallumVoiceModule: RCTEventEmitter {
|
|
13
|
+
|
|
14
|
+
private let sampleRate: Double = 48000
|
|
15
|
+
private let frameSize: AVAudioFrameCount = 960 // 20ms at 48kHz
|
|
16
|
+
|
|
17
|
+
private var audioEngine: AVAudioEngine?
|
|
18
|
+
private var outputEngine: AVAudioEngine?
|
|
19
|
+
private var captureRunning = false
|
|
20
|
+
private var playbackRunning = false
|
|
21
|
+
|
|
22
|
+
override static func moduleName() -> String! {
|
|
23
|
+
return "CallumVoice"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
override static func requiresMainQueueSetup() -> Bool {
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override func supportedEvents() -> [String]! {
|
|
31
|
+
return ["onAudioCaptured", "onAudioError"]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
override func constantsToExport() -> [AnyHashable : Any]! {
|
|
35
|
+
return [
|
|
36
|
+
"SAMPLE_RATE": Int(sampleRate),
|
|
37
|
+
"FRAME_SIZE": Int(frameSize)
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// MARK: - Audio Capture
|
|
42
|
+
|
|
43
|
+
@objc
|
|
44
|
+
func startAudioCapture(_ resolve: @escaping RCTPromiseResolveBlock,
|
|
45
|
+
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
|
46
|
+
do {
|
|
47
|
+
let session = AVAudioSession.sharedInstance()
|
|
48
|
+
try session.setCategory(.playAndRecord,
|
|
49
|
+
mode: .voiceChat,
|
|
50
|
+
options: [.defaultToSpeaker, .allowBluetooth])
|
|
51
|
+
try session.setActive(true)
|
|
52
|
+
try session.setPreferredSampleRate(sampleRate)
|
|
53
|
+
|
|
54
|
+
let engine = AVAudioEngine()
|
|
55
|
+
let inputNode = engine.inputNode
|
|
56
|
+
let inputFormat = inputNode.outputFormat(forBus: 0)
|
|
57
|
+
|
|
58
|
+
// Install tap to capture PCM audio
|
|
59
|
+
inputNode.installTap(onBus: 0, bufferSize: frameSize, format: inputFormat) {
|
|
60
|
+
[weak self] buffer, time in
|
|
61
|
+
guard let self = self, self.captureRunning else { return }
|
|
62
|
+
|
|
63
|
+
// Convert to Int16 array and send to JS
|
|
64
|
+
let channelData = buffer.int16ChannelData![0]
|
|
65
|
+
let frameLength = Int(buffer.frameLength)
|
|
66
|
+
var samples = [Int16](repeating: 0, count: frameLength)
|
|
67
|
+
for i in 0..<frameLength {
|
|
68
|
+
samples[i] = channelData[i]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
self.sendEvent(withName: "onAudioCaptured", body: samples)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
engine.prepare()
|
|
75
|
+
try engine.start()
|
|
76
|
+
|
|
77
|
+
audioEngine = engine
|
|
78
|
+
captureRunning = true
|
|
79
|
+
|
|
80
|
+
resolve(nil)
|
|
81
|
+
} catch {
|
|
82
|
+
reject("AUDIO_ERROR", "Failed to start audio capture: \(error.localizedDescription)", error)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@objc
|
|
87
|
+
func stopAudioCapture(_ resolve: @escaping RCTPromiseResolveBlock,
|
|
88
|
+
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
|
89
|
+
captureRunning = false
|
|
90
|
+
audioEngine?.inputNode.removeTap(onBus: 0)
|
|
91
|
+
audioEngine?.stop()
|
|
92
|
+
audioEngine = nil
|
|
93
|
+
resolve(nil)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// MARK: - Audio Playback
|
|
97
|
+
|
|
98
|
+
@objc
|
|
99
|
+
func startAudioPlayback(_ resolve: @escaping RCTPromiseResolveBlock,
|
|
100
|
+
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
|
101
|
+
do {
|
|
102
|
+
let session = AVAudioSession.sharedInstance()
|
|
103
|
+
try session.setCategory(.playAndRecord,
|
|
104
|
+
mode: .voiceChat,
|
|
105
|
+
options: [.defaultToSpeaker, .allowBluetooth])
|
|
106
|
+
try session.setActive(true)
|
|
107
|
+
|
|
108
|
+
let engine = AVAudioEngine()
|
|
109
|
+
let outputNode = engine.outputNode
|
|
110
|
+
let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate,
|
|
111
|
+
channels: 1)
|
|
112
|
+
|
|
113
|
+
engine.prepare()
|
|
114
|
+
try engine.start()
|
|
115
|
+
|
|
116
|
+
outputEngine = engine
|
|
117
|
+
playbackRunning = true
|
|
118
|
+
|
|
119
|
+
resolve(nil)
|
|
120
|
+
} catch {
|
|
121
|
+
reject("AUDIO_ERROR", "Failed to start audio playback: \(error.localizedDescription)", error)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@objc
|
|
126
|
+
func stopAudioPlayback(_ resolve: @escaping RCTPromiseResolveBlock,
|
|
127
|
+
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
|
128
|
+
playbackRunning = false
|
|
129
|
+
outputEngine?.stop()
|
|
130
|
+
outputEngine = nil
|
|
131
|
+
resolve(nil)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Enqueue PCM audio samples for a specific peer.
|
|
135
|
+
@objc
|
|
136
|
+
func enqueuePeerAudio(_ peerId: String, samples: [Int16]) {
|
|
137
|
+
guard playbackRunning, let engine = outputEngine else { return }
|
|
138
|
+
|
|
139
|
+
let frameCount = AVAudioFrameCount(samples.count)
|
|
140
|
+
guard let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1),
|
|
141
|
+
let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
buffer.frameLength = frameCount
|
|
146
|
+
let channelData = buffer.int16ChannelData![0]
|
|
147
|
+
for i in 0..<samples.count {
|
|
148
|
+
channelData[i] = samples[i]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
engine.outputNode.volume = 1.0
|
|
152
|
+
engine.outputNode.scheduleBuffer(buffer, at: nil, options: .interrupts)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// MARK: - Speaker Routing
|
|
156
|
+
|
|
157
|
+
@objc
|
|
158
|
+
func setSpeakerphoneOn(_ on: Bool,
|
|
159
|
+
resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
160
|
+
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
|
161
|
+
do {
|
|
162
|
+
let session = AVAudioSession.sharedInstance()
|
|
163
|
+
if on {
|
|
164
|
+
try session.overrideOutputAudioPort(.speaker)
|
|
165
|
+
} else {
|
|
166
|
+
try session.overrideOutputAudioPort(.none)
|
|
167
|
+
}
|
|
168
|
+
resolve(nil)
|
|
169
|
+
} catch {
|
|
170
|
+
reject("AUDIO_ERROR", "Failed to set speaker: \(error.localizedDescription)", error)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// MARK: - Audio Session
|
|
175
|
+
|
|
176
|
+
@objc
|
|
177
|
+
func configureAudioSession(_ resolve: @escaping RCTPromiseResolveBlock,
|
|
178
|
+
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
|
179
|
+
do {
|
|
180
|
+
let session = AVAudioSession.sharedInstance()
|
|
181
|
+
try session.setCategory(.playAndRecord,
|
|
182
|
+
mode: .voiceChat,
|
|
183
|
+
options: [.defaultToSpeaker, .allowBluetooth])
|
|
184
|
+
try session.setActive(true)
|
|
185
|
+
resolve(nil)
|
|
186
|
+
} catch {
|
|
187
|
+
reject("AUDIO_ERROR", "Failed to configure audio session: \(error.localizedDescription)", error)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
deinit {
|
|
192
|
+
captureRunning = false
|
|
193
|
+
playbackRunning = false
|
|
194
|
+
audioEngine?.inputNode.removeTap(onBus: 0)
|
|
195
|
+
audioEngine?.stop()
|
|
196
|
+
outputEngine?.stop()
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { VoiceClientState } from './VoiceClientState';
|
|
2
|
+
import { VoiceConfig } from './VoiceConfig';
|
|
3
|
+
import { VoiceEventListener } from './VoiceEventListener';
|
|
4
|
+
/**
|
|
5
|
+
* Cross-platform voice-chat client for React Native.
|
|
6
|
+
*
|
|
7
|
+
* Connects to the Go voice service over WebSocket, captures microphone audio
|
|
8
|
+
* via a native audio engine, and plays back remote peer audio.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* const client = new VoiceClient(createVoiceConfig({
|
|
13
|
+
* server: 'callem.cloudfort.ir',
|
|
14
|
+
* apiKey: 'vc_live_...',
|
|
15
|
+
* peerId: 'user_123',
|
|
16
|
+
* }));
|
|
17
|
+
*
|
|
18
|
+
* client.setListener({
|
|
19
|
+
* onConnected: () => console.log('Connected!'),
|
|
20
|
+
* onPeerSpeaking: (id) => console.log(`${id} is speaking`),
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* await client.connect();
|
|
24
|
+
* await client.joinRoom('game_room_1');
|
|
25
|
+
* await client.enableMic();
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare class VoiceClient {
|
|
29
|
+
private readonly config;
|
|
30
|
+
private listener;
|
|
31
|
+
private ws;
|
|
32
|
+
private _state;
|
|
33
|
+
private currentRoom;
|
|
34
|
+
private micEnabled;
|
|
35
|
+
private speakerEnabled;
|
|
36
|
+
private reconnectAttempts;
|
|
37
|
+
private prevRoom;
|
|
38
|
+
private prevMicEnabled;
|
|
39
|
+
private peers;
|
|
40
|
+
private speakingTimer;
|
|
41
|
+
private reconnectTimer;
|
|
42
|
+
constructor(config: VoiceConfig);
|
|
43
|
+
/** Current connection state */
|
|
44
|
+
get state(): VoiceClientState;
|
|
45
|
+
/** Whether connected to server */
|
|
46
|
+
get isConnected(): boolean;
|
|
47
|
+
/** Whether joined a room */
|
|
48
|
+
get isInRoom(): boolean;
|
|
49
|
+
/** Whether microphone is active */
|
|
50
|
+
get isMicEnabled(): boolean;
|
|
51
|
+
/** Whether speaker output is active */
|
|
52
|
+
get isSpeakerEnabled(): boolean;
|
|
53
|
+
/** Current room ID (null if not in room) */
|
|
54
|
+
get currentRoomId(): string | null;
|
|
55
|
+
/** List of peer IDs in the current room */
|
|
56
|
+
get peerIds(): string[];
|
|
57
|
+
/** Set the event listener */
|
|
58
|
+
setListener(listener: VoiceEventListener | null): void;
|
|
59
|
+
/** Connect to the voice server */
|
|
60
|
+
connect(): Promise<void>;
|
|
61
|
+
/** Disconnect from the server */
|
|
62
|
+
disconnect(): void;
|
|
63
|
+
/** Join a voice room */
|
|
64
|
+
joinRoom(roomId: string): Promise<void>;
|
|
65
|
+
/** Leave the current room */
|
|
66
|
+
leaveRoom(): Promise<void>;
|
|
67
|
+
/** Enable microphone (must be in a room) */
|
|
68
|
+
enableMic(): Promise<void>;
|
|
69
|
+
/** Disable microphone */
|
|
70
|
+
disableMic(): void;
|
|
71
|
+
/** Toggle microphone, returns new state */
|
|
72
|
+
toggleMic(): Promise<boolean>;
|
|
73
|
+
/** Enable speaker output */
|
|
74
|
+
enableSpeaker(): void;
|
|
75
|
+
/** Disable speaker output */
|
|
76
|
+
disableSpeaker(): void;
|
|
77
|
+
/** Toggle speaker, returns new state */
|
|
78
|
+
toggleSpeaker(): boolean;
|
|
79
|
+
/** Check if a specific peer is speaking */
|
|
80
|
+
isPeerSpeaking(peerId: string): boolean;
|
|
81
|
+
/** Get peer speaking state with RMS level */
|
|
82
|
+
getPeerSpeakingInfo(peerId: string): {
|
|
83
|
+
speaking: boolean;
|
|
84
|
+
rms: number;
|
|
85
|
+
};
|
|
86
|
+
/** Dispose and release all resources */
|
|
87
|
+
dispose(): void;
|
|
88
|
+
private handleWebSocketConnected;
|
|
89
|
+
private handleTextMessage;
|
|
90
|
+
private handleBinaryMessage;
|
|
91
|
+
private handlePeerAudio;
|
|
92
|
+
private checkSpeaking;
|
|
93
|
+
private handleClose;
|
|
94
|
+
private handleDisconnect;
|
|
95
|
+
private sendText;
|
|
96
|
+
/** Send captured audio to server (called from native audio engine) */
|
|
97
|
+
sendAudio(samples: Int16Array): void;
|
|
98
|
+
private cleanup;
|
|
99
|
+
private stopMic;
|
|
100
|
+
private resolveScheme;
|
|
101
|
+
private setState;
|
|
102
|
+
private raiseError;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=VoiceClient.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"VoiceClient.d.ts","sourceRoot":"","sources":["../src/VoiceClient.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AA+D1D;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;IACrC,OAAO,CAAC,QAAQ,CAAmC;IAEnD,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,MAAM,CAAmD;IACjE,OAAO,CAAC,WAAW,CAAuB;IAC1C,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,QAAQ,CAAuB;IACvC,OAAO,CAAC,cAAc,CAAS;IAE/B,OAAO,CAAC,KAAK,CAAqC;IAClD,OAAO,CAAC,aAAa,CAA+C;IACpE,OAAO,CAAC,cAAc,CAA8C;gBAExD,MAAM,EAAE,WAAW;IAM/B,+BAA+B;IAC/B,IAAI,KAAK,IAAI,gBAAgB,CAE5B;IAED,kCAAkC;IAClC,IAAI,WAAW,IAAI,OAAO,CAEzB;IAED,4BAA4B;IAC5B,IAAI,QAAQ,IAAI,OAAO,CAEtB;IAED,mCAAmC;IACnC,IAAI,YAAY,IAAI,OAAO,CAE1B;IAED,uCAAuC;IACvC,IAAI,gBAAgB,IAAI,OAAO,CAE9B;IAED,4CAA4C;IAC5C,IAAI,aAAa,IAAI,MAAM,GAAG,IAAI,CAEjC;IAED,2CAA2C;IAC3C,IAAI,OAAO,IAAI,MAAM,EAAE,CAEtB;IAID,6BAA6B;IAC7B,WAAW,CAAC,QAAQ,EAAE,kBAAkB,GAAG,IAAI,GAAG,IAAI;IAMtD,kCAAkC;IAC5B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IA2C9B,iCAAiC;IACjC,UAAU,IAAI,IAAI;IASlB,wBAAwB;IAClB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAgC7C,6BAA6B;IACvB,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IA2BhC,4CAA4C;IACtC,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IAchC,yBAAyB;IACzB,UAAU,IAAI,IAAI;IAMlB,2CAA2C;IACrC,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC;IAWnC,4BAA4B;IAC5B,aAAa,IAAI,IAAI;IAOrB,6BAA6B;IAC7B,cAAc,IAAI,IAAI;IAOtB,wCAAwC;IACxC,aAAa,IAAI,OAAO;IAWxB,2CAA2C;IAC3C,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO;IAIvC,6CAA6C;IAC7C,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE;IAQvE,wCAAwC;IACxC,OAAO,IAAI,IAAI;IAOf,OAAO,CAAC,wBAAwB;IAsBhC,OAAO,CAAC,iBAAiB;IAiBzB,OAAO,CAAC,mBAAmB;IA6B3B,OAAO,CAAC,eAAe;IAYvB,OAAO,CAAC,aAAa;IAqBrB,OAAO,CAAC,WAAW;IAQnB,OAAO,CAAC,gBAAgB;IA8BxB,OAAO,CAAC,QAAQ;IAMhB,sEAAsE;IACtE,SAAS,CAAC,OAAO,EAAE,UAAU,GAAG,IAAI;IASpC,OAAO,CAAC,OAAO;IAgCf,OAAO,CAAC,OAAO;IAQf,OAAO,CAAC,aAAa;IAMrB,OAAO,CAAC,QAAQ;IAMhB,OAAO,CAAC,UAAU;CAGnB"}
|