@tetrax/voice-sdk 1.0.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/README.md +117 -0
- package/package.json +11 -0
- package/src/index.js +273 -0
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# @tetrax/voice-sdk
|
|
2
|
+
|
|
3
|
+
Client SDK for TETRAX Communication Platform as a Service (CPaaS). Connects seamlessly to TETRAX Voice real-time audio signaling and SFU servers.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @tetrax/voice-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or reference locally via your application's `package.json`:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@tetrax/voice-sdk": "file:../path/to/tetrax-sdk"
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Basic Usage
|
|
20
|
+
|
|
21
|
+
```javascript
|
|
22
|
+
import { TetraxVoice } from '@tetrax/voice-sdk';
|
|
23
|
+
|
|
24
|
+
// Initialize SDK
|
|
25
|
+
const client = new TetraxVoice({
|
|
26
|
+
apiKey: 'trx_live_your_key',
|
|
27
|
+
apiUri: 'https://api.tetrax.io' // Optional: defaults to production TETRAX cloud
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Register Event Listeners
|
|
31
|
+
client.on('track', ({ stream, userId }) => {
|
|
32
|
+
// Play remote audio
|
|
33
|
+
const audio = new Audio();
|
|
34
|
+
audio.srcObject = stream;
|
|
35
|
+
audio.play();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
client.on('user-joined', ({ userId }) => {
|
|
39
|
+
console.log(`User ${userId} has joined the voice call`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
client.on('user-left', ({ userId }) => {
|
|
43
|
+
console.log(`User ${userId} has left the voice call`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Connect to signaling and join the call room
|
|
47
|
+
await client.connect(voiceToken);
|
|
48
|
+
await client.joinRoom('room_123');
|
|
49
|
+
|
|
50
|
+
// Toggle microphone
|
|
51
|
+
await client.mute();
|
|
52
|
+
await client.unmute();
|
|
53
|
+
|
|
54
|
+
// Leave room
|
|
55
|
+
await client.leaveRoom();
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Hosting & Distribution Options
|
|
61
|
+
|
|
62
|
+
Because an SDK (Software Development Kit) is a client-side library rather than a running process, **it does not need server hosting** like a backend api or database does.
|
|
63
|
+
|
|
64
|
+
Instead of running on a server, it needs to be hosted on a **package registry** or **version control host** so that target front-end applications can install it.
|
|
65
|
+
|
|
66
|
+
Here are the best ways to host and distribute the **`@[tetrax-sdk]`** package:
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
### Option 1: Publish to the NPM Registry (Standard Production Approach)
|
|
71
|
+
This makes the SDK available via a simple `npm install @tetrax/voice-sdk` (like other packages such as `react` or `socket.io-client`).
|
|
72
|
+
* **Where it is hosted:** [npmjs.com](https://www.npmjs.com/) (The global NPM registry).
|
|
73
|
+
* **Cost:** Free for public packages; paid for private packages.
|
|
74
|
+
* **How to distribute:**
|
|
75
|
+
1. Register an NPM account (or create an organization named `tetrax`).
|
|
76
|
+
2. Log in via your terminal inside the `tetrax-sdk` folder:
|
|
77
|
+
```bash
|
|
78
|
+
npm login
|
|
79
|
+
```
|
|
80
|
+
3. Publish the package:
|
|
81
|
+
```bash
|
|
82
|
+
npm publish --access public
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
### Option 2: Host on GitHub / Git Repository (Best for Private/Internal Use)
|
|
88
|
+
If you want to keep the SDK private or share it easily without publishing to public NPM, you can host it in its own Git repository.
|
|
89
|
+
* **Where it is hosted:** GitHub, GitLab, or Bitbucket.
|
|
90
|
+
* **How to install:** Other projects can install it directly from the Git URL:
|
|
91
|
+
```bash
|
|
92
|
+
npm install git+https://github.com/your-username/tetrax-voice-sdk.git
|
|
93
|
+
```
|
|
94
|
+
Or add it directly to `package.json`:
|
|
95
|
+
```json
|
|
96
|
+
"dependencies": {
|
|
97
|
+
"@tetrax/voice-sdk": "git+https://github.com/your-username/tetrax-voice-sdk.git"
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
### Option 3: Monorepo / Local Workspace (Best for single-repository setups)
|
|
104
|
+
If you are developing both the backend/frontend client and the SDK together, you can keep them in the same repository.
|
|
105
|
+
* **Where it is hosted:** Included in your main project repository.
|
|
106
|
+
* **How to install:** Keep using the local link in your `package.json`:
|
|
107
|
+
```json
|
|
108
|
+
"dependencies": {
|
|
109
|
+
"@tetrax/voice-sdk": "file:../tetrax-sdk"
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
### Summary
|
|
116
|
+
* **Mediasoup/Signaling Backend:** Must be hosted on a server (like VPS, AWS, DigitalOcean) with raw ports open for WebRTC connections.
|
|
117
|
+
* **Tetrax SDK:** Hosted on **NPM** or **GitHub** as a library of code that gets bundled directly into your client's web browser builds.
|
package/package.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tetrax/voice-sdk",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "TETRAX Voice Client SDK for Mediasoup Real-Time Audio Rooms",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"mediasoup-client": "^3.20.0",
|
|
9
|
+
"socket.io-client": "^4.8.3"
|
|
10
|
+
}
|
|
11
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { io } from 'socket.io-client';
|
|
2
|
+
import { Device } from 'mediasoup-client';
|
|
3
|
+
|
|
4
|
+
class EventEmitter {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.listeners = {};
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
on(event, cb) {
|
|
10
|
+
if (!this.listeners[event]) this.listeners[event] = [];
|
|
11
|
+
this.listeners[event].push(cb);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
off(event, cb) {
|
|
15
|
+
if (!this.listeners[event]) return;
|
|
16
|
+
this.listeners[event] = this.listeners[event].filter(l => l !== cb);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
emit(event, ...args) {
|
|
20
|
+
if (!this.listeners[event]) return;
|
|
21
|
+
this.listeners[event].forEach(cb => cb(...args));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class TetraxVoice extends EventEmitter {
|
|
26
|
+
constructor({ apiKey, apiUri }) {
|
|
27
|
+
super();
|
|
28
|
+
this.apiKey = apiKey;
|
|
29
|
+
this.apiUri = apiUri;
|
|
30
|
+
this.socket = null;
|
|
31
|
+
this.device = null;
|
|
32
|
+
this.roomId = null;
|
|
33
|
+
this.localStream = null;
|
|
34
|
+
|
|
35
|
+
// Transports and Media references
|
|
36
|
+
this.sendTransport = null;
|
|
37
|
+
this.recvTransport = null;
|
|
38
|
+
this.audioProducer = null;
|
|
39
|
+
this.consumers = new Map();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Connects to the TETRAX signaling server using the token
|
|
44
|
+
*/
|
|
45
|
+
async connect(token) {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
this.socket = io(this.apiUri, {
|
|
48
|
+
auth: { token }
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
this.socket.on('connect', () => {
|
|
52
|
+
this.emit('connected');
|
|
53
|
+
resolve();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
this.socket.on('connect_error', (error) => {
|
|
57
|
+
this.emit('error', error);
|
|
58
|
+
reject(error);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
this.socket.on('new-producer', async ({ producerId, userId }) => {
|
|
62
|
+
await this._consumeProducer(producerId, userId);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
this.socket.on('user-joined', ({ userId }) => {
|
|
66
|
+
this.emit('user-joined', { userId });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
this.socket.on('user-left', ({ userId }) => {
|
|
70
|
+
this.emit('user-left', { userId });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
this.socket.on('user-muted', ({ userId }) => {
|
|
74
|
+
this.emit('user-muted', { userId });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
this.socket.on('user-unmuted', ({ userId }) => {
|
|
78
|
+
this.emit('user-unmuted', { userId });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Joins a specific audio room and sets up sending/receiving audio
|
|
85
|
+
*/
|
|
86
|
+
async joinRoom(roomId) {
|
|
87
|
+
this.roomId = roomId;
|
|
88
|
+
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
this.socket.emit('join-room', { roomId }, async (res) => {
|
|
91
|
+
if (res?.error) {
|
|
92
|
+
return reject(new Error(res.error));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
// 1. Initialize Mediasoup Device
|
|
97
|
+
this.device = new Device();
|
|
98
|
+
await this.device.load({ routerRtpCapabilities: res.rtpCapabilities });
|
|
99
|
+
|
|
100
|
+
// 2. Setup Transports and Media
|
|
101
|
+
await this._setupSendTransport(roomId);
|
|
102
|
+
await this._setupRecvTransport(roomId, res.existingProducers || []);
|
|
103
|
+
|
|
104
|
+
this.emit('joined', res);
|
|
105
|
+
resolve(res);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
reject(err);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Mute the microphone stream
|
|
115
|
+
*/
|
|
116
|
+
async mute() {
|
|
117
|
+
if (this.audioProducer) {
|
|
118
|
+
await this.audioProducer.pause();
|
|
119
|
+
this.socket.emit('mute', { roomId: this.roomId });
|
|
120
|
+
this.emit('local-mute-changed', true);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Unmute the microphone stream
|
|
126
|
+
*/
|
|
127
|
+
async unmute() {
|
|
128
|
+
if (this.audioProducer) {
|
|
129
|
+
await this.audioProducer.resume();
|
|
130
|
+
this.socket.emit('unmute', { roomId: this.roomId });
|
|
131
|
+
this.emit('local-mute-changed', false);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Leave current room and clean up media and signaling state
|
|
137
|
+
*/
|
|
138
|
+
async leaveRoom() {
|
|
139
|
+
if (this.roomId && this.socket) {
|
|
140
|
+
this.socket.emit('leave-room', { roomId: this.roomId });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (this.localStream) {
|
|
144
|
+
this.localStream.getTracks().forEach(track => track.stop());
|
|
145
|
+
this.localStream = null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (this.sendTransport) {
|
|
149
|
+
try { this.sendTransport.close(); } catch (_) {}
|
|
150
|
+
this.sendTransport = null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (this.recvTransport) {
|
|
154
|
+
try { this.recvTransport.close(); } catch (_) {}
|
|
155
|
+
this.recvTransport = null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (this.audioProducer) {
|
|
159
|
+
try { this.audioProducer.close(); } catch (_) {}
|
|
160
|
+
this.audioProducer = null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.consumers.forEach(consumer => {
|
|
164
|
+
try { consumer.close(); } catch (_) {}
|
|
165
|
+
});
|
|
166
|
+
this.consumers.clear();
|
|
167
|
+
this.device = null;
|
|
168
|
+
this.roomId = null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Disconnect entirely from signaling server
|
|
173
|
+
*/
|
|
174
|
+
disconnect() {
|
|
175
|
+
this.leaveRoom();
|
|
176
|
+
if (this.socket) {
|
|
177
|
+
this.socket.disconnect();
|
|
178
|
+
this.socket = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// --- Internal WebRTC Helpers ---
|
|
183
|
+
|
|
184
|
+
async _setupSendTransport(roomId) {
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
this.socket.emit('create-transport', { roomId, direction: 'send' }, async (sendRes) => {
|
|
187
|
+
if (sendRes?.error) throw new Error(sendRes.error);
|
|
188
|
+
|
|
189
|
+
this.sendTransport = this.device.createSendTransport(sendRes.params);
|
|
190
|
+
|
|
191
|
+
this.sendTransport.on('connect', ({ dtlsParameters }, callback, errback) => {
|
|
192
|
+
this.socket.emit('connect-transport', { roomId, transportId: this.sendTransport.id, dtlsParameters }, (connRes) => {
|
|
193
|
+
if (connRes?.error) errback(connRes.error);
|
|
194
|
+
else callback();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
this.sendTransport.on('produce', ({ kind, rtpParameters }, callback, errback) => {
|
|
199
|
+
this.socket.emit('produce', { roomId, transportId: this.sendTransport.id, kind, rtpParameters }, (prodRes) => {
|
|
200
|
+
if (prodRes?.error) errback(prodRes.error);
|
|
201
|
+
else callback({ id: prodRes.id });
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Initialize Local Capture
|
|
206
|
+
try {
|
|
207
|
+
this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
|
208
|
+
const track = this.localStream.getAudioTracks()[0];
|
|
209
|
+
if (track) {
|
|
210
|
+
this.audioProducer = await this.sendTransport.produce({ track });
|
|
211
|
+
}
|
|
212
|
+
} catch (err) {
|
|
213
|
+
this.emit('warning', 'Microphone access denied or unavailable. Joined in listen-only mode.');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
resolve();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async _setupRecvTransport(roomId, existingProducers) {
|
|
222
|
+
return new Promise((resolve) => {
|
|
223
|
+
this.socket.emit('create-transport', { roomId, direction: 'recv' }, async (recvRes) => {
|
|
224
|
+
if (recvRes?.error) throw new Error(recvRes.error);
|
|
225
|
+
|
|
226
|
+
this.recvTransport = this.device.createRecvTransport(recvRes.params);
|
|
227
|
+
|
|
228
|
+
this.recvTransport.on('connect', ({ dtlsParameters }, callback, errback) => {
|
|
229
|
+
this.socket.emit('connect-transport', { roomId, transportId: this.recvTransport.id, dtlsParameters }, (connRes) => {
|
|
230
|
+
if (connRes?.error) errback(connRes.error);
|
|
231
|
+
else callback();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Consume all initial users
|
|
236
|
+
for (const p of existingProducers) {
|
|
237
|
+
await this._consumeProducer(p.producerId, p.userId);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
resolve();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async _consumeProducer(producerId, userId) {
|
|
246
|
+
if (!this.device || !this.recvTransport) return;
|
|
247
|
+
|
|
248
|
+
this.socket.emit('consume', {
|
|
249
|
+
roomId: this.roomId,
|
|
250
|
+
transportId: this.recvTransport.id,
|
|
251
|
+
producerId,
|
|
252
|
+
rtpCapabilities: this.device.rtpCapabilities
|
|
253
|
+
}, async (res) => {
|
|
254
|
+
if (res?.error) return;
|
|
255
|
+
|
|
256
|
+
const consumer = await this.recvTransport.consume({
|
|
257
|
+
id: res.id,
|
|
258
|
+
producerId: res.producerId,
|
|
259
|
+
kind: res.kind,
|
|
260
|
+
rtpParameters: res.rtpParameters
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
this.consumers.set(consumer.id, consumer);
|
|
264
|
+
|
|
265
|
+
this.socket.emit('resume-consumer', { roomId: this.roomId, consumerId: consumer.id }, (resumedRes) => {
|
|
266
|
+
if (resumedRes?.error) return;
|
|
267
|
+
|
|
268
|
+
const stream = new MediaStream([consumer.track]);
|
|
269
|
+
this.emit('track', { track: consumer.track, stream, userId, consumerId: consumer.id });
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|