@voicemaster/core 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.
@@ -0,0 +1,64 @@
1
+ interface VoiceClientConfig {
2
+ signalingUrl: string;
3
+ roomId: string;
4
+ userId: string;
5
+ autoConnect?: boolean;
6
+ iceServers?: RTCIceServer[];
7
+ }
8
+ interface VoiceEvents {
9
+ connected: () => void;
10
+ disconnected: () => void;
11
+ remoteStream: (stream: MediaStream) => void;
12
+ localStream: (stream: MediaStream) => void;
13
+ error: (error: Error) => void;
14
+ userJoined: (userId: string) => void;
15
+ userLeft: (userId: string) => void;
16
+ speaking: (userId: string) => void;
17
+ stoppedSpeaking: (userId: string) => void;
18
+ }
19
+ type EventHandler<T extends keyof VoiceEvents> = VoiceEvents[T];
20
+ declare class VoiceClient {
21
+ private ws;
22
+ private peers;
23
+ private localStream;
24
+ private remoteStreams;
25
+ private eventHandlers;
26
+ private userId;
27
+ private roomId;
28
+ private signalingUrl;
29
+ private iceServers;
30
+ private speakingDetectionInterval;
31
+ private audioContext;
32
+ private sourceNode;
33
+ private analyserNode;
34
+ private speakingThreshold;
35
+ private isSpeaking;
36
+ private reconnectAttempts;
37
+ private maxReconnectAttempts;
38
+ private isConnected;
39
+ constructor(config: VoiceClientConfig);
40
+ on<K extends keyof VoiceEvents>(event: K, handler: EventHandler<K>): void;
41
+ off<K extends keyof VoiceEvents>(event: K, handler: EventHandler<K>): void;
42
+ private emit;
43
+ connect(): Promise<void>;
44
+ private initMicrophone;
45
+ private setupSpeakingDetection;
46
+ private broadcastSpeakingStatus;
47
+ private initWebSocket;
48
+ private handleReconnect;
49
+ private handleSignalingMessage;
50
+ private initPeer;
51
+ private handlePeerSignal;
52
+ private closePeer;
53
+ private send;
54
+ disconnect(): void;
55
+ toggleMute(): void;
56
+ isMuted(): boolean;
57
+ setSpeakingThreshold(threshold: number): void;
58
+ getRemoteStream(userId?: string): MediaStream | null;
59
+ getPeers(): string[];
60
+ getUserId(): string;
61
+ isConnectedToRoom(): boolean;
62
+ }
63
+
64
+ export { VoiceClient, type VoiceClientConfig, type VoiceEvents };
@@ -0,0 +1,64 @@
1
+ interface VoiceClientConfig {
2
+ signalingUrl: string;
3
+ roomId: string;
4
+ userId: string;
5
+ autoConnect?: boolean;
6
+ iceServers?: RTCIceServer[];
7
+ }
8
+ interface VoiceEvents {
9
+ connected: () => void;
10
+ disconnected: () => void;
11
+ remoteStream: (stream: MediaStream) => void;
12
+ localStream: (stream: MediaStream) => void;
13
+ error: (error: Error) => void;
14
+ userJoined: (userId: string) => void;
15
+ userLeft: (userId: string) => void;
16
+ speaking: (userId: string) => void;
17
+ stoppedSpeaking: (userId: string) => void;
18
+ }
19
+ type EventHandler<T extends keyof VoiceEvents> = VoiceEvents[T];
20
+ declare class VoiceClient {
21
+ private ws;
22
+ private peers;
23
+ private localStream;
24
+ private remoteStreams;
25
+ private eventHandlers;
26
+ private userId;
27
+ private roomId;
28
+ private signalingUrl;
29
+ private iceServers;
30
+ private speakingDetectionInterval;
31
+ private audioContext;
32
+ private sourceNode;
33
+ private analyserNode;
34
+ private speakingThreshold;
35
+ private isSpeaking;
36
+ private reconnectAttempts;
37
+ private maxReconnectAttempts;
38
+ private isConnected;
39
+ constructor(config: VoiceClientConfig);
40
+ on<K extends keyof VoiceEvents>(event: K, handler: EventHandler<K>): void;
41
+ off<K extends keyof VoiceEvents>(event: K, handler: EventHandler<K>): void;
42
+ private emit;
43
+ connect(): Promise<void>;
44
+ private initMicrophone;
45
+ private setupSpeakingDetection;
46
+ private broadcastSpeakingStatus;
47
+ private initWebSocket;
48
+ private handleReconnect;
49
+ private handleSignalingMessage;
50
+ private initPeer;
51
+ private handlePeerSignal;
52
+ private closePeer;
53
+ private send;
54
+ disconnect(): void;
55
+ toggleMute(): void;
56
+ isMuted(): boolean;
57
+ setSpeakingThreshold(threshold: number): void;
58
+ getRemoteStream(userId?: string): MediaStream | null;
59
+ getPeers(): string[];
60
+ getUserId(): string;
61
+ isConnectedToRoom(): boolean;
62
+ }
63
+
64
+ export { VoiceClient, type VoiceClientConfig, type VoiceEvents };
package/dist/index.js ADDED
@@ -0,0 +1,317 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ VoiceClient: () => VoiceClient
34
+ });
35
+ module.exports = __toCommonJS(index_exports);
36
+
37
+ // src/VoiceClient.ts
38
+ var import_simple_peer = __toESM(require("simple-peer"));
39
+ var VoiceClient = class {
40
+ constructor(config) {
41
+ this.ws = null;
42
+ this.peers = /* @__PURE__ */ new Map();
43
+ this.localStream = null;
44
+ this.remoteStreams = /* @__PURE__ */ new Map();
45
+ this.eventHandlers = {};
46
+ this.speakingDetectionInterval = null;
47
+ this.audioContext = null;
48
+ this.sourceNode = null;
49
+ this.analyserNode = null;
50
+ this.speakingThreshold = 0.05;
51
+ this.isSpeaking = false;
52
+ this.reconnectAttempts = 0;
53
+ this.maxReconnectAttempts = 5;
54
+ this.isConnected = false;
55
+ this.userId = config.userId;
56
+ this.roomId = config.roomId;
57
+ this.signalingUrl = config.signalingUrl;
58
+ this.iceServers = config.iceServers || [
59
+ { urls: "stun:stun.l.google.com:19302" },
60
+ { urls: "stun:stun.relay.metered.ca:80" },
61
+ {
62
+ urls: "turn:openrelay.metered.ca:80",
63
+ username: "openrelayproject",
64
+ credential: "openrelayproject"
65
+ }
66
+ ];
67
+ if (config.autoConnect !== false) {
68
+ this.connect();
69
+ }
70
+ }
71
+ on(event, handler) {
72
+ if (!this.eventHandlers[event]) {
73
+ this.eventHandlers[event] = [];
74
+ }
75
+ this.eventHandlers[event].push(handler);
76
+ }
77
+ off(event, handler) {
78
+ const handlers = this.eventHandlers[event];
79
+ if (handlers) {
80
+ const index = handlers.indexOf(handler);
81
+ if (index !== -1) handlers.splice(index, 1);
82
+ }
83
+ }
84
+ emit(event, ...args) {
85
+ const handlers = this.eventHandlers[event];
86
+ if (handlers) {
87
+ handlers.forEach((handler) => handler(...args));
88
+ }
89
+ }
90
+ async connect() {
91
+ try {
92
+ await this.initMicrophone();
93
+ this.initWebSocket();
94
+ } catch (error) {
95
+ this.emit("error", error);
96
+ this.handleReconnect();
97
+ }
98
+ }
99
+ async initMicrophone() {
100
+ this.localStream = await navigator.mediaDevices.getUserMedia({
101
+ audio: {
102
+ echoCancellation: true,
103
+ noiseSuppression: true,
104
+ autoGainControl: true,
105
+ sampleRate: 48e3,
106
+ channelCount: 1
107
+ }
108
+ });
109
+ this.emit("localStream", this.localStream);
110
+ this.setupSpeakingDetection();
111
+ }
112
+ setupSpeakingDetection() {
113
+ if (!this.localStream) return;
114
+ this.audioContext = new AudioContext();
115
+ this.sourceNode = this.audioContext.createMediaStreamSource(this.localStream);
116
+ this.analyserNode = this.audioContext.createAnalyser();
117
+ this.analyserNode.fftSize = 256;
118
+ this.sourceNode.connect(this.analyserNode);
119
+ this.sourceNode.connect(this.audioContext.destination);
120
+ const dataArray = new Uint8Array(this.analyserNode.frequencyBinCount);
121
+ this.speakingDetectionInterval = setInterval(() => {
122
+ if (!this.analyserNode) return;
123
+ this.analyserNode.getByteTimeDomainData(dataArray);
124
+ let maxSample = 0;
125
+ for (let i = 0; i < dataArray.length; i++) {
126
+ const sample = Math.abs(dataArray[i] / 128 - 1);
127
+ if (sample > maxSample) maxSample = sample;
128
+ }
129
+ const isCurrentlySpeaking = maxSample > this.speakingThreshold;
130
+ if (isCurrentlySpeaking && !this.isSpeaking) {
131
+ this.isSpeaking = true;
132
+ this.broadcastSpeakingStatus(true);
133
+ this.emit("speaking", this.userId);
134
+ } else if (!isCurrentlySpeaking && this.isSpeaking) {
135
+ this.isSpeaking = false;
136
+ this.broadcastSpeakingStatus(false);
137
+ this.emit("stoppedSpeaking", this.userId);
138
+ }
139
+ }, 100);
140
+ }
141
+ broadcastSpeakingStatus(isSpeaking) {
142
+ this.peers.forEach((peer) => {
143
+ if (peer && peer.connected) {
144
+ peer.send(JSON.stringify({
145
+ type: "speaking",
146
+ userId: this.userId,
147
+ isSpeaking
148
+ }));
149
+ }
150
+ });
151
+ }
152
+ initWebSocket() {
153
+ const url = `${this.signalingUrl}?userId=${this.userId}&roomId=${this.roomId}`;
154
+ this.ws = new WebSocket(url);
155
+ this.ws.onopen = () => {
156
+ this.reconnectAttempts = 0;
157
+ this.isConnected = true;
158
+ this.send({
159
+ type: "join",
160
+ roomId: this.roomId,
161
+ userId: this.userId
162
+ });
163
+ };
164
+ this.ws.onmessage = (event) => {
165
+ const message = JSON.parse(event.data);
166
+ this.handleSignalingMessage(message);
167
+ };
168
+ this.ws.onclose = () => {
169
+ this.isConnected = false;
170
+ this.handleReconnect();
171
+ };
172
+ this.ws.onerror = (error) => {
173
+ this.emit("error", new Error("WebSocket error"));
174
+ };
175
+ }
176
+ handleReconnect() {
177
+ if (this.reconnectAttempts < this.maxReconnectAttempts) {
178
+ this.reconnectAttempts++;
179
+ setTimeout(() => {
180
+ this.initWebSocket();
181
+ }, 1e3 * Math.min(30, this.reconnectAttempts));
182
+ } else {
183
+ this.emit("disconnected");
184
+ }
185
+ }
186
+ handleSignalingMessage(message) {
187
+ switch (message.type) {
188
+ case "user-joined":
189
+ this.initPeer(message.userId, true);
190
+ this.emit("userJoined", message.userId);
191
+ break;
192
+ case "signal":
193
+ this.handlePeerSignal(message.userId, message.payload);
194
+ break;
195
+ case "user-left":
196
+ this.closePeer(message.userId);
197
+ this.emit("userLeft", message.userId);
198
+ break;
199
+ }
200
+ }
201
+ initPeer(targetUserId, initiator) {
202
+ if (this.peers.has(targetUserId)) return;
203
+ const peer = new import_simple_peer.default({
204
+ initiator,
205
+ trickle: true,
206
+ stream: this.localStream,
207
+ config: { iceServers: this.iceServers }
208
+ });
209
+ peer.on("signal", (data) => {
210
+ this.send({
211
+ type: "signal",
212
+ userId: targetUserId,
213
+ payload: data
214
+ });
215
+ });
216
+ peer.on("stream", (stream) => {
217
+ this.remoteStreams.set(targetUserId, stream);
218
+ this.emit("remoteStream", stream);
219
+ });
220
+ peer.on("data", (data) => {
221
+ try {
222
+ const parsed = JSON.parse(data.toString());
223
+ if (parsed.type === "speaking") {
224
+ if (parsed.isSpeaking) {
225
+ this.emit("speaking", parsed.userId);
226
+ } else {
227
+ this.emit("stoppedSpeaking", parsed.userId);
228
+ }
229
+ }
230
+ } catch (e) {
231
+ }
232
+ });
233
+ peer.on("error", (err) => {
234
+ this.emit("error", err);
235
+ });
236
+ peer.on("close", () => {
237
+ this.peers.delete(targetUserId);
238
+ });
239
+ this.peers.set(targetUserId, peer);
240
+ }
241
+ handlePeerSignal(targetUserId, signal) {
242
+ let peer = this.peers.get(targetUserId);
243
+ if (!peer) {
244
+ this.initPeer(targetUserId, false);
245
+ peer = this.peers.get(targetUserId);
246
+ }
247
+ if (peer) {
248
+ peer.signal(signal);
249
+ }
250
+ }
251
+ closePeer(userId) {
252
+ const peer = this.peers.get(userId);
253
+ if (peer) {
254
+ peer.destroy();
255
+ this.peers.delete(userId);
256
+ }
257
+ }
258
+ send(data) {
259
+ if (this.ws?.readyState === WebSocket.OPEN) {
260
+ this.ws.send(JSON.stringify(data));
261
+ }
262
+ }
263
+ disconnect() {
264
+ if (this.speakingDetectionInterval) {
265
+ clearInterval(this.speakingDetectionInterval);
266
+ }
267
+ if (this.audioContext) {
268
+ this.audioContext.close();
269
+ }
270
+ this.peers.forEach((peer) => {
271
+ peer.destroy();
272
+ });
273
+ this.peers.clear();
274
+ if (this.localStream) {
275
+ this.localStream.getTracks().forEach((track) => track.stop());
276
+ }
277
+ if (this.ws) {
278
+ this.ws.close();
279
+ }
280
+ this.emit("disconnected");
281
+ }
282
+ toggleMute() {
283
+ if (this.localStream) {
284
+ const audioTrack = this.localStream.getAudioTracks()[0];
285
+ if (audioTrack) {
286
+ audioTrack.enabled = !audioTrack.enabled;
287
+ }
288
+ }
289
+ }
290
+ isMuted() {
291
+ return this.localStream?.getAudioTracks()[0]?.enabled === false;
292
+ }
293
+ setSpeakingThreshold(threshold) {
294
+ this.speakingThreshold = Math.min(1, Math.max(0, threshold));
295
+ }
296
+ getRemoteStream(userId) {
297
+ if (userId) {
298
+ const stream = this.remoteStreams.get(userId);
299
+ return stream || null;
300
+ }
301
+ const firstStream = this.remoteStreams.values().next().value;
302
+ return firstStream || null;
303
+ }
304
+ getPeers() {
305
+ return Array.from(this.peers.keys());
306
+ }
307
+ getUserId() {
308
+ return this.userId;
309
+ }
310
+ isConnectedToRoom() {
311
+ return this.isConnected;
312
+ }
313
+ };
314
+ // Annotate the CommonJS export names for ESM import in node:
315
+ 0 && (module.exports = {
316
+ VoiceClient
317
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,280 @@
1
+ // src/VoiceClient.ts
2
+ import Peer from "simple-peer";
3
+ var VoiceClient = class {
4
+ constructor(config) {
5
+ this.ws = null;
6
+ this.peers = /* @__PURE__ */ new Map();
7
+ this.localStream = null;
8
+ this.remoteStreams = /* @__PURE__ */ new Map();
9
+ this.eventHandlers = {};
10
+ this.speakingDetectionInterval = null;
11
+ this.audioContext = null;
12
+ this.sourceNode = null;
13
+ this.analyserNode = null;
14
+ this.speakingThreshold = 0.05;
15
+ this.isSpeaking = false;
16
+ this.reconnectAttempts = 0;
17
+ this.maxReconnectAttempts = 5;
18
+ this.isConnected = false;
19
+ this.userId = config.userId;
20
+ this.roomId = config.roomId;
21
+ this.signalingUrl = config.signalingUrl;
22
+ this.iceServers = config.iceServers || [
23
+ { urls: "stun:stun.l.google.com:19302" },
24
+ { urls: "stun:stun.relay.metered.ca:80" },
25
+ {
26
+ urls: "turn:openrelay.metered.ca:80",
27
+ username: "openrelayproject",
28
+ credential: "openrelayproject"
29
+ }
30
+ ];
31
+ if (config.autoConnect !== false) {
32
+ this.connect();
33
+ }
34
+ }
35
+ on(event, handler) {
36
+ if (!this.eventHandlers[event]) {
37
+ this.eventHandlers[event] = [];
38
+ }
39
+ this.eventHandlers[event].push(handler);
40
+ }
41
+ off(event, handler) {
42
+ const handlers = this.eventHandlers[event];
43
+ if (handlers) {
44
+ const index = handlers.indexOf(handler);
45
+ if (index !== -1) handlers.splice(index, 1);
46
+ }
47
+ }
48
+ emit(event, ...args) {
49
+ const handlers = this.eventHandlers[event];
50
+ if (handlers) {
51
+ handlers.forEach((handler) => handler(...args));
52
+ }
53
+ }
54
+ async connect() {
55
+ try {
56
+ await this.initMicrophone();
57
+ this.initWebSocket();
58
+ } catch (error) {
59
+ this.emit("error", error);
60
+ this.handleReconnect();
61
+ }
62
+ }
63
+ async initMicrophone() {
64
+ this.localStream = await navigator.mediaDevices.getUserMedia({
65
+ audio: {
66
+ echoCancellation: true,
67
+ noiseSuppression: true,
68
+ autoGainControl: true,
69
+ sampleRate: 48e3,
70
+ channelCount: 1
71
+ }
72
+ });
73
+ this.emit("localStream", this.localStream);
74
+ this.setupSpeakingDetection();
75
+ }
76
+ setupSpeakingDetection() {
77
+ if (!this.localStream) return;
78
+ this.audioContext = new AudioContext();
79
+ this.sourceNode = this.audioContext.createMediaStreamSource(this.localStream);
80
+ this.analyserNode = this.audioContext.createAnalyser();
81
+ this.analyserNode.fftSize = 256;
82
+ this.sourceNode.connect(this.analyserNode);
83
+ this.sourceNode.connect(this.audioContext.destination);
84
+ const dataArray = new Uint8Array(this.analyserNode.frequencyBinCount);
85
+ this.speakingDetectionInterval = setInterval(() => {
86
+ if (!this.analyserNode) return;
87
+ this.analyserNode.getByteTimeDomainData(dataArray);
88
+ let maxSample = 0;
89
+ for (let i = 0; i < dataArray.length; i++) {
90
+ const sample = Math.abs(dataArray[i] / 128 - 1);
91
+ if (sample > maxSample) maxSample = sample;
92
+ }
93
+ const isCurrentlySpeaking = maxSample > this.speakingThreshold;
94
+ if (isCurrentlySpeaking && !this.isSpeaking) {
95
+ this.isSpeaking = true;
96
+ this.broadcastSpeakingStatus(true);
97
+ this.emit("speaking", this.userId);
98
+ } else if (!isCurrentlySpeaking && this.isSpeaking) {
99
+ this.isSpeaking = false;
100
+ this.broadcastSpeakingStatus(false);
101
+ this.emit("stoppedSpeaking", this.userId);
102
+ }
103
+ }, 100);
104
+ }
105
+ broadcastSpeakingStatus(isSpeaking) {
106
+ this.peers.forEach((peer) => {
107
+ if (peer && peer.connected) {
108
+ peer.send(JSON.stringify({
109
+ type: "speaking",
110
+ userId: this.userId,
111
+ isSpeaking
112
+ }));
113
+ }
114
+ });
115
+ }
116
+ initWebSocket() {
117
+ const url = `${this.signalingUrl}?userId=${this.userId}&roomId=${this.roomId}`;
118
+ this.ws = new WebSocket(url);
119
+ this.ws.onopen = () => {
120
+ this.reconnectAttempts = 0;
121
+ this.isConnected = true;
122
+ this.send({
123
+ type: "join",
124
+ roomId: this.roomId,
125
+ userId: this.userId
126
+ });
127
+ };
128
+ this.ws.onmessage = (event) => {
129
+ const message = JSON.parse(event.data);
130
+ this.handleSignalingMessage(message);
131
+ };
132
+ this.ws.onclose = () => {
133
+ this.isConnected = false;
134
+ this.handleReconnect();
135
+ };
136
+ this.ws.onerror = (error) => {
137
+ this.emit("error", new Error("WebSocket error"));
138
+ };
139
+ }
140
+ handleReconnect() {
141
+ if (this.reconnectAttempts < this.maxReconnectAttempts) {
142
+ this.reconnectAttempts++;
143
+ setTimeout(() => {
144
+ this.initWebSocket();
145
+ }, 1e3 * Math.min(30, this.reconnectAttempts));
146
+ } else {
147
+ this.emit("disconnected");
148
+ }
149
+ }
150
+ handleSignalingMessage(message) {
151
+ switch (message.type) {
152
+ case "user-joined":
153
+ this.initPeer(message.userId, true);
154
+ this.emit("userJoined", message.userId);
155
+ break;
156
+ case "signal":
157
+ this.handlePeerSignal(message.userId, message.payload);
158
+ break;
159
+ case "user-left":
160
+ this.closePeer(message.userId);
161
+ this.emit("userLeft", message.userId);
162
+ break;
163
+ }
164
+ }
165
+ initPeer(targetUserId, initiator) {
166
+ if (this.peers.has(targetUserId)) return;
167
+ const peer = new Peer({
168
+ initiator,
169
+ trickle: true,
170
+ stream: this.localStream,
171
+ config: { iceServers: this.iceServers }
172
+ });
173
+ peer.on("signal", (data) => {
174
+ this.send({
175
+ type: "signal",
176
+ userId: targetUserId,
177
+ payload: data
178
+ });
179
+ });
180
+ peer.on("stream", (stream) => {
181
+ this.remoteStreams.set(targetUserId, stream);
182
+ this.emit("remoteStream", stream);
183
+ });
184
+ peer.on("data", (data) => {
185
+ try {
186
+ const parsed = JSON.parse(data.toString());
187
+ if (parsed.type === "speaking") {
188
+ if (parsed.isSpeaking) {
189
+ this.emit("speaking", parsed.userId);
190
+ } else {
191
+ this.emit("stoppedSpeaking", parsed.userId);
192
+ }
193
+ }
194
+ } catch (e) {
195
+ }
196
+ });
197
+ peer.on("error", (err) => {
198
+ this.emit("error", err);
199
+ });
200
+ peer.on("close", () => {
201
+ this.peers.delete(targetUserId);
202
+ });
203
+ this.peers.set(targetUserId, peer);
204
+ }
205
+ handlePeerSignal(targetUserId, signal) {
206
+ let peer = this.peers.get(targetUserId);
207
+ if (!peer) {
208
+ this.initPeer(targetUserId, false);
209
+ peer = this.peers.get(targetUserId);
210
+ }
211
+ if (peer) {
212
+ peer.signal(signal);
213
+ }
214
+ }
215
+ closePeer(userId) {
216
+ const peer = this.peers.get(userId);
217
+ if (peer) {
218
+ peer.destroy();
219
+ this.peers.delete(userId);
220
+ }
221
+ }
222
+ send(data) {
223
+ if (this.ws?.readyState === WebSocket.OPEN) {
224
+ this.ws.send(JSON.stringify(data));
225
+ }
226
+ }
227
+ disconnect() {
228
+ if (this.speakingDetectionInterval) {
229
+ clearInterval(this.speakingDetectionInterval);
230
+ }
231
+ if (this.audioContext) {
232
+ this.audioContext.close();
233
+ }
234
+ this.peers.forEach((peer) => {
235
+ peer.destroy();
236
+ });
237
+ this.peers.clear();
238
+ if (this.localStream) {
239
+ this.localStream.getTracks().forEach((track) => track.stop());
240
+ }
241
+ if (this.ws) {
242
+ this.ws.close();
243
+ }
244
+ this.emit("disconnected");
245
+ }
246
+ toggleMute() {
247
+ if (this.localStream) {
248
+ const audioTrack = this.localStream.getAudioTracks()[0];
249
+ if (audioTrack) {
250
+ audioTrack.enabled = !audioTrack.enabled;
251
+ }
252
+ }
253
+ }
254
+ isMuted() {
255
+ return this.localStream?.getAudioTracks()[0]?.enabled === false;
256
+ }
257
+ setSpeakingThreshold(threshold) {
258
+ this.speakingThreshold = Math.min(1, Math.max(0, threshold));
259
+ }
260
+ getRemoteStream(userId) {
261
+ if (userId) {
262
+ const stream = this.remoteStreams.get(userId);
263
+ return stream || null;
264
+ }
265
+ const firstStream = this.remoteStreams.values().next().value;
266
+ return firstStream || null;
267
+ }
268
+ getPeers() {
269
+ return Array.from(this.peers.keys());
270
+ }
271
+ getUserId() {
272
+ return this.userId;
273
+ }
274
+ isConnectedToRoom() {
275
+ return this.isConnected;
276
+ }
277
+ };
278
+ export {
279
+ VoiceClient
280
+ };
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@voicemaster/core",
3
+ "version": "1.0.0",
4
+ "description": "WebRTC voice communication core library",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
17
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "keywords": ["webrtc", "voice", "audio", "p2p", "typescript"],
21
+ "author": "Sergey Minasyan",
22
+ "license": "MIT",
23
+ "dependencies": {
24
+ "simple-peer": "^9.11.1"
25
+ },
26
+ "devDependencies": {
27
+ "@types/simple-peer": "^9.11.8",
28
+ "tsup": "^8.0.0",
29
+ "typescript": "^5.3.0"
30
+ }
31
+ }
@@ -0,0 +1,359 @@
1
+ import Peer from 'simple-peer';
2
+
3
+ export interface VoiceClientConfig {
4
+ signalingUrl: string;
5
+ roomId: string;
6
+ userId: string;
7
+ autoConnect?: boolean;
8
+ iceServers?: RTCIceServer[];
9
+ }
10
+
11
+ export interface VoiceEvents {
12
+ connected: () => void;
13
+ disconnected: () => void;
14
+ remoteStream: (stream: MediaStream) => void;
15
+ localStream: (stream: MediaStream) => void;
16
+ error: (error: Error) => void;
17
+ userJoined: (userId: string) => void;
18
+ userLeft: (userId: string) => void;
19
+ speaking: (userId: string) => void;
20
+ stoppedSpeaking: (userId: string) => void;
21
+ }
22
+
23
+ type EventHandler<T extends keyof VoiceEvents> = VoiceEvents[T];
24
+
25
+ export class VoiceClient {
26
+ private ws: WebSocket | null = null;
27
+ private peers: Map<string, Peer.Instance> = new Map();
28
+ private localStream: MediaStream | null = null;
29
+ private remoteStreams: Map<string, MediaStream> = new Map();
30
+ private eventHandlers: Partial<Record<keyof VoiceEvents, Function[]>> = {};
31
+ private userId: string;
32
+ private roomId: string;
33
+ private signalingUrl: string;
34
+ private iceServers: RTCIceServer[];
35
+ private speakingDetectionInterval: NodeJS.Timeout | null = null;
36
+ private audioContext: AudioContext | null = null;
37
+ private sourceNode: MediaStreamAudioSourceNode | null = null;
38
+ private analyserNode: AnalyserNode | null = null;
39
+ private speakingThreshold = 0.05;
40
+ private isSpeaking = false;
41
+ private reconnectAttempts = 0;
42
+ private maxReconnectAttempts = 5;
43
+ private isConnected = false;
44
+
45
+ constructor(config: VoiceClientConfig) {
46
+ this.userId = config.userId;
47
+ this.roomId = config.roomId;
48
+ this.signalingUrl = config.signalingUrl;
49
+ this.iceServers = config.iceServers || [
50
+ { urls: 'stun:stun.l.google.com:19302' },
51
+ { urls: 'stun:stun.relay.metered.ca:80' },
52
+ {
53
+ urls: 'turn:openrelay.metered.ca:80',
54
+ username: 'openrelayproject',
55
+ credential: 'openrelayproject'
56
+ }
57
+ ];
58
+
59
+ if (config.autoConnect !== false) {
60
+ this.connect();
61
+ }
62
+ }
63
+
64
+ on<K extends keyof VoiceEvents>(event: K, handler: EventHandler<K>): void {
65
+ if (!this.eventHandlers[event]) {
66
+ this.eventHandlers[event] = [];
67
+ }
68
+ this.eventHandlers[event]!.push(handler);
69
+ }
70
+
71
+ off<K extends keyof VoiceEvents>(event: K, handler: EventHandler<K>): void {
72
+ const handlers = this.eventHandlers[event];
73
+ if (handlers) {
74
+ const index = handlers.indexOf(handler);
75
+ if (index !== -1) handlers.splice(index, 1);
76
+ }
77
+ }
78
+
79
+ private emit<K extends keyof VoiceEvents>(event: K, ...args: Parameters<EventHandler<K>>): void {
80
+ const handlers = this.eventHandlers[event];
81
+ if (handlers) {
82
+ handlers.forEach(handler => handler(...args as any));
83
+ }
84
+ }
85
+
86
+ async connect(): Promise<void> {
87
+ try {
88
+ await this.initMicrophone();
89
+ this.initWebSocket();
90
+ } catch (error) {
91
+ this.emit('error', error as Error);
92
+ this.handleReconnect();
93
+ }
94
+ }
95
+
96
+ private async initMicrophone(): Promise<void> {
97
+ this.localStream = await navigator.mediaDevices.getUserMedia({
98
+ audio: {
99
+ echoCancellation: true,
100
+ noiseSuppression: true,
101
+ autoGainControl: true,
102
+ sampleRate: 48000,
103
+ channelCount: 1
104
+ }
105
+ });
106
+
107
+ this.emit('localStream', this.localStream);
108
+ this.setupSpeakingDetection();
109
+ }
110
+
111
+ private setupSpeakingDetection(): void {
112
+ if (!this.localStream) return;
113
+
114
+ this.audioContext = new AudioContext();
115
+ this.sourceNode = this.audioContext.createMediaStreamSource(this.localStream);
116
+ this.analyserNode = this.audioContext.createAnalyser();
117
+ this.analyserNode.fftSize = 256;
118
+
119
+ this.sourceNode.connect(this.analyserNode);
120
+ this.sourceNode.connect(this.audioContext.destination);
121
+
122
+ const dataArray = new Uint8Array(this.analyserNode.frequencyBinCount);
123
+
124
+ this.speakingDetectionInterval = setInterval(() => {
125
+ if (!this.analyserNode) return;
126
+
127
+ this.analyserNode.getByteTimeDomainData(dataArray);
128
+ let maxSample = 0;
129
+
130
+ for (let i = 0; i < dataArray.length; i++) {
131
+ const sample = Math.abs(dataArray[i] / 128 - 1);
132
+ if (sample > maxSample) maxSample = sample;
133
+ }
134
+
135
+ const isCurrentlySpeaking = maxSample > this.speakingThreshold;
136
+
137
+ if (isCurrentlySpeaking && !this.isSpeaking) {
138
+ this.isSpeaking = true;
139
+ this.broadcastSpeakingStatus(true);
140
+ this.emit('speaking', this.userId);
141
+ } else if (!isCurrentlySpeaking && this.isSpeaking) {
142
+ this.isSpeaking = false;
143
+ this.broadcastSpeakingStatus(false);
144
+ this.emit('stoppedSpeaking', this.userId);
145
+ }
146
+ }, 100);
147
+ }
148
+
149
+ private broadcastSpeakingStatus(isSpeaking: boolean): void {
150
+ this.peers.forEach((peer) => {
151
+ if (peer && peer.connected) {
152
+ peer.send(JSON.stringify({
153
+ type: 'speaking',
154
+ userId: this.userId,
155
+ isSpeaking
156
+ }));
157
+ }
158
+ });
159
+ }
160
+
161
+ private initWebSocket(): void {
162
+ const url = `${this.signalingUrl}?userId=${this.userId}&roomId=${this.roomId}`;
163
+ this.ws = new WebSocket(url);
164
+
165
+ this.ws.onopen = () => {
166
+ this.reconnectAttempts = 0;
167
+ this.isConnected = true;
168
+ this.send({
169
+ type: 'join',
170
+ roomId: this.roomId,
171
+ userId: this.userId
172
+ });
173
+ };
174
+
175
+ this.ws.onmessage = (event) => {
176
+ const message = JSON.parse(event.data);
177
+ this.handleSignalingMessage(message);
178
+ };
179
+
180
+ this.ws.onclose = () => {
181
+ this.isConnected = false;
182
+ this.handleReconnect();
183
+ };
184
+
185
+ this.ws.onerror = (error) => {
186
+ this.emit('error', new Error('WebSocket error'));
187
+ };
188
+ }
189
+
190
+ private handleReconnect(): void {
191
+ if (this.reconnectAttempts < this.maxReconnectAttempts) {
192
+ this.reconnectAttempts++;
193
+ setTimeout(() => {
194
+ this.initWebSocket();
195
+ }, 1000 * Math.min(30, this.reconnectAttempts));
196
+ } else {
197
+ this.emit('disconnected');
198
+ }
199
+ }
200
+
201
+ private handleSignalingMessage(message: any): void {
202
+ switch (message.type) {
203
+ case 'user-joined':
204
+ this.initPeer(message.userId, true);
205
+ this.emit('userJoined', message.userId);
206
+ break;
207
+
208
+ case 'signal':
209
+ this.handlePeerSignal(message.userId, message.payload);
210
+ break;
211
+
212
+ case 'user-left':
213
+ this.closePeer(message.userId);
214
+ this.emit('userLeft', message.userId);
215
+ break;
216
+ }
217
+ }
218
+
219
+ private initPeer(targetUserId: string, initiator: boolean): void {
220
+ if (this.peers.has(targetUserId)) return;
221
+
222
+ const peer = new Peer({
223
+ initiator,
224
+ trickle: true,
225
+ stream: this.localStream!,
226
+ config: { iceServers: this.iceServers }
227
+ });
228
+
229
+ peer.on('signal', (data) => {
230
+ this.send({
231
+ type: 'signal',
232
+ userId: targetUserId,
233
+ payload: data
234
+ });
235
+ });
236
+
237
+ peer.on('stream', (stream) => {
238
+ this.remoteStreams.set(targetUserId, stream);
239
+ this.emit('remoteStream', stream);
240
+ });
241
+
242
+ peer.on('data', (data) => {
243
+ try {
244
+ const parsed = JSON.parse(data.toString());
245
+ if (parsed.type === 'speaking') {
246
+ if (parsed.isSpeaking) {
247
+ this.emit('speaking', parsed.userId);
248
+ } else {
249
+ this.emit('stoppedSpeaking', parsed.userId);
250
+ }
251
+ }
252
+ } catch (e) {
253
+ // Binary data, ignore
254
+ }
255
+ });
256
+
257
+ peer.on('error', (err) => {
258
+ this.emit('error', err);
259
+ });
260
+
261
+ peer.on('close', () => {
262
+ this.peers.delete(targetUserId);
263
+ });
264
+
265
+ this.peers.set(targetUserId, peer);
266
+ }
267
+
268
+ private handlePeerSignal(targetUserId: string, signal: any): void {
269
+ let peer = this.peers.get(targetUserId);
270
+
271
+ if (!peer) {
272
+ this.initPeer(targetUserId, false);
273
+ peer = this.peers.get(targetUserId);
274
+ }
275
+
276
+ if (peer) {
277
+ peer.signal(signal);
278
+ }
279
+ }
280
+
281
+ private closePeer(userId: string): void {
282
+ const peer = this.peers.get(userId);
283
+ if (peer) {
284
+ peer.destroy();
285
+ this.peers.delete(userId);
286
+ }
287
+ }
288
+
289
+ private send(data: any): void {
290
+ if (this.ws?.readyState === WebSocket.OPEN) {
291
+ this.ws.send(JSON.stringify(data));
292
+ }
293
+ }
294
+
295
+ disconnect(): void {
296
+ if (this.speakingDetectionInterval) {
297
+ clearInterval(this.speakingDetectionInterval);
298
+ }
299
+
300
+ if (this.audioContext) {
301
+ this.audioContext.close();
302
+ }
303
+
304
+ this.peers.forEach((peer) => {
305
+ peer.destroy();
306
+ });
307
+ this.peers.clear();
308
+
309
+ if (this.localStream) {
310
+ this.localStream.getTracks().forEach(track => track.stop());
311
+ }
312
+
313
+ if (this.ws) {
314
+ this.ws.close();
315
+ }
316
+
317
+ this.emit('disconnected');
318
+ }
319
+
320
+ toggleMute(): void {
321
+ if (this.localStream) {
322
+ const audioTrack = this.localStream.getAudioTracks()[0];
323
+ if (audioTrack) {
324
+ audioTrack.enabled = !audioTrack.enabled;
325
+ }
326
+ }
327
+ }
328
+
329
+ isMuted(): boolean {
330
+ return this.localStream?.getAudioTracks()[0]?.enabled === false;
331
+ }
332
+
333
+ setSpeakingThreshold(threshold: number): void {
334
+ this.speakingThreshold = Math.min(1, Math.max(0, threshold));
335
+ }
336
+
337
+ getRemoteStream(userId?: string): MediaStream | null {
338
+ if (userId) {
339
+ const stream = this.remoteStreams.get(userId);
340
+ return stream || null;
341
+ }
342
+ const firstStream = this.remoteStreams.values().next().value;
343
+ return firstStream || null;
344
+ }
345
+
346
+ getPeers(): string[] {
347
+ return Array.from(this.peers.keys());
348
+ }
349
+
350
+ getUserId(): string {
351
+ return this.userId;
352
+ }
353
+
354
+ isConnectedToRoom(): boolean {
355
+ return this.isConnected;
356
+ }
357
+ }
358
+
359
+ export default VoiceClient;
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { VoiceClient } from './VoiceClient';
2
+ export type { VoiceClientConfig, VoiceEvents } from './VoiceClient';
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "lib": ["ES2020", "DOM"],
6
+ "moduleResolution": "bundler",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "resolveJsonModule": true,
12
+ "noEmit": true
13
+ },
14
+ "include": ["src/**/*"],
15
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
16
+ }