@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.
- package/dist/index.d.mts +64 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +317 -0
- package/dist/index.mjs +280 -0
- package/package.json +31 -0
- package/src/VoiceClient.ts +359 -0
- package/src/index.ts +2 -0
- package/tsconfig.json +16 -0
package/dist/index.d.mts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
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
|
+
}
|