@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 ADDED
@@ -0,0 +1,672 @@
1
+ # Callum.Voice React Native SDK — Real-Time Voice Chat Library
2
+
3
+ A **React Native plugin** for cross-platform real-time voice communication over a Go server (`phonil-opus`).
4
+ Works on **Android** and **iOS**.
5
+ All **microphone capture**, **audio playback**, **WebSocket connection**, **multi-peer audio mixing**, **speaking detection**, and **room management** logic is implemented in this plugin.
6
+
7
+ ---
8
+
9
+ ## Table of Contents
10
+
11
+ - [Architecture](#architecture)
12
+ - [File Structure](#file-structure)
13
+ - [Communication Protocol](#communication-protocol)
14
+ - [Installation](#installation)
15
+ - [Quick Start](#quick-start)
16
+ - [Complete Example](#complete-example)
17
+ - [API Reference](#api-reference)
18
+ - [VoiceConfig](#voiceconfig)
19
+ - [VoiceEventListener](#voiceeventlistener)
20
+ - [Native Modules](#native-modules)
21
+ - [Permissions](#permissions)
22
+ - [Important Notes](#important-notes)
23
+
24
+ ---
25
+
26
+ ## Architecture
27
+
28
+ ```
29
+ ┌──────────────────────────────────────────────────────────────┐
30
+ │ Callum.Voice React Native SDK │
31
+ │ │
32
+ │ ┌─────────────────────┐ ┌──────────────────────────┐ │
33
+ │ │ VoiceClient (TS) │────▶│ Native Modules │ │
34
+ │ │ │◀────│ │ │
35
+ │ │ • WebSocket (native) │ │ Android: AudioRecord/ │ │
36
+ │ │ • Room Management │ │ AudioTrack │ │
37
+ │ │ • Peer Tracking │ │ iOS: AVAudioEngine │ │
38
+ │ │ • Speaking Detection │ └──────────────────────────┘ │
39
+ │ │ • Auto Reconnect │ ▲ │
40
+ │ └──────────┬───────────┘ │ │
41
+ │ │ WebSocket (PCM 16-bit mono) │ │
42
+ │ ▼ │ │
43
+ │ ┌─────────────────────┐ │ │
44
+ │ │ Go Server │── PCM Audio ─────┘ │
45
+ │ │ (phonil-opus) │ │
46
+ │ └─────────────────────┘ │
47
+ └──────────────────────────────────────────────────────────────┘
48
+ ```
49
+
50
+ ### Data Flow
51
+
52
+ ```
53
+ Microphone ──▶ NativeModule ──(Int16[])──▶ JS VoiceClient ──(WebSocket)──▶ Server
54
+ Speaker ◀── NativeModule ◀──(Int16[])──◀ JS VoiceClient ◀──(WebSocket)──◀ Server
55
+ ```
56
+
57
+ ---
58
+
59
+ ## File Structure
60
+
61
+ ```
62
+ callum-react-native/
63
+ ├── src/
64
+ │ ├── index.ts # Library exports
65
+ │ ├── VoiceClientState.ts # Connection state enum
66
+ │ ├── VoiceConfig.ts # Configuration interface
67
+ │ ├── VoiceEventListener.ts # Event callback interface
68
+ │ └── VoiceClient.ts # Core client logic
69
+ ├── android/
70
+ │ ├── build.gradle # Android build config
71
+ │ └── src/main/
72
+ │ ├── AndroidManifest.xml # Permissions
73
+ │ └── java/com/callumvoice/
74
+ │ ├── CallumVoiceModule.kt # Native audio module
75
+ │ └── CallumVoicePackage.kt # Package registration
76
+ ├── ios/
77
+ │ ├── CallumVoiceModule.swift # Native audio module
78
+ │ ├── CallumVoiceModule-Bridging-Header.h # ObjC bridge
79
+ │ └── CallumVoiceBridge.m # Bridge implementation
80
+ ├── callum-voice.podspec # iOS CocoaPods spec
81
+ ├── package.json # npm package config
82
+ ├── tsconfig.json # TypeScript config
83
+ └── README.md # This file
84
+ ```
85
+
86
+ ---
87
+
88
+ ## Communication Protocol
89
+
90
+ ### WebSocket Connection
91
+ ```
92
+ ws(s)://{server}/ws?room=__lobby__&peer={peerId}&api_key={apiKey}
93
+ ```
94
+
95
+ ### Binary Packet Format
96
+ ```
97
+ ┌──────────┬──────────┬──────────┬──────────┬──────────────┐
98
+ │ RoomLen │ RoomID │ PeerLen │ PeerID │ PCM Audio │
99
+ │ (1 byte) │ (N bytes)│ (1 byte) │ (N bytes)│ (variable) │
100
+ └──────────┴──────────┴──────────┴──────────┴──────────────┘
101
+ ```
102
+
103
+ ### Audio Format
104
+ - **PCM 16-bit signed integer**, mono, 48000 Hz
105
+ - ~960 samples per 20ms frame (~768 kbps)
106
+
107
+ ### JSON Control Messages
108
+ ```json
109
+ // Join room
110
+ {"type": "join", "room": "room_id", "peer": "peer_id", "sampleRate": 48000}
111
+
112
+ // UDP token (server → client)
113
+ {"type": "udp_token", "token": "..."}
114
+ ```
115
+
116
+ ---
117
+
118
+ ## Installation
119
+
120
+ ### 1. Install Package
121
+
122
+ ```bash
123
+ npm install callum-voice
124
+ # or
125
+ yarn add callum-voice
126
+ ```
127
+
128
+ ### 2. iOS — Install Pods
129
+
130
+ ```bash
131
+ cd ios && pod install && cd ..
132
+ ```
133
+
134
+ ### 3. Android — Register Package
135
+
136
+ In `MainApplication.kt`:
137
+
138
+ ```kotlin
139
+ import com.callumvoice.CallumVoicePackage
140
+
141
+ override fun getPackages(): List<ReactPackage> {
142
+ return listOf(
143
+ MainReactPackage(),
144
+ CallumVoicePackage() // ← Add this
145
+ )
146
+ }
147
+ ```
148
+
149
+ ### 4. Platform Permissions
150
+
151
+ #### Android (`android/app/src/main/AndroidManifest.xml`)
152
+
153
+ ```xml
154
+ <uses-permission android:name="android.permission.INTERNET" />
155
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
156
+ <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
157
+ ```
158
+
159
+ #### iOS (`ios/Runner/Info.plist`)
160
+
161
+ ```xml
162
+ <key>NSMicrophoneUsageDescription</key>
163
+ <string>This app needs microphone access for voice chat</string>
164
+ ```
165
+
166
+ ---
167
+
168
+ ## Quick Start
169
+
170
+ ```typescript
171
+ import { VoiceClient, createVoiceConfig } from 'callum-voice';
172
+
173
+ // 1. Create config
174
+ const config = createVoiceConfig({
175
+ server: 'callem.cloudfort.ir',
176
+ apiKey: 'vc_live_0123456789abcdef0123456789abcdef',
177
+ peerId: 'user_123',
178
+ });
179
+
180
+ // 2. Create client
181
+ const client = new VoiceClient(config);
182
+
183
+ // 3. Set listener
184
+ client.setListener({
185
+ onConnected: () => console.log('Connected!'),
186
+ onPeerSpeaking: (peerId) => console.log(`${peerId} is speaking`),
187
+ });
188
+
189
+ // 4. Connect
190
+ await client.connect();
191
+
192
+ // 5. Join a room
193
+ await client.joinRoom('game_room_1');
194
+
195
+ // 6. Enable microphone
196
+ await client.enableMic();
197
+ ```
198
+
199
+ ---
200
+
201
+ ## Complete Example
202
+
203
+ ```tsx
204
+ import React, { useEffect, useState, useRef, useCallback } from 'react';
205
+ import {
206
+ View,
207
+ Text,
208
+ FlatList,
209
+ TouchableOpacity,
210
+ StyleSheet,
211
+ Alert,
212
+ } from 'react-native';
213
+ import {
214
+ VoiceClient,
215
+ VoiceClientState,
216
+ VoiceEventListener,
217
+ createVoiceConfig,
218
+ } from 'callum-voice';
219
+
220
+ const STATE_LABELS: Record<VoiceClientState, string> = {
221
+ [VoiceClientState.Disconnected]: 'Disconnected',
222
+ [VoiceClientState.Connecting]: 'Connecting...',
223
+ [VoiceClientState.Connected]: 'Connected',
224
+ [VoiceClientState.InRoom]: 'In Room',
225
+ [VoiceClientState.Reconnecting]: 'Reconnecting...',
226
+ };
227
+
228
+ export default function VoiceChatScreen() {
229
+ const clientRef = useRef<VoiceClient | null>(null);
230
+ const [state, setState] = useState<VoiceClientState>(VoiceClientState.Disconnected);
231
+ const [peers, setPeers] = useState<string[]>([]);
232
+ const [speakingPeers, setSpeakingPeers] = useState<Set<string>>(new Set());
233
+ const [micEnabled, setMicEnabled] = useState(false);
234
+ const [speakerEnabled, setSpeakerEnabled] = useState(false);
235
+
236
+ useEffect(() => {
237
+ const config = createVoiceConfig({
238
+ server: 'callem.cloudfort.ir',
239
+ apiKey: 'vc_live_0123456789abcdef0123456789abcdef',
240
+ peerId: `user_${Date.now()}`,
241
+ });
242
+
243
+ const client = new VoiceClient(config);
244
+ clientRef.current = client;
245
+
246
+ const listener: VoiceEventListener = {
247
+ onConnected: () => console.log('Connected'),
248
+ onDisconnected: () => {
249
+ setPeers([]);
250
+ setSpeakingPeers(new Set());
251
+ },
252
+ onReconnecting: (attempt) => console.log(`Reconnecting #${attempt}`),
253
+ onAuthFailed: (reason) => Alert.alert('Auth Failed', reason),
254
+ onRoomJoined: (roomId) => console.log(`Joined ${roomId}`),
255
+ onRoomLeft: () => {
256
+ setPeers([]);
257
+ setSpeakingPeers(new Set());
258
+ },
259
+ onPeerJoined: (peerId) => setPeers(prev => [...prev, peerId]),
260
+ onPeerLeft: (peerId) => {
261
+ setPeers(prev => prev.filter(p => p !== peerId));
262
+ setSpeakingPeers(prev => {
263
+ const next = new Set(prev);
264
+ next.delete(peerId);
265
+ return next;
266
+ });
267
+ },
268
+ onPeerSpeaking: (peerId) =>
269
+ setSpeakingPeers(prev => new Set(prev).add(peerId)),
270
+ onPeerStopped: (peerId) =>
271
+ setSpeakingPeers(prev => {
272
+ const next = new Set(prev);
273
+ next.delete(peerId);
274
+ return next;
275
+ }),
276
+ onMicEnabled: () => setMicEnabled(true),
277
+ onMicDisabled: () => setMicEnabled(false),
278
+ onSpeakerEnabled: () => setSpeakerEnabled(true),
279
+ onSpeakerDisabled: () => setSpeakerEnabled(false),
280
+ onError: (error) => Alert.alert('Voice Error', error.message),
281
+ onStateChanged: (newState) => setState(newState),
282
+ };
283
+
284
+ client.setListener(listener);
285
+ client.connect().then(() => client.joinRoom('game_room_1'));
286
+
287
+ return () => {
288
+ client.dispose();
289
+ clientRef.current = null;
290
+ };
291
+ }, []);
292
+
293
+ const handleToggleMic = useCallback(async () => {
294
+ const client = clientRef.current;
295
+ if (!client) return;
296
+ if (micEnabled) {
297
+ client.disableMic();
298
+ } else {
299
+ await client.enableMic();
300
+ }
301
+ }, [micEnabled]);
302
+
303
+ const handleToggleSpeaker = useCallback(() => {
304
+ const client = clientRef.current;
305
+ if (!client) return;
306
+ if (speakerEnabled) {
307
+ client.disableSpeaker();
308
+ } else {
309
+ client.enableSpeaker();
310
+ }
311
+ }, [speakerEnabled]);
312
+
313
+ return (
314
+ <View style={styles.container}>
315
+ {/* Status Bar */}
316
+ <View style={styles.statusBar}>
317
+ <Text style={styles.statusText}>{STATE_LABELS[state]}</Text>
318
+ </View>
319
+
320
+ {/* Controls */}
321
+ <View style={styles.controls}>
322
+ <TouchableOpacity
323
+ style={[styles.button, micEnabled && styles.buttonActive]}
324
+ onPress={handleToggleMic}
325
+ >
326
+ <Text style={styles.buttonText}>
327
+ {micEnabled ? '🎤 Mute' : '🎤 Unmute'}
328
+ </Text>
329
+ </TouchableOpacity>
330
+
331
+ <TouchableOpacity
332
+ style={[styles.button, speakerEnabled && styles.buttonActive]}
333
+ onPress={handleToggleSpeaker}
334
+ >
335
+ <Text style={styles.buttonText}>
336
+ {speakerEnabled ? '🔊 Speaker Off' : '🔇 Speaker On'}
337
+ </Text>
338
+ </TouchableOpacity>
339
+ </View>
340
+
341
+ {/* Peers List */}
342
+ <FlatList
343
+ data={peers}
344
+ keyExtractor={(item) => item}
345
+ renderItem={({ item }) => {
346
+ const isSpeaking = speakingPeers.has(item);
347
+ return (
348
+ <View style={styles.peerRow}>
349
+ <Text style={styles.peerIcon}>{isSpeaking ? '🟢' : '⚪'}</Text>
350
+ <View style={styles.peerInfo}>
351
+ <Text style={styles.peerName}>{item}</Text>
352
+ <Text style={styles.peerStatus}>
353
+ {isSpeaking ? 'Speaking...' : 'Silent'}
354
+ </Text>
355
+ </View>
356
+ </View>
357
+ );
358
+ }}
359
+ ListEmptyComponent={
360
+ <Text style={styles.emptyText}>No peers in room</Text>
361
+ }
362
+ />
363
+ </View>
364
+ );
365
+ }
366
+
367
+ const styles = StyleSheet.create({
368
+ container: { flex: 1, backgroundColor: '#1a1a2e' },
369
+ statusBar: {
370
+ padding: 16,
371
+ backgroundColor: '#16213e',
372
+ alignItems: 'center',
373
+ },
374
+ statusText: { color: '#e94560', fontSize: 18, fontWeight: 'bold' },
375
+ controls: {
376
+ flexDirection: 'row',
377
+ justifyContent: 'center',
378
+ padding: 16,
379
+ gap: 16,
380
+ },
381
+ button: {
382
+ paddingHorizontal: 24,
383
+ paddingVertical: 12,
384
+ backgroundColor: '#0f3460',
385
+ borderRadius: 8,
386
+ },
387
+ buttonActive: { backgroundColor: '#e94560' },
388
+ buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
389
+ peerRow: {
390
+ flexDirection: 'row',
391
+ alignItems: 'center',
392
+ paddingHorizontal: 16,
393
+ paddingVertical: 12,
394
+ borderBottomWidth: 1,
395
+ borderBottomColor: '#16213e',
396
+ },
397
+ peerIcon: { fontSize: 24, marginRight: 12 },
398
+ peerInfo: { flex: 1 },
399
+ peerName: { color: '#fff', fontSize: 16, fontWeight: '500' },
400
+ peerStatus: { color: '#888', fontSize: 13 },
401
+ emptyText: { color: '#888', textAlign: 'center', marginTop: 40, fontSize: 16 },
402
+ });
403
+ ```
404
+
405
+ ---
406
+
407
+ ## API Reference
408
+
409
+ ### VoiceClient
410
+
411
+ The main class for managing voice connections.
412
+
413
+ #### Constructor
414
+
415
+ ```typescript
416
+ new VoiceClient(config: VoiceConfig)
417
+ ```
418
+
419
+ | Parameter | Type | Description |
420
+ |-----------|------|-------------|
421
+ | `config` | `VoiceConfig` | Connection and audio configuration |
422
+
423
+ #### Properties
424
+
425
+ | Property | Type | Description |
426
+ |----------|------|-------------|
427
+ | `state` | `VoiceClientState` | Current connection state (read-only) |
428
+ | `isConnected` | `boolean` | Whether connected to server |
429
+ | `isInRoom` | `boolean` | Whether joined a room |
430
+ | `isMicEnabled` | `boolean` | Whether microphone is active |
431
+ | `isSpeakerEnabled` | `boolean` | Whether speaker output is active |
432
+ | `currentRoomId` | `string \| null` | Current room ID |
433
+ | `peerIds` | `string[]` | List of peer IDs in the current room |
434
+
435
+ #### Methods
436
+
437
+ | Method | Returns | Description |
438
+ |--------|---------|-------------|
439
+ | `setListener(listener)` | `void` | Set the event listener |
440
+ | `connect()` | `Promise<void>` | Connect to the voice server |
441
+ | `disconnect()` | `void` | Disconnect from the server |
442
+ | `joinRoom(roomId)` | `Promise<void>` | Join a voice room |
443
+ | `leaveRoom()` | `Promise<void>` | Leave the current room |
444
+ | `enableMic()` | `Promise<void>` | Enable microphone (must be in room) |
445
+ | `disableMic()` | `void` | Disable microphone |
446
+ | `toggleMic()` | `Promise<boolean>` | Toggle mic, returns new state |
447
+ | `enableSpeaker()` | `void` | Enable speaker output |
448
+ | `disableSpeaker()` | `void` | Disable speaker output |
449
+ | `toggleSpeaker()` | `boolean` | Toggle speaker, returns new state |
450
+ | `isPeerSpeaking(peerId)` | `boolean` | Check if a specific peer is speaking |
451
+ | `getPeerSpeakingInfo(peerId)` | `{speaking, rms}` | Get peer speaking state with RMS level |
452
+ | `sendAudio(samples)` | `void` | Send captured audio to server (from native) |
453
+ | `dispose()` | `void` | Cleanup and release resources |
454
+
455
+ ---
456
+
457
+ ## VoiceConfig
458
+
459
+ Configuration for connecting to the voice chat service.
460
+
461
+ ```typescript
462
+ interface VoiceConfig {
463
+ server: string;
464
+ apiKey: string;
465
+ peerId: string;
466
+ useTls?: boolean;
467
+ sampleRate: number;
468
+ autoReconnect: boolean;
469
+ maxReconnectAttempts: number;
470
+ reconnectDelayMs: number;
471
+ echoCancellation: boolean;
472
+ noiseSuppression: boolean;
473
+ autoGainControl: boolean;
474
+ }
475
+ ```
476
+
477
+ ### createVoiceConfig Helper
478
+
479
+ ```typescript
480
+ function createVoiceConfig(overrides: Partial<VoiceConfig>): VoiceConfig
481
+ ```
482
+
483
+ Creates a `VoiceConfig` with sensible defaults, merged with your overrides.
484
+
485
+ | Property | Type | Default | Description |
486
+ |----------|------|---------|-------------|
487
+ | `server` | `string` | `''` | Server address (e.g. `"callem.cloudfort.ir"`). Do NOT include scheme or port. |
488
+ | `apiKey` | `string` | `''` | API key from account registration (format: `vc_live_...`) |
489
+ | `peerId` | `string` | `''` | Unique identifier for this peer/user |
490
+ | `useTls` | `boolean?` | `undefined` | Use WSS instead of WS. Auto-detected when undefined. |
491
+ | `sampleRate` | `number` | `48000` | Audio sample rate in Hz |
492
+ | `autoReconnect` | `boolean` | `true` | Automatically reconnect on disconnect |
493
+ | `maxReconnectAttempts` | `number` | `5` | Maximum reconnection attempts |
494
+ | `reconnectDelayMs` | `number` | `2000` | Delay between reconnection attempts (ms) |
495
+ | `echoCancellation` | `boolean` | `true` | Enable platform echo cancellation |
496
+ | `noiseSuppression` | `boolean` | `true` | Enable platform noise suppression |
497
+ | `autoGainControl` | `boolean` | `true` | Enable platform automatic gain control |
498
+
499
+ ---
500
+
501
+ ## VoiceEventListener
502
+
503
+ Interface for receiving voice client events. All methods are optional.
504
+
505
+ ```typescript
506
+ interface VoiceEventListener {
507
+ onConnected?(): void;
508
+ onDisconnected?(): void;
509
+ onReconnecting?(attempt: number): void;
510
+ onAuthFailed?(reason: string): void;
511
+ onRoomJoined?(roomId: string): void;
512
+ onRoomLeft?(roomId: string): void;
513
+ onPeerJoined?(peerId: string): void;
514
+ onPeerLeft?(peerId: string): void;
515
+ onPeerSpeaking?(peerId: string): void;
516
+ onPeerStopped?(peerId: string): void;
517
+ onMicEnabled?(): void;
518
+ onMicDisabled?(): void;
519
+ onSpeakerEnabled?(): void;
520
+ onSpeakerDisabled?(): void;
521
+ onError?(error: Error): void;
522
+ onStateChanged?(newState: VoiceClientState): void;
523
+ }
524
+ ```
525
+
526
+ ### Events
527
+
528
+ | Method | Parameters | Description |
529
+ |--------|-----------|-------------|
530
+ | `onConnected` | — | Connection established |
531
+ | `onDisconnected` | — | Connection lost |
532
+ | `onReconnecting` | `attempt` — current attempt number | Reconnection in progress |
533
+ | `onAuthFailed` | `reason` — failure description | Authentication failed |
534
+ | `onRoomJoined` | `roomId` — joined room ID | Successfully joined a room |
535
+ | `onRoomLeft` | `roomId` — left room ID | Left a room |
536
+ | `onPeerJoined` | `peerId` — new peer's ID | A peer joined the room |
537
+ | `onPeerLeft` | `peerId` — departing peer's ID | A peer left the room |
538
+ | `onPeerSpeaking` | `peerId` — speaking peer's ID | Peer started speaking |
539
+ | `onPeerStopped` | `peerId` — silent peer's ID | Peer stopped speaking |
540
+ | `onMicEnabled` | — | Microphone enabled |
541
+ | `onMicDisabled` | — | Microphone disabled |
542
+ | `onSpeakerEnabled` | — | Speaker output enabled |
543
+ | `onSpeakerDisabled` | — | Speaker output disabled |
544
+ | `onError` | `error` — Error object | An error occurred |
545
+ | `onStateChanged` | `newState` — VoiceClientState | Connection state changed |
546
+
547
+ ---
548
+
549
+ ## Native Modules
550
+
551
+ ### Android (Kotlin)
552
+
553
+ The Android native module at `android/src/main/java/com/callumvoice/CallumVoiceModule.kt` provides:
554
+
555
+ | Method | Description |
556
+ |--------|-------------|
557
+ | `startAudioCapture()` | Start microphone recording via `AudioRecord` |
558
+ | `stopAudioCapture()` | Stop microphone recording |
559
+ | `startAudioPlayback()` | Start audio playback via `AudioTrack` |
560
+ | `stopAudioPlayback()` | Stop audio playback |
561
+ | `enqueuePeerAudio(peerId, samples)` | Write PCM samples to AudioTrack |
562
+ | `setSpeakerphoneOn(on)` | Route audio to speaker/earpiece |
563
+ | `configureAudioSession()` | Set `MODE_IN_COMMUNICATION` |
564
+
565
+ **Events sent to JavaScript:**
566
+ - `onAudioCaptured` — Int16 array of captured PCM samples
567
+ - `onAudioError` — Error message string
568
+
569
+ ### iOS (Swift)
570
+
571
+ The iOS native module at `ios/CallumVoiceModule.swift` provides:
572
+
573
+ | Method | Description |
574
+ |--------|-------------|
575
+ | `startAudioCapture()` | Start microphone capture via `AVAudioEngine` |
576
+ | `stopAudioCapture()` | Stop microphone capture |
577
+ | `startAudioPlayback()` | Start audio playback via `AVAudioEngine` |
578
+ | `stopAudioPlayback()` | Stop audio playback |
579
+ | `enqueuePeerAudio(peerId, samples)` | Schedule PCM buffer for playback |
580
+ | `setSpeakerphoneOn(on)` | Override output audio port |
581
+ | `configureAudioSession()` | Configure `AVAudioSession` for voice chat |
582
+
583
+ **Events sent to JavaScript:**
584
+ - `onAudioCaptured` — Int16 array of captured PCM samples
585
+ - `onAudioError` — Error message string
586
+
587
+ ---
588
+
589
+ ## Permissions
590
+
591
+ ### Android
592
+
593
+ | Permission | Required | Description |
594
+ |-----------|----------|-------------|
595
+ | `INTERNET` | Yes | WebSocket connection to server |
596
+ | `RECORD_AUDIO` | Yes | Microphone capture |
597
+ | `MODIFY_AUDIO_SETTINGS` | Yes | Audio routing control |
598
+ | `ACCESS_NETWORK_STATE` | Recommended | Network change detection |
599
+
600
+ Request at runtime:
601
+ ```typescript
602
+ import { PermissionsAndroid } from 'react-native';
603
+
604
+ const granted = await PermissionsAndroid.request(
605
+ PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
606
+ { title: 'Microphone', message: 'Needed for voice chat' }
607
+ );
608
+ ```
609
+
610
+ ### iOS
611
+
612
+ | Key | Required | Description |
613
+ |-----|----------|-------------|
614
+ | `NSMicrophoneUsageDescription` | Yes | Microphone access reason |
615
+
616
+ ---
617
+
618
+ ## Important Notes
619
+
620
+ ### Connection Flow
621
+ 1. Create `VoiceConfig` with server, API key, and peer ID
622
+ 2. Create `VoiceClient` with config
623
+ 3. Set a `VoiceEventListener`
624
+ 4. Call `connect()` — establishes WebSocket connection
625
+ 5. Call `joinRoom(roomId)` — joins a voice room
626
+ 6. Call `enableMic()` — starts sending audio
627
+ 7. Call `enableSpeaker()` — starts receiving audio
628
+
629
+ ### Audio Format
630
+ - **PCM 16-bit signed integer**, mono, 48000 Hz
631
+ - 960 samples per 20ms frame
632
+ - ~768 kbps bandwidth per peer
633
+
634
+ ### Speaking Detection
635
+ - RMS threshold: **0.012** (normalized)
636
+ - Check interval: **80ms**
637
+ - Window: last **480 samples** (~10ms at 48kHz)
638
+
639
+ ### Ring Buffer
640
+ - Capacity: **96,000 samples** (~2 seconds at 48kHz)
641
+ - Oldest data is overwritten when full
642
+
643
+ ### Auto-Reconnect
644
+ - Saves current room and mic state before disconnect
645
+ - Attempts reconnection up to `maxReconnectAttempts` times
646
+ - Restores room and mic state on successful reconnection
647
+ - Delay between attempts: `reconnectDelayMs` (default 2000ms)
648
+
649
+ ### Authentication
650
+ - API key format: `vc_live_` followed by 32 hex characters
651
+ - Sent as query parameter in WebSocket URL
652
+ - Auth failures return close code `1008` or `4001`
653
+
654
+ ### Multi-Peer Audio Mixing
655
+ - Volume scaling: `min(1.0, 0.8 / peerCount)`
656
+ - Prevents clipping when multiple peers speak simultaneously
657
+
658
+ ### Thread Safety
659
+ - WebSocket events handled on the JS thread
660
+ - Native audio capture/playback on dedicated threads
661
+ - Peer state managed in JS single-threaded context
662
+
663
+ ### Cleanup
664
+ - Always call `dispose()` when done to release resources
665
+ - `disconnect()` disables auto-reconnect before cleaning up
666
+ - Native audio resources are released in `onCatalystInstanceDestroy` / `deinit`
667
+
668
+ ---
669
+
670
+ ## License
671
+
672
+ Copyright © Cloudfort. All Rights Reserved.
@@ -0,0 +1,38 @@
1
+ buildscript {
2
+ ext.safeExtGet = {prop, fallback ->
3
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
4
+ }
5
+ }
6
+
7
+ apply plugin: 'com.android.library'
8
+ apply plugin: 'kotlin-android'
9
+
10
+ android {
11
+ namespace "com.callumvoice"
12
+ compileSdkVersion safeExtGet('compileSdkVersion', 34)
13
+
14
+ defaultConfig {
15
+ minSdkVersion safeExtGet('minSdkVersion', 21)
16
+ targetSdkVersion safeExtGet('targetSdkVersion', 34)
17
+ }
18
+
19
+ compileOptions {
20
+ sourceCompatibility JavaVersion.VERSION_1_8
21
+ targetCompatibility JavaVersion.VERSION_1_8
22
+ }
23
+
24
+ kotlinOptions {
25
+ jvmTarget = "1.8"
26
+ }
27
+
28
+ sourceSets {
29
+ main {
30
+ java.srcDirs = ['src/main/java']
31
+ }
32
+ }
33
+ }
34
+
35
+ dependencies {
36
+ implementation "com.facebook.react:react-android:+"
37
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:+"
38
+ }