@unboundcx/video-sdk-client 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AudioMixer.js ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * AudioMixer - Web Audio API based mixer for scalable audio handling
3
+ * Handles multiple audio streams efficiently by mixing them in a single audio context
4
+ * Used automatically for meetings with 30+ participants
5
+ */
6
+
7
+ export class AudioMixer {
8
+ constructor() {
9
+ this.audioContext = null;
10
+ this.masterGain = null;
11
+ this.participants = new Map(); // Map<participantId, { source, gainNode, stream }>
12
+ this.isInitialized = false;
13
+ }
14
+
15
+ /**
16
+ * Initialize the audio context and master gain
17
+ */
18
+ async initialize() {
19
+ if (this.isInitialized) {
20
+ console.log('SDK :: AudioMixer :: Already initialized');
21
+ return;
22
+ }
23
+
24
+ try {
25
+ // Create audio context
26
+ this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
27
+
28
+ // Create master gain node (output)
29
+ this.masterGain = this.audioContext.createGain();
30
+ this.masterGain.gain.value = 1.0;
31
+ this.masterGain.connect(this.audioContext.destination);
32
+
33
+ this.isInitialized = true;
34
+ console.log('SDK :: AudioMixer :: Initialized successfully', {
35
+ sampleRate: this.audioContext.sampleRate,
36
+ state: this.audioContext.state,
37
+ });
38
+
39
+ // Handle audio context state changes
40
+ this.audioContext.addEventListener('statechange', () => {
41
+ console.log('SDK :: AudioMixer :: State changed:', this.audioContext.state);
42
+ });
43
+ } catch (err) {
44
+ console.error('SDK :: AudioMixer :: Failed to initialize:', err);
45
+ throw err;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Resume audio context (needed for user interaction requirement)
51
+ */
52
+ async resume() {
53
+ if (this.audioContext && this.audioContext.state === 'suspended') {
54
+ await this.audioContext.resume();
55
+ console.log('SDK :: AudioMixer :: Context resumed');
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Add a participant's audio stream to the mixer
61
+ */
62
+ addParticipant(participantId, stream, volume = 1.0) {
63
+ if (!this.isInitialized) {
64
+ console.error('SDK :: AudioMixer :: Not initialized');
65
+ return false;
66
+ }
67
+
68
+ // Remove existing participant if already added
69
+ if (this.participants.has(participantId)) {
70
+ this.removeParticipant(participantId);
71
+ }
72
+
73
+ try {
74
+ // Create media stream source
75
+ const source = this.audioContext.createMediaStreamSource(stream);
76
+
77
+ // Create gain node for this participant (for individual volume control)
78
+ const gainNode = this.audioContext.createGain();
79
+ gainNode.gain.value = volume;
80
+
81
+ // Connect: source -> gainNode -> masterGain -> destination
82
+ source.connect(gainNode);
83
+ gainNode.connect(this.masterGain);
84
+
85
+ // Store participant info
86
+ this.participants.set(participantId, {
87
+ source,
88
+ gainNode,
89
+ stream,
90
+ });
91
+
92
+ console.log('SDK :: AudioMixer :: Added participant:', participantId, {
93
+ volume,
94
+ participantCount: this.participants.size,
95
+ });
96
+
97
+ return true;
98
+ } catch (err) {
99
+ console.error('SDK :: AudioMixer :: Failed to add participant:', participantId, err);
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Remove a participant's audio stream from the mixer
106
+ */
107
+ removeParticipant(participantId) {
108
+ const participant = this.participants.get(participantId);
109
+ if (!participant) {
110
+ return false;
111
+ }
112
+
113
+ try {
114
+ // Disconnect nodes
115
+ participant.source.disconnect();
116
+ participant.gainNode.disconnect();
117
+
118
+ // Remove from map
119
+ this.participants.delete(participantId);
120
+
121
+ console.log('SDK :: AudioMixer :: Removed participant:', participantId, {
122
+ participantCount: this.participants.size,
123
+ });
124
+
125
+ return true;
126
+ } catch (err) {
127
+ console.error('SDK :: AudioMixer :: Failed to remove participant:', participantId, err);
128
+ return false;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Set volume for a specific participant (0.0 to 1.0)
134
+ */
135
+ setParticipantVolume(participantId, volume) {
136
+ const participant = this.participants.get(participantId);
137
+ if (!participant) {
138
+ console.warn('SDK :: AudioMixer :: Participant not found:', participantId);
139
+ return false;
140
+ }
141
+
142
+ try {
143
+ // Clamp volume between 0 and 1
144
+ const clampedVolume = Math.max(0, Math.min(1, volume));
145
+ participant.gainNode.gain.value = clampedVolume;
146
+
147
+ console.log('SDK :: AudioMixer :: Set volume:', participantId, clampedVolume);
148
+ return true;
149
+ } catch (err) {
150
+ console.error('SDK :: AudioMixer :: Failed to set volume:', participantId, err);
151
+ return false;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Get current volume for a participant
157
+ */
158
+ getParticipantVolume(participantId) {
159
+ const participant = this.participants.get(participantId);
160
+ return participant ? participant.gainNode.gain.value : null;
161
+ }
162
+
163
+ /**
164
+ * Set master volume (affects all participants)
165
+ */
166
+ setMasterVolume(volume) {
167
+ if (!this.masterGain) {
168
+ return false;
169
+ }
170
+
171
+ const clampedVolume = Math.max(0, Math.min(1, volume));
172
+ this.masterGain.gain.value = clampedVolume;
173
+ console.log('SDK :: AudioMixer :: Set master volume:', clampedVolume);
174
+ return true;
175
+ }
176
+
177
+ /**
178
+ * Get master volume
179
+ */
180
+ getMasterVolume() {
181
+ return this.masterGain ? this.masterGain.gain.value : null;
182
+ }
183
+
184
+ /**
185
+ * Check if a participant is in the mixer
186
+ */
187
+ hasParticipant(participantId) {
188
+ return this.participants.has(participantId);
189
+ }
190
+
191
+ /**
192
+ * Get number of participants in mixer
193
+ */
194
+ getParticipantCount() {
195
+ return this.participants.size;
196
+ }
197
+
198
+ /**
199
+ * Clear all participants and reset
200
+ */
201
+ clear() {
202
+ console.log('SDK :: AudioMixer :: Clearing all participants');
203
+
204
+ // Remove all participants
205
+ for (const participantId of this.participants.keys()) {
206
+ this.removeParticipant(participantId);
207
+ }
208
+
209
+ this.participants.clear();
210
+ }
211
+
212
+ /**
213
+ * Cleanup and close the audio context
214
+ */
215
+ async destroy() {
216
+ console.log('SDK :: AudioMixer :: Destroying');
217
+
218
+ // Clear all participants
219
+ this.clear();
220
+
221
+ // Close audio context
222
+ if (this.audioContext) {
223
+ try {
224
+ await this.audioContext.close();
225
+ console.log('SDK :: AudioMixer :: Audio context closed');
226
+ } catch (err) {
227
+ console.error('SDK :: AudioMixer :: Failed to close audio context:', err);
228
+ }
229
+ }
230
+
231
+ this.audioContext = null;
232
+ this.masterGain = null;
233
+ this.isInitialized = false;
234
+ }
235
+ }
package/README.md ADDED
@@ -0,0 +1,400 @@
1
+ # Video Meeting SDK
2
+
3
+ A standalone, framework-agnostic SDK for building video conferencing applications with mediasoup.
4
+
5
+ ## Features
6
+
7
+ - 🎥 Camera, microphone, and screen sharing
8
+ - 👥 Multi-party video conferencing
9
+ - 🔄 Real-time media stream management
10
+ - 🎨 Virtual backgrounds with blur and custom images (MediaPipe)
11
+ - 📡 Simulcast for adaptive bitrate (3 quality layers)
12
+ - 🎯 Simple, promise-based API
13
+ - 📦 Framework agnostic (works with React, Vue, Svelte, vanilla JS)
14
+ - 🔌 Socket.io + mediasoup integration
15
+ - 🖼️ No UI dependencies - bring your own interface
16
+ - ✅ Mac, Windows, Linux support (latest mediasoup v3.16+)
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ npm install @unboundcx/video-sdk
22
+ ```
23
+
24
+ **Note:** This SDK connects to Unbound's managed video server infrastructure. You don't need to set up your own media servers.
25
+
26
+ ## Quick Start
27
+
28
+ ### Option 1: Using with Unbound SDK (Recommended)
29
+
30
+ ```javascript
31
+ import { VideoMeetingClient } from '@unboundcx/video-sdk';
32
+ import { UnboundSDK } from '@unboundcx/sdk';
33
+
34
+ // Initialize SDK
35
+ const sdk = new UnboundSDK('your-namespace', 'api.yourdomain.com');
36
+
37
+ // Initialize Video Client
38
+ const client = new VideoMeetingClient({ debug: true });
39
+
40
+ // Join using Unbound SDK API response
41
+ const joinResponse = await sdk.video.joinRoom('room-123', 'password', 'user@example.com');
42
+ await client.joinFromApiResponse(joinResponse);
43
+
44
+ // Publish local media
45
+ const videoStream = await client.publishCamera({ resolution: '720p' });
46
+ await client.publishMicrophone();
47
+
48
+ // Listen for remote participants
49
+ client.on('stream:added', ({ participantId, stream, type }) => {
50
+ const videoElement = document.getElementById(`video-${participantId}`);
51
+ videoElement.srcObject = stream;
52
+ });
53
+
54
+ // Leave meeting
55
+ await client.leaveRoom();
56
+ await client.disconnect();
57
+ ```
58
+
59
+ ### Option 2: Direct Integration (Custom Backend)
60
+
61
+ If you're building your own backend (not using Unbound's video server):
62
+
63
+ ```javascript
64
+ import { VideoMeetingClient } from '@unboundcx/video-sdk';
65
+
66
+ // Initialize - no serverUrl needed, will be provided by your join API
67
+ const client = new VideoMeetingClient({ debug: true });
68
+
69
+ // Your custom API should return the same format as Unbound SDK
70
+ const joinResponse = await yourApi.joinVideoRoom('room-123');
71
+ // joinResponse should include: { videoRoom, server, participant, authorization }
72
+
73
+ await client.joinFromApiResponse(joinResponse);
74
+
75
+ // Publish local camera
76
+ const videoStream = await client.publishCamera();
77
+
78
+ // Listen for remote participants
79
+ client.on('participant:joined', (participant) => {
80
+ console.log('Participant joined:', participant.id);
81
+ });
82
+
83
+ client.on('stream:added', ({ participantId, stream, type }) => {
84
+ const videoElement = document.getElementById(`video-${participantId}`);
85
+ videoElement.srcObject = stream;
86
+ });
87
+
88
+ // Leave meeting
89
+ await client.leaveRoom();
90
+ await client.disconnect();
91
+ ```
92
+
93
+ **Note:** This SDK is designed to work with Unbound's video server infrastructure. For custom backends, you'll need to implement compatible server-side APIs.
94
+
95
+ ## API Documentation
96
+
97
+ ### Constructor
98
+
99
+ ```javascript
100
+ new VideoMeetingClient(options)
101
+ ```
102
+
103
+ **Options:**
104
+ - `serverUrl` (string, optional) - WebSocket server URL (not required if using `joinFromApiResponse`)
105
+ - `debug` (boolean, optional) - Enable debug logging
106
+
107
+ ### Methods
108
+
109
+ #### Connection
110
+
111
+ **`joinFromApiResponse(joinResponse)`** ⭐ Recommended
112
+ - Joins a meeting using the response from `api.video.joinRoom()`
113
+ - Automatically extracts server URL, auth token, and room info
114
+ - Connects to video server and joins room in one call
115
+ - Returns: `Promise<Object>` - Full join data including videoRoom, participant, server info
116
+
117
+ **Example:**
118
+ ```javascript
119
+ const joinResponse = await api.video.joinRoom('room-123', 'password', 'user@example.com');
120
+ await client.joinFromApiResponse(joinResponse);
121
+
122
+ // Access join data
123
+ const videoRoom = client.getVideoRoom();
124
+ const participant = client.getCurrentParticipant();
125
+ ```
126
+
127
+ **`connect(authToken)`**
128
+ - Connect to server (manual method)
129
+ - Returns: `Promise<void>`
130
+
131
+ **`disconnect()`**
132
+ - Disconnect from server
133
+ - Returns: `Promise<void>`
134
+
135
+ **`joinRoom(roomId, options?)`**
136
+ - Join a meeting room (manual method, requires `connect()` first)
137
+ - Returns: `Promise<Object>` - Room data
138
+
139
+ **`leaveRoom()`**
140
+ - Leave current room
141
+ - Returns: `Promise<void>`
142
+
143
+ #### Local Media
144
+
145
+ **`publishCamera(options?)`** - Start publishing camera
146
+ - `options.resolution` - '480p', '720p', '1080p' (default: '720p')
147
+ - `options.frameRate` - Frame rate (default: 24)
148
+ - `options.deviceId` - Specific camera device ID
149
+ - `options.background` - Virtual background options:
150
+ - `{ type: 'blur', blurLevel: 8 }` - Blur background
151
+ - `{ type: 'image', imageUrl: '/path/to/image.jpg' }` - Virtual background
152
+ - `{ type: 'none' }` - No effect (default)
153
+ - `options.simulcast` - Enable simulcast (default: true for video)
154
+ - Returns: `Promise<MediaStream>`
155
+
156
+ **Example:**
157
+ ```javascript
158
+ // Publish with blur background
159
+ await client.publishCamera({
160
+ resolution: '720p',
161
+ background: { type: 'blur', blurLevel: 8 }
162
+ });
163
+
164
+ // Publish with virtual background
165
+ await client.publishCamera({
166
+ background: {
167
+ type: 'image',
168
+ imageUrl: '/images/backgrounds/office.jpg'
169
+ }
170
+ });
171
+ ```
172
+
173
+ **`updateCameraBackground(options)`** - Change background on active camera
174
+ - `options.type` - 'none', 'blur', or 'image'
175
+ - `options.blurLevel` - Blur amount in pixels (default: 8)
176
+ - `options.imageUrl` - Background image URL
177
+ - Returns: `Promise<MediaStream>`
178
+
179
+ **Other Methods:**
180
+ - `publishMicrophone(options?)` - Start publishing microphone
181
+ - `publishScreenShare()` - Start screen sharing
182
+ - `stopCamera()` - Stop camera stream
183
+ - `stopMicrophone()` - Stop microphone stream
184
+ - `stopScreenShare()` - Stop screen sharing
185
+ - `changeCamera(deviceId)` - Switch to different camera
186
+ - `changeMicrophone(deviceId)` - Switch to different microphone
187
+ - `muteCamera()` - Mute camera
188
+ - `unmuteCamera()` - Unmute camera
189
+ - `muteMicrophone()` - Mute microphone
190
+ - `unmuteMicrophone()` - Unmute microphone
191
+
192
+ #### Device Management
193
+ - `getDevices()` - Get available media devices
194
+ - `getLocalStream(type)` - Get local stream by type
195
+
196
+ #### State and Info
197
+ - `getState()` - Get current SDK state ('disconnected', 'connecting', 'connected', 'joining', 'in-room')
198
+ - `isConnected()` - Check if connected to server
199
+ - `isInRoom()` - Check if currently in a room
200
+ - `isCameraActive()` - Check if camera is active
201
+ - `isMicrophoneActive()` - Check if microphone is active
202
+ - `isCameraMuted()` - Check if camera is muted
203
+ - `isMicrophoneMuted()` - Check if microphone is muted
204
+ - `getParticipantCount()` - Get number of remote participants
205
+
206
+ #### Join Data Access (when using `joinFromApiResponse`)
207
+ - `getVideoRoom()` - Get video room info (id, name, friendlyName, waitingRoom, etc.)
208
+ - `getCurrentParticipant()` - Get current participant info (id, name, email, isHost, etc.)
209
+ - `getServerInfo()` - Get server info (url, serverId, socketPort)
210
+ - `getJoinData()` - Get full join data (videoRoom, participant, server, authorization)
211
+
212
+ ### Events
213
+
214
+ ```javascript
215
+ client.on('event-name', (data) => {
216
+ // Handle event
217
+ });
218
+ ```
219
+
220
+ **Connection Events:**
221
+ - `connected` - Connected to server
222
+ - `disconnected` - Disconnected from server
223
+ - `error` - Connection error
224
+
225
+ **Room Events:**
226
+ - `room:joined` - Successfully joined room
227
+ - `room:left` - Left the room
228
+ - `participant:joined` - New participant joined
229
+ - `participant:left` - Participant left
230
+ - `participant:updated` - Participant state changed
231
+
232
+ **Media Events:**
233
+ - `stream:added` - New media stream available
234
+ - `stream:removed` - Media stream removed
235
+ - `local-stream:added` - Local stream started
236
+ - `local-stream:removed` - Local stream stopped
237
+
238
+ **State Events:**
239
+ - `state:changed` - SDK state changed
240
+
241
+ ## Example: Complete Video Call
242
+
243
+ ```javascript
244
+ import { VideoMeetingClient } from '@yourcompany/video-sdk';
245
+
246
+ class VideoCall {
247
+ constructor() {
248
+ this.client = new VideoMeetingClient({
249
+ serverUrl: 'wss://video.example.com',
250
+ debug: true
251
+ });
252
+
253
+ this.setupEventListeners();
254
+ }
255
+
256
+ setupEventListeners() {
257
+ // Handle remote streams
258
+ this.client.on('stream:added', ({ participantId, stream, type }) => {
259
+ this.addRemoteStream(participantId, stream, type);
260
+ });
261
+
262
+ this.client.on('stream:removed', ({ participantId, type }) => {
263
+ this.removeRemoteStream(participantId, type);
264
+ });
265
+
266
+ // Handle participants
267
+ this.client.on('participant:joined', (participant) => {
268
+ this.addParticipantUI(participant);
269
+ });
270
+
271
+ this.client.on('participant:left', ({ participantId }) => {
272
+ this.removeParticipantUI(participantId);
273
+ });
274
+
275
+ // Handle errors
276
+ this.client.on('error', (error) => {
277
+ console.error('SDK Error:', error);
278
+ this.showErrorMessage(error.message);
279
+ });
280
+ }
281
+
282
+ async start(roomId, authToken) {
283
+ try {
284
+ // Connect to server
285
+ await this.client.connect(authToken);
286
+
287
+ // Join room
288
+ await this.client.joinRoom(roomId);
289
+
290
+ // Start local media
291
+ const videoStream = await this.client.publishCamera({
292
+ resolution: '720p'
293
+ });
294
+ const audioStream = await this.client.publishMicrophone();
295
+
296
+ // Display local video
297
+ this.showLocalVideo(videoStream);
298
+
299
+ } catch (error) {
300
+ console.error('Failed to start call:', error);
301
+ throw error;
302
+ }
303
+ }
304
+
305
+ async toggleCamera() {
306
+ if (this.client.isCameraMuted()) {
307
+ await this.client.unmuteCamera();
308
+ } else {
309
+ await this.client.muteCamera();
310
+ }
311
+ }
312
+
313
+ async toggleMicrophone() {
314
+ if (this.client.isMicrophoneMuted()) {
315
+ await this.client.unmuteMicrophone();
316
+ } else {
317
+ await this.client.muteMicrophone();
318
+ }
319
+ }
320
+
321
+ async startScreenShare() {
322
+ try {
323
+ await this.client.publishScreenShare();
324
+ } catch (error) {
325
+ if (error.name === 'NotAllowedError') {
326
+ console.log('Screen share permission denied');
327
+ }
328
+ }
329
+ }
330
+
331
+ async end() {
332
+ await this.client.leaveRoom();
333
+ await this.client.disconnect();
334
+ }
335
+
336
+ // UI methods (implement based on your framework)
337
+ addRemoteStream(participantId, stream, type) { }
338
+ removeRemoteStream(participantId, type) { }
339
+ addParticipantUI(participant) { }
340
+ removeParticipantUI(participantId) { }
341
+ showLocalVideo(stream) { }
342
+ showErrorMessage(message) { }
343
+ }
344
+
345
+ // Usage
346
+ const videoCall = new VideoCall();
347
+ await videoCall.start('room-123', 'auth-token-xyz');
348
+ ```
349
+
350
+ ## Architecture
351
+
352
+ The SDK is composed of several managers:
353
+
354
+ - **MediasoupManager** - Manages mediasoup Device and Transports
355
+ - **LocalMediaManager** - Handles camera, microphone, screen share
356
+ - **RemoteMediaManager** - Manages remote participant streams
357
+ - **ConnectionManager** - Socket.io connection and signaling
358
+
359
+ ## State Machine
360
+
361
+ ```
362
+ disconnected -> connecting -> connected -> joining -> in-room -> leaving -> connected
363
+ ↓ ↓
364
+ error error
365
+ ```
366
+
367
+ ## Error Handling
368
+
369
+ All errors are wrapped in custom error classes:
370
+
371
+ ```javascript
372
+ try {
373
+ await client.publishCamera();
374
+ } catch (error) {
375
+ if (error.name === 'DeviceNotFoundError') {
376
+ console.log('Camera not found');
377
+ } else if (error.name === 'PermissionDeniedError') {
378
+ console.log('Camera permission denied');
379
+ } else if (error.name === 'ConnectionError') {
380
+ console.log('Connection failed');
381
+ }
382
+ }
383
+ ```
384
+
385
+ ## Development
386
+
387
+ ```bash
388
+ # Install dependencies
389
+ npm install
390
+
391
+ # Run tests
392
+ npm test
393
+
394
+ # Build
395
+ npm run build
396
+ ```
397
+
398
+ ## License
399
+
400
+ MIT