@cloudfort/callum-voice
v0.0.4
Published
Real-time voice chat SDK for React Native apps
Maintainers
Readme
Callum.Voice React Native SDK — Real-Time Voice Chat Library
A React Native plugin for cross-platform real-time voice communication over a Go server (phonil-opus).
Works on Android and iOS.
All microphone capture, audio playback, WebSocket connection, multi-peer audio mixing, speaking detection, and room management logic is implemented in this plugin.
Table of Contents
- Architecture
- File Structure
- Communication Protocol
- Installation
- Quick Start
- Complete Example
- API Reference
- VoiceConfig
- VoiceEventListener
- Native Modules
- Permissions
- Important Notes
Architecture
┌──────────────────────────────────────────────────────────────┐
│ Callum.Voice React Native SDK │
│ │
│ ┌─────────────────────┐ ┌──────────────────────────┐ │
│ │ VoiceClient (TS) │────▶│ Native Modules │ │
│ │ │◀────│ │ │
│ │ • WebSocket (native) │ │ Android: AudioRecord/ │ │
│ │ • Room Management │ │ AudioTrack │ │
│ │ • Peer Tracking │ │ iOS: AVAudioEngine │ │
│ │ • Speaking Detection │ └──────────────────────────┘ │
│ │ • Auto Reconnect │ ▲ │
│ └──────────┬───────────┘ │ │
│ │ WebSocket (PCM 16-bit mono) │ │
│ ▼ │ │
│ ┌─────────────────────┐ │ │
│ │ Go Server │── PCM Audio ─────┘ │
│ │ (phonil-opus) │ │
│ └─────────────────────┘ │
└──────────────────────────────────────────────────────────────┘Data Flow
Microphone ──▶ NativeModule ──(Int16[])──▶ JS VoiceClient ──(WebSocket)──▶ Server
Speaker ◀── NativeModule ◀──(Int16[])──◀ JS VoiceClient ◀──(WebSocket)──◀ ServerFile Structure
callum-react-native/
├── src/
│ ├── index.ts # Library exports
│ ├── VoiceClientState.ts # Connection state enum
│ ├── VoiceConfig.ts # Configuration interface
│ ├── VoiceEventListener.ts # Event callback interface
│ └── VoiceClient.ts # Core client logic
├── android/
│ ├── build.gradle # Android build config
│ └── src/main/
│ ├── AndroidManifest.xml # Permissions
│ └── java/ir/cloudfort/callum/
│ ├── CallumVoiceModule.kt # Native audio module
│ └── CallumVoicePackage.kt # Package registration
├── ios/
│ ├── CallumVoiceModule.swift # Native audio module
│ ├── CallumVoiceModule-Bridging-Header.h # ObjC bridge
│ └── CallumVoiceBridge.m # Bridge implementation
├── callum-voice.podspec # iOS CocoaPods spec
├── package.json # npm package config
├── tsconfig.json # TypeScript config
└── README.md # This fileCommunication Protocol
WebSocket Connection
ws(s)://{server}/ws?room=__lobby__&peer={peerId}&api_key={apiKey}Binary Packet Format
┌──────────┬──────────┬──────────┬──────────┬──────────────┐
│ RoomLen │ RoomID │ PeerLen │ PeerID │ PCM Audio │
│ (1 byte) │ (N bytes)│ (1 byte) │ (N bytes)│ (variable) │
└──────────┴──────────┴──────────┴──────────┴──────────────┘Audio Format
- PCM 16-bit signed integer, mono, 48000 Hz
- ~960 samples per 20ms frame (~768 kbps)
JSON Control Messages
// Join room
{"type": "join", "room": "room_id", "peer": "peer_id", "sampleRate": 48000}
// UDP token (server → client)
{"type": "udp_token", "token": "..."}Installation
1. Install Package
npm install callum-voice
# or
yarn add callum-voice2. iOS — Install Pods
cd ios && pod install && cd ..3. Android — Register Package
In MainApplication.kt:
import ir.cloudfort.callum.CallumVoicePackage
override fun getPackages(): List<ReactPackage> {
return listOf(
MainReactPackage(),
CallumVoicePackage() // ← Add this
)
}4. Platform Permissions
Android (android/app/src/main/AndroidManifest.xml)
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />iOS (ios/Runner/Info.plist)
<key>NSMicrophoneUsageDescription</key>
<string>This app needs microphone access for voice chat</string>Quick Start
import { VoiceClient, createVoiceConfig } from 'callum-voice';
// 1. Create config
const config = createVoiceConfig({
server: 'callem.cloudfort.ir',
apiKey: 'vc_token',
peerId: 'user_123',
});
// 2. Create client
const client = new VoiceClient(config);
// 3. Set listener
client.setListener({
onConnected: () => console.log('Connected!'),
onPeerSpeaking: (peerId) => console.log(`${peerId} is speaking`),
});
// 4. Connect
await client.connect();
// 5. Join a room
await client.joinRoom('game_room_1');
// 6. Enable microphone
await client.enableMic();Complete Example
import React, { useEffect, useState, useRef, useCallback } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
Alert,
} from 'react-native';
import {
VoiceClient,
VoiceClientState,
VoiceEventListener,
createVoiceConfig,
} from 'callum-voice';
const STATE_LABELS: Record<VoiceClientState, string> = {
[VoiceClientState.Disconnected]: 'Disconnected',
[VoiceClientState.Connecting]: 'Connecting...',
[VoiceClientState.Connected]: 'Connected',
[VoiceClientState.InRoom]: 'In Room',
[VoiceClientState.Reconnecting]: 'Reconnecting...',
};
export default function VoiceChatScreen() {
const clientRef = useRef<VoiceClient | null>(null);
const [state, setState] = useState<VoiceClientState>(VoiceClientState.Disconnected);
const [peers, setPeers] = useState<string[]>([]);
const [speakingPeers, setSpeakingPeers] = useState<Set<string>>(new Set());
const [micEnabled, setMicEnabled] = useState(false);
const [speakerEnabled, setSpeakerEnabled] = useState(false);
useEffect(() => {
const config = createVoiceConfig({
server: 'callem.cloudfort.ir',
apiKey: 'vc_token',
peerId: `user_${Date.now()}`,
});
const client = new VoiceClient(config);
clientRef.current = client;
const listener: VoiceEventListener = {
onConnected: () => console.log('Connected'),
onDisconnected: () => {
setPeers([]);
setSpeakingPeers(new Set());
},
onReconnecting: (attempt) => console.log(`Reconnecting #${attempt}`),
onAuthFailed: (reason) => Alert.alert('Auth Failed', reason),
onRoomJoined: (roomId) => console.log(`Joined ${roomId}`),
onRoomLeft: () => {
setPeers([]);
setSpeakingPeers(new Set());
},
onPeerJoined: (peerId) => setPeers(prev => [...prev, peerId]),
onPeerLeft: (peerId) => {
setPeers(prev => prev.filter(p => p !== peerId));
setSpeakingPeers(prev => {
const next = new Set(prev);
next.delete(peerId);
return next;
});
},
onPeerSpeaking: (peerId) =>
setSpeakingPeers(prev => new Set(prev).add(peerId)),
onPeerStopped: (peerId) =>
setSpeakingPeers(prev => {
const next = new Set(prev);
next.delete(peerId);
return next;
}),
onMicEnabled: () => setMicEnabled(true),
onMicDisabled: () => setMicEnabled(false),
onSpeakerEnabled: () => setSpeakerEnabled(true),
onSpeakerDisabled: () => setSpeakerEnabled(false),
onError: (error) => Alert.alert('Voice Error', error.message),
onStateChanged: (newState) => setState(newState),
};
client.setListener(listener);
client.connect().then(() => client.joinRoom('game_room_1'));
return () => {
client.dispose();
clientRef.current = null;
};
}, []);
const handleToggleMic = useCallback(async () => {
const client = clientRef.current;
if (!client) return;
if (micEnabled) {
client.disableMic();
} else {
await client.enableMic();
}
}, [micEnabled]);
const handleToggleSpeaker = useCallback(() => {
const client = clientRef.current;
if (!client) return;
if (speakerEnabled) {
client.disableSpeaker();
} else {
client.enableSpeaker();
}
}, [speakerEnabled]);
return (
<View style={styles.container}>
{/* Status Bar */}
<View style={styles.statusBar}>
<Text style={styles.statusText}>{STATE_LABELS[state]}</Text>
</View>
{/* Controls */}
<View style={styles.controls}>
<TouchableOpacity
style={[styles.button, micEnabled && styles.buttonActive]}
onPress={handleToggleMic}
>
<Text style={styles.buttonText}>
{micEnabled ? '🎤 Mute' : '🎤 Unmute'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, speakerEnabled && styles.buttonActive]}
onPress={handleToggleSpeaker}
>
<Text style={styles.buttonText}>
{speakerEnabled ? '🔊 Speaker Off' : '🔇 Speaker On'}
</Text>
</TouchableOpacity>
</View>
{/* Peers List */}
<FlatList
data={peers}
keyExtractor={(item) => item}
renderItem={({ item }) => {
const isSpeaking = speakingPeers.has(item);
return (
<View style={styles.peerRow}>
<Text style={styles.peerIcon}>{isSpeaking ? '🟢' : '⚪'}</Text>
<View style={styles.peerInfo}>
<Text style={styles.peerName}>{item}</Text>
<Text style={styles.peerStatus}>
{isSpeaking ? 'Speaking...' : 'Silent'}
</Text>
</View>
</View>
);
}}
ListEmptyComponent={
<Text style={styles.emptyText}>No peers in room</Text>
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#1a1a2e' },
statusBar: {
padding: 16,
backgroundColor: '#16213e',
alignItems: 'center',
},
statusText: { color: '#e94560', fontSize: 18, fontWeight: 'bold' },
controls: {
flexDirection: 'row',
justifyContent: 'center',
padding: 16,
gap: 16,
},
button: {
paddingHorizontal: 24,
paddingVertical: 12,
backgroundColor: '#0f3460',
borderRadius: 8,
},
buttonActive: { backgroundColor: '#e94560' },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
peerRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#16213e',
},
peerIcon: { fontSize: 24, marginRight: 12 },
peerInfo: { flex: 1 },
peerName: { color: '#fff', fontSize: 16, fontWeight: '500' },
peerStatus: { color: '#888', fontSize: 13 },
emptyText: { color: '#888', textAlign: 'center', marginTop: 40, fontSize: 16 },
});API Reference
VoiceClient
The main class for managing voice connections.
Constructor
new VoiceClient(config: VoiceConfig)| Parameter | Type | Description |
|-----------|------|-------------|
| config | VoiceConfig | Connection and audio configuration |
Properties
| Property | Type | Description |
|----------|------|-------------|
| state | VoiceClientState | Current connection state (read-only) |
| isConnected | boolean | Whether connected to server |
| isInRoom | boolean | Whether joined a room |
| isMicEnabled | boolean | Whether microphone is active |
| isSpeakerEnabled | boolean | Whether speaker output is active |
| currentRoomId | string \| null | Current room ID |
| peerIds | string[] | List of peer IDs in the current room |
Methods
| Method | Returns | Description |
|--------|---------|-------------|
| setListener(listener) | void | Set the event listener |
| connect() | Promise<void> | Connect to the voice server |
| disconnect() | void | Disconnect from the server |
| joinRoom(roomId) | Promise<void> | Join a voice room |
| leaveRoom() | Promise<void> | Leave the current room |
| enableMic() | Promise<void> | Enable microphone (must be in room) |
| disableMic() | void | Disable microphone |
| toggleMic() | Promise<boolean> | Toggle mic, returns new state |
| enableSpeaker() | void | Enable speaker output |
| disableSpeaker() | void | Disable speaker output |
| toggleSpeaker() | boolean | Toggle speaker, returns new state |
| isPeerSpeaking(peerId) | boolean | Check if a specific peer is speaking |
| getPeerSpeakingInfo(peerId) | {speaking, rms} | Get peer speaking state with RMS level |
| sendAudio(samples) | void | Send captured audio to server (from native) |
| dispose() | void | Cleanup and release resources |
VoiceConfig
Configuration for connecting to the voice chat service.
interface VoiceConfig {
server: string;
apiKey: string;
peerId: string;
useTls?: boolean;
sampleRate: number;
autoReconnect: boolean;
maxReconnectAttempts: number;
reconnectDelayMs: number;
echoCancellation: boolean;
noiseSuppression: boolean;
autoGainControl: boolean;
}createVoiceConfig Helper
function createVoiceConfig(overrides: Partial<VoiceConfig>): VoiceConfigCreates a VoiceConfig with sensible defaults, merged with your overrides.
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| server | string | '' | Server address (e.g. "callem.cloudfort.ir"). Do NOT include scheme or port. |
| apiKey | string | '' | API key from account registration (format: vc_live_...) |
| peerId | string | '' | Unique identifier for this peer/user |
| useTls | boolean? | undefined | Use WSS instead of WS. Auto-detected when undefined. |
| sampleRate | number | 48000 | Audio sample rate in Hz |
| autoReconnect | boolean | true | Automatically reconnect on disconnect |
| maxReconnectAttempts | number | 5 | Maximum reconnection attempts |
| reconnectDelayMs | number | 2000 | Delay between reconnection attempts (ms) |
| echoCancellation | boolean | true | Enable platform echo cancellation |
| noiseSuppression | boolean | true | Enable platform noise suppression |
| autoGainControl | boolean | true | Enable platform automatic gain control |
VoiceEventListener
Interface for receiving voice client events. All methods are optional.
interface VoiceEventListener {
onConnected?(): void;
onDisconnected?(): void;
onReconnecting?(attempt: number): void;
onAuthFailed?(reason: string): void;
onRoomJoined?(roomId: string): void;
onRoomLeft?(roomId: string): void;
onPeerJoined?(peerId: string): void;
onPeerLeft?(peerId: string): void;
onPeerSpeaking?(peerId: string): void;
onPeerStopped?(peerId: string): void;
onMicEnabled?(): void;
onMicDisabled?(): void;
onSpeakerEnabled?(): void;
onSpeakerDisabled?(): void;
onError?(error: Error): void;
onStateChanged?(newState: VoiceClientState): void;
}Events
| Method | Parameters | Description |
|--------|-----------|-------------|
| onConnected | — | Connection established |
| onDisconnected | — | Connection lost |
| onReconnecting | attempt — current attempt number | Reconnection in progress |
| onAuthFailed | reason — failure description | Authentication failed |
| onRoomJoined | roomId — joined room ID | Successfully joined a room |
| onRoomLeft | roomId — left room ID | Left a room |
| onPeerJoined | peerId — new peer's ID | A peer joined the room |
| onPeerLeft | peerId — departing peer's ID | A peer left the room |
| onPeerSpeaking | peerId — speaking peer's ID | Peer started speaking |
| onPeerStopped | peerId — silent peer's ID | Peer stopped speaking |
| onMicEnabled | — | Microphone enabled |
| onMicDisabled | — | Microphone disabled |
| onSpeakerEnabled | — | Speaker output enabled |
| onSpeakerDisabled | — | Speaker output disabled |
| onError | error — Error object | An error occurred |
| onStateChanged | newState — VoiceClientState | Connection state changed |
Native Modules
Android (Kotlin)
The Android native module at android/src/main/java/ir/cloudfort/callum/CallumVoiceModule.kt provides:
| Method | Description |
|--------|-------------|
| startAudioCapture() | Start microphone recording via AudioRecord |
| stopAudioCapture() | Stop microphone recording |
| startAudioPlayback() | Start audio playback via AudioTrack |
| stopAudioPlayback() | Stop audio playback |
| enqueuePeerAudio(peerId, samples) | Write PCM samples to AudioTrack |
| setSpeakerphoneOn(on) | Route audio to speaker/earpiece |
| configureAudioSession() | Set MODE_IN_COMMUNICATION |
Events sent to JavaScript:
onAudioCaptured— Int16 array of captured PCM samplesonAudioError— Error message string
iOS (Swift)
The iOS native module at ios/CallumVoiceModule.swift provides:
| Method | Description |
|--------|-------------|
| startAudioCapture() | Start microphone capture via AVAudioEngine |
| stopAudioCapture() | Stop microphone capture |
| startAudioPlayback() | Start audio playback via AVAudioEngine |
| stopAudioPlayback() | Stop audio playback |
| enqueuePeerAudio(peerId, samples) | Schedule PCM buffer for playback |
| setSpeakerphoneOn(on) | Override output audio port |
| configureAudioSession() | Configure AVAudioSession for voice chat |
Events sent to JavaScript:
onAudioCaptured— Int16 array of captured PCM samplesonAudioError— Error message string
Permissions
Android
| Permission | Required | Description |
|-----------|----------|-------------|
| INTERNET | Yes | WebSocket connection to server |
| RECORD_AUDIO | Yes | Microphone capture |
| MODIFY_AUDIO_SETTINGS | Yes | Audio routing control |
| ACCESS_NETWORK_STATE | Recommended | Network change detection |
Request at runtime:
import { PermissionsAndroid } from 'react-native';
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
{ title: 'Microphone', message: 'Needed for voice chat' }
);iOS
| Key | Required | Description |
|-----|----------|-------------|
| NSMicrophoneUsageDescription | Yes | Microphone access reason |
Important Notes
Connection Flow
- Create
VoiceConfigwith server, API key, and peer ID - Create
VoiceClientwith config - Set a
VoiceEventListener - Call
connect()— establishes WebSocket connection - Call
joinRoom(roomId)— joins a voice room - Call
enableMic()— starts sending audio - Call
enableSpeaker()— starts receiving audio
Audio Format
- PCM 16-bit signed integer, mono, 48000 Hz
- 960 samples per 20ms frame
- ~768 kbps bandwidth per peer
Speaking Detection
- RMS threshold: 0.012 (normalized)
- Check interval: 80ms
- Window: last 480 samples (~10ms at 48kHz)
Ring Buffer
- Capacity: 96,000 samples (~2 seconds at 48kHz)
- Oldest data is overwritten when full
Auto-Reconnect
- Saves current room and mic state before disconnect
- Attempts reconnection up to
maxReconnectAttemptstimes - Restores room and mic state on successful reconnection
- Delay between attempts:
reconnectDelayMs(default 2000ms)
Authentication
- API key format:
vc_live_followed by 32 hex characters - Sent as query parameter in WebSocket URL
- Auth failures return close code
1008or4001
Multi-Peer Audio Mixing
- Volume scaling:
min(1.0, 0.8 / peerCount) - Prevents clipping when multiple peers speak simultaneously
Thread Safety
- WebSocket events handled on the JS thread
- Native audio capture/playback on dedicated threads
- Peer state managed in JS single-threaded context
Cleanup
- Always call
dispose()when done to release resources disconnect()disables auto-reconnect before cleaning up- Native audio resources are released in
onCatalystInstanceDestroy/deinit
License
Copyright © Cloudfort. All Rights Reserved.
